diff --git a/+labkit/+app/+diagnostic/Artifact.m b/+labkit/+app/+diagnostic/Artifact.m new file mode 100644 index 000000000..8726a1fb3 --- /dev/null +++ b/+labkit/+app/+diagnostic/Artifact.m @@ -0,0 +1,99 @@ +classdef (Sealed) Artifact + %ARTIFACT Describe one anonymous diagnostic-sample artifact. + % + % Usage: + % artifact = labkit.app.diagnostic.Artifact( ... + % id,role,relativePath,Name=Value) + % + % Description: + % Identifies one synthetic input, expected export, or support file + % beneath a diagnostic sample root without exposing a user file path. + % + % Inputs: + % id - Nonempty semantic identifier unique within a SamplePack. + % role - Nonempty App-owned artifact purpose. + % relativePath - Nonempty diagnostic-root-relative path without + % traversal. + % + % Optional Name-Value Arguments: + % Expectation - "loads", "rejects", "exports", or "support". + % Default: "loads". + % + % Outputs: + % artifact - Immutable diagnostic artifact value. + % + % Errors: + % labkit:app:contract:UnknownArgument - An option is unknown, + % duplicated, or unpaired. + % labkit:app:contract:InvalidValue - Text, path, or Expectation is + % malformed. + % + % Example: + % artifact = labkit.app.diagnostic.Artifact( ... + % "input","source","samples/input.csv"); + % assert(artifact.Expectation == "loads") + % + % See also labkit.app.diagnostic.SampleContext, + % labkit.app.diagnostic.SamplePack + + properties (SetAccess = immutable) + Id (1, 1) string + Role (1, 1) string + RelativePath (1, 1) string + Expectation (1, 1) string + end + + methods + function obj = Artifact(id, role, relativePath, varargin) + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.diagnostic.Artifact", "Expectation", ... + varargin{:}); + obj.Id = nonemptyText(id, "Id"); + obj.Role = nonemptyText(role, "Role"); + obj.RelativePath = relativePathValue(relativePath); + expectation = optionValue(options, "Expectation", "loads"); + expectation = nonemptyText(expectation, "Expectation"); + if ~any(expectation == ... + ["loads", "rejects", "exports", "support"]) + error("labkit:app:contract:InvalidValue", ... + "Diagnostic Artifact Expectation is unsupported."); + end + obj.Expectation = expectation; + end + end +end + +function value = relativePathValue(value) +value = replace(nonemptyText(value, "RelativePath"), "\", "/"); +if startsWith(value, "/") || startsWith(value, "//") || ... + ~isempty(regexp(char(value), '^[A-Za-z]:', "once")) + invalid("RelativePath must be diagnostic-root-relative."); +end +parts = split(value, "/"); +if any(parts == ["", ".", ".."], "all") + invalid("RelativePath must not traverse diagnostic boundaries."); +end +value = join(parts, "/"); +end + +function value = nonemptyText(value, name) +if ~(ischar(value) || (isstring(value) && isscalar(value))) + invalid("%s must be scalar text.", name); +end +value = string(value); +if strlength(value) == 0 + invalid("%s must be nonempty.", name); +end +end + +function value = optionValue(options, name, defaultValue) +value = defaultValue; +if isfield(options, name) + value = options.(name); +end +end + +function invalid(message, varargin) +error("labkit:app:contract:InvalidValue", ... + "Diagnostic Artifact " + message, varargin{:}); +end diff --git a/+labkit/+app/+diagnostic/Options.m b/+labkit/+app/+diagnostic/Options.m new file mode 100644 index 000000000..d7a16e2d1 --- /dev/null +++ b/+labkit/+app/+diagnostic/Options.m @@ -0,0 +1,86 @@ +classdef (Sealed) Options + %OPTIONS Configure one App SDK diagnostic session. + % + % Usage: + % options = labkit.app.diagnostic.Options() + % options = labkit.app.diagnostic.Options(Name=Value) + % + % Description: + % Options selects standard or verbose runtime recording and whether a + % Definition should build its declared anonymous synthetic sample. + % Omitting Diagnostics from Definition.launch is equivalent to the + % default standard options. This value never exposes a runtime, + % recorder, figure registry, or callback transport. + % + % Optional Name-Value Arguments: + % Level - "standard" or "verbose". Standard keeps a bounded in-memory + % diagnostic history; verbose additionally writes structured + % artifacts when ArtifactFolder is nonempty. Default: "standard". + % ArtifactFolder - Scalar diagnostic-session folder. Empty keeps + % recording in memory only. Default: "". + % Sample - "none" or "synthetic". Synthetic requires the Definition's + % BuildDebugSample contract. Default: "none". + % + % Outputs: + % options - Immutable diagnostic configuration. + % + % Errors: + % labkit:app:contract:UnknownArgument - An option is unknown, + % duplicated, or unpaired. + % labkit:app:contract:InvalidValue - A supplied value is malformed or + % outside its documented legal set. + % + % Example: + % options = labkit.app.diagnostic.Options(Level="verbose"); + % assert(options.Level == "verbose") + % + % See also labkit.app.Definition, + % labkit.app.diagnostic.SampleContext, + % labkit.app.diagnostic.SamplePack + + properties (SetAccess = immutable) + Level (1, 1) string + ArtifactFolder (1, 1) string + Sample (1, 1) string + end + + methods + function obj = Options(varargin) + names = ["Level", "ArtifactFolder", "Sample"]; + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.diagnostic.Options", names, varargin{:}); + obj.Level = oneOf(optionValue( ... + options, "Level", "standard"), ... + ["standard", "verbose"], "Level"); + obj.ArtifactFolder = scalarText(optionValue( ... + options, "ArtifactFolder", ""), "ArtifactFolder"); + obj.Sample = oneOf(optionValue( ... + options, "Sample", "none"), ... + ["none", "synthetic"], "Sample"); + end + end +end + +function value = oneOf(value, legal, name) +value = scalarText(value, name); +if ~any(value == legal) + error("labkit:app:contract:InvalidValue", ... + "Diagnostic Options %s must be %s.", ... + name, strjoin(legal, " or ")); +end +end + +function value = scalarText(value, name) +if ~(ischar(value) || (isstring(value) && isscalar(value))) + error("labkit:app:contract:InvalidValue", ... + "Diagnostic Options %s must be scalar text.", name); +end +value = string(value); +end + +function value = optionValue(options, name, defaultValue) +value = defaultValue; +if isfield(options, name) + value = options.(name); +end +end diff --git a/+labkit/+app/+diagnostic/SampleContext.m b/+labkit/+app/+diagnostic/SampleContext.m new file mode 100644 index 000000000..8c3247726 --- /dev/null +++ b/+labkit/+app/+diagnostic/SampleContext.m @@ -0,0 +1,162 @@ +classdef (Sealed) SampleContext + %SAMPLECONTEXT Provide bounded folders for anonymous debug samples. + % + % Usage: + % context = labkit.app.diagnostic.SampleContext(artifactFolder) + % filepath = context.samplePath(relativePath) + % filepath = context.outputPath(relativePath) + % record = context.sourceRecord(id,role,filepath,required) + % artifact = context.artifact(id,role,filepath,Name=Value) + % + % Description: + % SampleContext creates one diagnostic root with samples and outputs + % children. App-owned BuildDebugSample callbacks may write only + % anonymous synthetic files beneath these folders. The value does not + % expose a runtime, recorder, project store, or UI object. + % + % Inputs: + % artifactFolder - Nonempty scalar diagnostic-session folder. + % relativePath - Nonempty child path without an absolute root, empty + % segment, current segment, or parent traversal. + % id - Stable portable-source identifier. + % role - Stable portable-source role. + % filepath - Path returned by samplePath. + % required - Logical scalar source requirement. + % + % Outputs: + % context - Immutable diagnostic sample context. + % filepath - Absolute path bounded by SampleFolder or OutputFolder. + % record - Portable source value from + % labkit.app.project.sourceRecord. + % artifact - Typed diagnostic artifact whose relative path is derived + % from a filepath beneath ArtifactFolder. + % + % Errors: + % labkit:app:contract:InvalidValue - A folder, relative path, or source + % argument is malformed. + % MATLAB filesystem errors propagate when the diagnostic folders cannot + % be created. + % + % Typical Call: + % context = labkit.app.diagnostic.SampleContext(tempname); + % filepath = context.samplePath("input.csv"); + % + % See also labkit.app.diagnostic.Artifact, + % labkit.app.diagnostic.SamplePack, + % labkit.app.project.sourceRecord + + properties (SetAccess = immutable) + ArtifactFolder (1, 1) string + SampleFolder (1, 1) string + OutputFolder (1, 1) string + end + + methods + function obj = SampleContext(artifactFolder) + artifactFolder = nonemptyText( ... + artifactFolder, "ArtifactFolder"); + obj.ArtifactFolder = artifactFolder; + obj.SampleFolder = string(fullfile( ... + char(artifactFolder), "samples")); + obj.OutputFolder = string(fullfile( ... + char(artifactFolder), "outputs")); + ensureFolder(obj.ArtifactFolder); + ensureFolder(obj.SampleFolder); + ensureFolder(obj.OutputFolder); + end + + function filepath = samplePath(obj, relativePath) + filepath = boundedPath(obj.SampleFolder, relativePath); + end + + function filepath = outputPath(obj, relativePath) + filepath = boundedPath(obj.OutputFolder, relativePath); + end + + function record = sourceRecord(~, id, role, filepath, required) + if nargin < 5 + required = true; + end + record = labkit.app.project.sourceRecord( ... + id, role, filepath, required); + end + + function artifact = artifact(obj, id, role, filepath, varargin) + relativePath = relativeArtifactPath( ... + obj.ArtifactFolder, filepath); + artifact = labkit.app.diagnostic.Artifact( ... + id, role, relativePath, varargin{:}); + end + end +end + +function filepath = boundedPath(folder, relativePath) +relativePath = replace(nonemptyText(relativePath, "relativePath"), "\", "/"); +if startsWith(relativePath, "/") || startsWith(relativePath, "//") || ... + ~isempty(regexp(char(relativePath), '^[A-Za-z]:', "once")) + invalid("relativePath must be folder-relative."); +end +parts = split(relativePath, "/"); +if any(parts == ["", ".", ".."], "all") + invalid("relativePath must not traverse the diagnostic folder."); +end +partCells = cellstr(parts); +filepath = string(fullfile(char(folder), partCells{:})); +parent = string(fileparts(filepath)); +ensureFolder(parent); +end + +function ensureFolder(folder) +if exist(char(folder), "dir") ~= 7 + mkdir(char(folder)); +end +end + +function relativePath = relativeArtifactPath(root, filepath) +root = normalizedAbsolutePath(root, "ArtifactFolder"); +filepath = normalizedAbsolutePath(filepath, "filepath"); +rootPrefix = root + "/"; +if ispc + inside = startsWith(lower(filepath), lower(rootPrefix)); +else + inside = startsWith(filepath, rootPrefix); +end +if ~inside + invalid("filepath must remain beneath ArtifactFolder."); +end +relativePath = extractAfter(filepath, strlength(rootPrefix)); +if strlength(relativePath) == 0 + invalid("filepath must name a child artifact."); +end +end + +function value = normalizedAbsolutePath(value, name) +value = replace(nonemptyText(value, name), "\", "/"); +if ~isAbsolutePath(value) + value = replace(string(fullfile(pwd, char(value))), "\", "/"); +end +while endsWith(value, "/") && value ~= "/" && ... + isempty(regexp(char(value), '^[A-Za-z]:/$', "once")) + value = extractBefore(value, strlength(value)); +end +end + +function tf = isAbsolutePath(value) +tf = startsWith(value, "/") || startsWith(value, "//") || ... + ~isempty(regexp(char(value), '^[A-Za-z]:/', "once")); +end + +function value = nonemptyText(value, name) +if ~(ischar(value) || (isstring(value) && isscalar(value))) + invalid("%s must be scalar text.", name); +end +value = string(value); +if strlength(value) == 0 + invalid("%s must be nonempty.", name); +end +end + +function invalid(message, varargin) +error("labkit:app:contract:InvalidValue", ... + "Diagnostic SampleContext " + message, varargin{:}); +end diff --git a/+labkit/+app/+diagnostic/SamplePack.m b/+labkit/+app/+diagnostic/SamplePack.m new file mode 100644 index 000000000..382f1a88e --- /dev/null +++ b/+labkit/+app/+diagnostic/SamplePack.m @@ -0,0 +1,101 @@ +classdef (Sealed) SamplePack + %SAMPLEPACK Describe one typed anonymous App reproduction scenario. + % + % Usage: + % pack = labkit.app.diagnostic.SamplePack( ... + % Scenario=scenario,InitialProject=project,Artifacts=artifacts) + % + % Description: + % Couples one App-authored synthetic project with its anonymous artifact + % declarations so verbose diagnostics can reproduce a named scenario. + % + % Required Name-Value Arguments: + % Scenario - Nonempty stable scenario identifier. + % InitialProject - Scalar current App project struct. + % Artifacts - Row cell array of labkit.app.diagnostic.Artifact values. + % Empty is legal for an App whose scenario needs no files. + % + % Outputs: + % pack - Immutable diagnostic sample pack. + % + % Errors: + % labkit:app:contract:UnknownArgument - An argument is missing, unknown, + % duplicated, or unpaired. + % labkit:app:contract:InvalidValue - Scenario, project, or Artifacts is + % malformed. + % labkit:app:contract:DuplicateId - Artifact IDs or relative paths are + % duplicated. + % + % Example: + % artifact = labkit.app.diagnostic.Artifact( ... + % "input","source","samples/input.csv"); + % pack = labkit.app.diagnostic.SamplePack( ... + % Scenario="representative",InitialProject=struct(), ... + % Artifacts={artifact}); + % assert(pack.Scenario == "representative") + % + % See also labkit.app.diagnostic.SampleContext, + % labkit.app.diagnostic.Artifact, + % labkit.app.Definition + + properties (SetAccess = immutable) + Scenario (1, 1) string + InitialProject (1, 1) struct + Artifacts (1, :) cell + end + + methods + function obj = SamplePack(varargin) + names = ["Scenario", "InitialProject", "Artifacts"]; + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.diagnostic.SamplePack", names, varargin{:}); + for name = names + if ~isfield(options, name) + error("labkit:app:contract:UnknownArgument", ... + "labkit.app.diagnostic.SamplePack requires %s.", ... + name); + end + end + obj.Scenario = nonemptyText(options.Scenario, "Scenario"); + if ~isstruct(options.InitialProject) || ... + ~isscalar(options.InitialProject) + invalid("InitialProject must be a scalar struct."); + end + obj.InitialProject = options.InitialProject; + artifacts = options.Artifacts; + if ~iscell(artifacts) || ... + (~isempty(artifacts) && ~isrow(artifacts)) || ... + ~all(cellfun(@(value) isa(value, ... + "labkit.app.diagnostic.Artifact") && isscalar(value), ... + artifacts)) + invalid("Artifacts must be a row cell array of Artifact values."); + end + artifacts = reshape(artifacts, 1, []); + ids = string(cellfun(@(value) value.Id, artifacts, ... + "UniformOutput", false)); + paths = string(cellfun(@(value) value.RelativePath, artifacts, ... + "UniformOutput", false)); + if numel(unique(ids)) ~= numel(ids) || ... + numel(unique(paths)) ~= numel(paths) + error("labkit:app:contract:DuplicateId", ... + "Diagnostic SamplePack artifact IDs and paths must be unique."); + end + obj.Artifacts = artifacts; + end + end +end + +function value = nonemptyText(value, name) +if ~(ischar(value) || (isstring(value) && isscalar(value))) + invalid("%s must be scalar text.", name); +end +value = string(value); +if strlength(value) == 0 + invalid("%s must be nonempty.", name); +end +end + +function invalid(message, varargin) +error("labkit:app:contract:InvalidValue", ... + "Diagnostic SamplePack " + message, varargin{:}); +end diff --git a/+labkit/+app/+dialog/Choice.m b/+labkit/+app/+dialog/Choice.m new file mode 100644 index 000000000..2badbb7e1 --- /dev/null +++ b/+labkit/+app/+dialog/Choice.m @@ -0,0 +1,53 @@ +classdef (Sealed) Choice + %DIALOGRESULT Represent a typed dialog choice or cancellation. + % + % Usage: + % result = labkit.app.dialog.Choice(value) + % result = labkit.app.dialog.Choice(value, Cancelled=cancelled) + % + % Description: + % Choice separates cancellation from the chosen value so an empty + % string, zero, false, or empty App value is not interpreted as cancel. + % + % Inputs: + % value - App-facing value returned by the dialog. + % + % Name-Value Arguments: + % Cancelled - Logical scalar. Default: false. + % + % Outputs: + % result - Immutable labkit.app.dialog.Choice value. + % + % Errors: + % labkit:app:contract:UnknownArgument - An option is unknown, duplicated, + % or unpaired. + % labkit:app:contract:InvalidValue - Cancelled is not logical scalar. + % + % Example: + % result = labkit.app.dialog.Choice("", Cancelled=false); + % assert(~result.Cancelled) + % + % See also labkit.app.CallbackContext + + properties (SetAccess = immutable) + Value + Cancelled (1, 1) logical + end + + methods + function obj = Choice(value, varargin) + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.dialog.Choice", "Cancelled", varargin{:}); + cancelled = false; + if isfield(options, "Cancelled") + cancelled = options.Cancelled; + end + if ~(islogical(cancelled) && isscalar(cancelled)) + error("labkit:app:contract:InvalidValue", ... + "Choice Cancelled must be a logical scalar."); + end + obj.Value = value; + obj.Cancelled = cancelled; + end + end +end diff --git a/+labkit/+app/+event/IntervalScroll.m b/+labkit/+app/+event/IntervalScroll.m new file mode 100644 index 000000000..25784b233 --- /dev/null +++ b/+labkit/+app/+event/IntervalScroll.m @@ -0,0 +1,63 @@ +classdef (Sealed) IntervalScroll + %INTERVALSCROLL Describe one normalized interval scroll gesture. + % + % Usage: + % event = labkit.app.event.IntervalScroll(Anchor=anchor, Count=count) + % + % Description: + % IntervalScroll replaces native MATLAB scroll events and untyped + % structs with the horizontal data coordinate under the pointer and the + % signed vertical scroll count used by interval interactions. + % + % Required Name-Value Arguments: + % Anchor - Finite scalar horizontal data coordinate. + % Count - Finite nonzero scalar vertical scroll count. + % + % Outputs: + % event - Immutable labkit.app.event.IntervalScroll value. + % + % Errors: + % labkit:app:contract:UnknownArgument - An argument is missing, unknown, + % duplicated, or unpaired. + % labkit:app:contract:InvalidValue - Anchor or Count is malformed. + % + % Example: + % event = labkit.app.event.IntervalScroll(Anchor=0.25, Count=-1); + % assert(event.Anchor == 0.25) + % + % See also labkit.app.interaction.interval + + properties (SetAccess = immutable) + Anchor (1, 1) double + Count (1, 1) double + end + + methods + function obj = IntervalScroll(varargin) + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.event.IntervalScroll", ... + ["Anchor", "Count"], varargin{:}); + for name = ["Anchor", "Count"] + if ~isfield(options, name) + error("labkit:app:contract:UnknownArgument", ... + "labkit.app.event.IntervalScroll requires argument %s.", ... + name); + end + end + obj.Anchor = finiteScalar(options.Anchor, "Anchor"); + obj.Count = finiteScalar(options.Count, "Count"); + if obj.Count == 0 + error("labkit:app:contract:InvalidValue", ... + "IntervalScroll Count must be nonzero."); + end + end + end +end + +function value = finiteScalar(value, name) +if ~(isnumeric(value) && isscalar(value) && isfinite(value)) + error("labkit:app:contract:InvalidValue", ... + "IntervalScroll %s must be a finite numeric scalar.", name); +end +value = double(value); +end diff --git a/+labkit/+app/+event/ListSelection.m b/+labkit/+app/+event/ListSelection.m new file mode 100644 index 000000000..77bdc67fb --- /dev/null +++ b/+labkit/+app/+event/ListSelection.m @@ -0,0 +1,85 @@ +classdef (Sealed) ListSelection + %LISTSELECTION Describe selected file or list item identities. + % + % Usage: + % selection = labkit.app.event.ListSelection(Name=Value) + % + % Description: + % ListSelection carries stable item IDs, positive display indices, or + % both for file panels and list controls. When both are supplied they + % have equal lengths and matching order. + % + % Optional Name-Value Arguments: + % Ids - Unique row string or cellstr array. Default: empty. + % Indices - Unique positive integer numeric row. Default: empty. + % + % Outputs: + % selection - Immutable labkit.app.event.ListSelection value. + % + % Errors: + % labkit:app:contract:UnknownArgument - An option is unknown, duplicated, + % or unpaired. + % labkit:app:contract:InvalidValue - IDs or indices are malformed or + % have inconsistent lengths. + % + % Example: + % selection = labkit.app.event.ListSelection( ... + % Ids=["sample-a", "sample-b"], Indices=[1 3]); + % assert(isequal(selection.Indices, [1 3])) + % + % See also labkit.app.event.TableCellSelection, + % labkit.app.layout.fileList + + properties (SetAccess = immutable) + Ids (1, :) string + Indices (1, :) double + end + + methods + function obj = ListSelection(varargin) + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.event.ListSelection", ["Ids", "Indices"], varargin{:}); + obj.Ids = ids(optionValue(options, "Ids", strings(1, 0))); + obj.Indices = indices( ... + optionValue(options, "Indices", zeros(1, 0))); + if ~isempty(obj.Ids) && ~isempty(obj.Indices) && ... + numel(obj.Ids) ~= numel(obj.Indices) + error("labkit:app:contract:InvalidValue", ... + "Selection Ids and Indices must have equal lengths."); + end + end + end +end + +function values = ids(values) + if ischar(values) + values = string(values); + elseif iscellstr(values) + values = string(values); + elseif ~isstring(values) + error("labkit:app:contract:InvalidValue", ... + "Selection Ids must be text."); + end + values = reshape(values, 1, []); + if any(strlength(values) == 0) || numel(unique(values)) ~= numel(values) + error("labkit:app:contract:InvalidValue", ... + "Selection Ids must be unique nonempty text."); + end +end + +function values = indices(values) + if ~(isnumeric(values) && (isempty(values) || isrow(values)) && ... + all(isfinite(values)) && all(values >= 1) && ... + all(values == fix(values)) && numel(unique(values)) == numel(values)) + error("labkit:app:contract:InvalidValue", ... + "Selection Indices must be unique positive integers."); + end + values = double(reshape(values, 1, [])); +end + +function value = optionValue(options, name, defaultValue) + value = defaultValue; + if isfield(options, name) + value = options.(name); + end +end diff --git a/+labkit/+app/+event/TableCellEdit.m b/+labkit/+app/+event/TableCellEdit.m new file mode 100644 index 000000000..5190efac0 --- /dev/null +++ b/+labkit/+app/+event/TableCellEdit.m @@ -0,0 +1,102 @@ +classdef (Sealed) TableCellEdit + %TABLEEDIT Describe one validated table-cell edit signal. + % + % Usage: + % edit = labkit.app.event.TableCellEdit(Name=Value) + % + % Description: + % TableEdit replaces raw MATLAB CellEditData and event metadata with + % stable row/column identity, the previous and proposed values, and the + % complete proposed table data when the callback must interpret pasted + % or related rows atomically. + % + % Required Name-Value Arguments: + % RowIndex - Positive integer row index. + % ColumnIndex - Positive integer column index. + % PreviousValue - App-owned value before the edit. + % NewValue - App-owned proposed value. + % + % Optional Name-Value Arguments: + % RowId - Empty or nonempty scalar text stable across sorting. Default: + % empty. + % ColumnId - Empty or nonempty scalar text stable across display-name + % changes. Default: empty. + % Data - Complete proposed table data after the edit. Default: empty. + % + % Outputs: + % edit - Immutable labkit.app.event.TableCellEdit value. + % + % Errors: + % labkit:app:contract:UnknownArgument - An option is missing, unknown, + % duplicated, or unpaired. + % labkit:app:contract:InvalidValue - An index or ID is malformed. + % + % Example: + % edit = labkit.app.event.TableCellEdit(RowIndex=2, ColumnIndex=3, ... + % ColumnId="group", PreviousValue="A", NewValue="B"); + % assert(edit.ColumnId == "group") + % + % See also labkit.app.layout.dataTable, labkit.app.event.ListSelection + + properties (SetAccess = immutable) + RowId (1, 1) string + RowIndex (1, 1) double + ColumnId (1, 1) string + ColumnIndex (1, 1) double + PreviousValue + NewValue + Data + end + + methods + function obj = TableCellEdit(varargin) + names = ["RowId", "RowIndex", "ColumnId", "ColumnIndex", ... + "PreviousValue", "NewValue", "Data"]; + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.event.TableCellEdit", names, varargin{:}); + for name = ["RowIndex", "ColumnIndex", ... + "PreviousValue", "NewValue"] + if ~isfield(options, name) + error("labkit:app:contract:UnknownArgument", ... + "labkit.app.event.TableCellEdit requires argument %s.", name); + end + end + obj.RowId = optionalText(options, "RowId"); + obj.RowIndex = positiveIndex(options.RowIndex, "RowIndex"); + obj.ColumnId = optionalText(options, "ColumnId"); + obj.ColumnIndex = positiveIndex( ... + options.ColumnIndex, "ColumnIndex"); + obj.PreviousValue = options.PreviousValue; + obj.NewValue = options.NewValue; + obj.Data = optionValue(options, "Data", []); + end + end +end + +function value = optionValue(options, name, defaultValue) + value = defaultValue; + if isfield(options, name) + value = options.(name); + end +end + +function value = positiveIndex(value, name) + if ~(isnumeric(value) && isscalar(value) && isfinite(value) && ... + value >= 1 && value == fix(value)) + error("labkit:app:contract:InvalidValue", ... + "TableEdit %s must be a positive integer.", name); + end + value = double(value); +end + +function value = optionalText(options, name) + value = ""; + if isfield(options, name) + supplied = options.(name); + if ~(ischar(supplied) || (isstring(supplied) && isscalar(supplied))) + error("labkit:app:contract:InvalidValue", ... + "TableEdit %s must be scalar text.", name); + end + value = string(supplied); + end +end diff --git a/+labkit/+app/+event/TableCellSelection.m b/+labkit/+app/+event/TableCellSelection.m new file mode 100644 index 000000000..f1467fea8 --- /dev/null +++ b/+labkit/+app/+event/TableCellSelection.m @@ -0,0 +1,49 @@ +classdef (Sealed) TableCellSelection + %TABLECELLSELECTION Describe selected cells in a semantic data table. + % + % Usage: + % selection = labkit.app.event.TableCellSelection(cellIndices) + % + % Description: + % TableCellSelection carries unique N-by-2 row/column index pairs. + % It replaces native MATLAB table-selection event shapes at the App + % callback boundary. + % + % Inputs: + % cellIndices - Unique positive integer N-by-2 matrix. An empty + % selection is zeros(0,2). + % + % Outputs: + % selection - Immutable TableCellSelection value. + % + % Errors: + % labkit:app:contract:InvalidValue - cellIndices is not a unique + % positive integer N-by-2 matrix. + % + % Example: + % selection = labkit.app.event.TableCellSelection([1 2; 3 1]); + % assert(isequal(selection.CellIndices, [1 2; 3 1])) + % + % See also labkit.app.event.TableCellEdit, + % labkit.app.layout.dataTable + + properties (SetAccess = immutable) + CellIndices (:, 2) double + end + + methods + function obj = TableCellSelection(cellIndices) + if ~(isnumeric(cellIndices) && size(cellIndices, 2) == 2 && ... + all(isfinite(cellIndices), "all") && ... + all(cellIndices >= 1, "all") && ... + all(cellIndices == fix(cellIndices), "all") && ... + size(unique(cellIndices, "rows"), 1) == ... + size(cellIndices, 1)) + error("labkit:app:contract:InvalidValue", ... + "TableCellSelection requires unique positive " + ... + "row/column index pairs."); + end + obj.CellIndices = double(cellIndices); + end + end +end diff --git a/+labkit/+app/+interaction/anchorPath.m b/+labkit/+app/+interaction/anchorPath.m new file mode 100644 index 000000000..19472a247 --- /dev/null +++ b/+labkit/+app/+interaction/anchorPath.m @@ -0,0 +1,40 @@ +function spec = anchorPath(id, onChanged, varargin) +%ANCHORPATH Declare an editable open or closed path on one plot axis. +% +% Usage: +% spec = labkit.app.interaction.anchorPath(id, onChanged, Name=Value) +% +% Description: +% Creates the semantic declaration for a managed multi-anchor path editor; +% the runtime owns native graphics, viewport preservation, and dispatch. +% +% Inputs: +% id - Unique MATLAB identifier for this interaction. +% onChanged - Callback state = callback(state,points,context). +% +% Options: +% Axis - Axis ID within the owning plotArea. Default: "main". +% Style - Scalar struct of anchor editor visual options. Default: struct(). +% Instruction - Scalar user guidance text. Default: "". +% ViewportPolicy - "preserve" or "fit". Default: "preserve". +% +% Outputs: +% spec - Immutable interaction declaration accepted by layout.plotArea. +% +% Errors: +% Throws labkit:app:contract:* for invalid IDs, options, or callbacks. +% +% Typical Call: +% spec = labkit.app.interaction.anchorPath( ... +% "curve", @changeCurve, Style=struct("closed", false)); +% +% See also labkit.app.layout.plotArea, labkit.app.view.Snapshot +spec = makeSpec("anchorPath", id, onChanged, ... + ["Axis", "Style", "Instruction", "ViewportPolicy"], varargin{:}); +end + +function spec = makeSpec(kind, id, callback, names, varargin) +options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.interaction." + kind, names, varargin{:}); +spec = labkit.app.internal.InteractionSpec(kind, id, callback, options); +end diff --git a/+labkit/+app/+interaction/interpolateAnchorPath.m b/+labkit/+app/+interaction/interpolateAnchorPath.m new file mode 100644 index 000000000..1edbdb303 --- /dev/null +++ b/+labkit/+app/+interaction/interpolateAnchorPath.m @@ -0,0 +1,65 @@ +function [curve, owners] = interpolateAnchorPath(points, imageSize, options) +%INTERPOLATEANCHORPATH Build a visible path through image anchor points. +% +% Usage: +% curve = labkit.app.interaction.interpolateAnchorPath(points, imageSize) +% curve = labkit.app.interaction.interpolateAnchorPath(points, imageSize, Name=Value) +% [curve, owners] = labkit.app.interaction.interpolateAnchorPath(...) +% +% Inputs: +% points - N-by-2 numeric matrix of [x y] anchor coordinates in image-pixel +% coordinates. +% imageSize - Numeric vector whose first two elements are the positive finite +% image height and width. +% options - Name-value argument container for Style and Closed. Supply these +% values by name; do not pass an options struct. +% +% Name-Value Arguments: +% Style - "Curve" for a Catmull-Rom path or "Straight lines" for line +% segments joining the anchors. Default: "Curve". +% Closed - Logical scalar. true joins the last anchor to the first and +% requires at least three anchors. false requires at least two. Default: +% false. +% +% Outputs: +% curve - M-by-2 path samples. Coordinates are limited to pixel-edge bounds +% [0.5, width+0.5] and [0.5, height+0.5]. The result is empty until the +% selected open or closed path has enough anchors. +% owners - (M-1)-by-1 anchor-segment indices used to associate each visible +% curve segment with its starting anchor. It is empty with curve. +% +% Description: +% anchorPath contains the deterministic geometry shared by managed anchor +% editors and app previews. Curved paths pass through the supplied anchors; +% two-point open curves reduce to a straight segment. The function creates no +% graphics and can be used independently of a LabKit app. +% +% Errors: +% MATLAB argument-validation or assertion errors are raised when points is +% not N-by-2, imageSize lacks positive finite height and width, Style is +% unsupported, or a named argument has an incompatible type or shape. +% +% Example: +% points = [10 30; 30 10; 50 30]; +% curve = labkit.app.interaction.interpolateAnchorPath( ... +% points, [40 60], "Style", "Straight lines"); +% assert(isequal(curve, points)) +% +% See also labkit.app.interaction.anchorPath + + arguments + points double + imageSize double + options.Style (1, 1) string = "Curve" + options.Closed (1, 1) logical = false + end + assert(isempty(points) || size(points, 2) == 2, ... + 'Anchor points must be an N-by-2 numeric array.'); + assert(numel(imageSize) >= 2 && all(isfinite(imageSize(1:2))) && ... + all(imageSize(1:2) > 0), ... + 'Image size must contain positive finite height and width values.'); + assert(any(options.Style == ["Curve", "Straight lines"]), ... + 'Style must be "Curve" or "Straight lines".'); + [curve, owners] = anchorCurvePoints(points, imageSize, ... + options.Style, options.Closed); +end diff --git a/+labkit/+app/+interaction/interval.m b/+labkit/+app/+interaction/interval.m new file mode 100644 index 000000000..3ef6720db --- /dev/null +++ b/+labkit/+app/+interaction/interval.m @@ -0,0 +1,39 @@ +function spec = interval(id, onChanged, varargin) +%INTERVAL Declare an editable one-dimensional plot interval. +% +% Usage: +% spec = labkit.app.interaction.interval(id,onChanged,Name=Value) +% +% Description: +% Creates the semantic declaration for a managed interval editor and its +% optional typed scroll callback on one plot axis. +% +% Inputs: +% id - Unique MATLAB identifier. +% onChanged - Callback state = callback(state,range,context). +% +% Options: +% Axis - Axis ID. Default: "main". +% Style - Scalar visual-option struct. Default: struct(). +% Instruction - Scalar guidance text. Default: "". +% ViewportPolicy - "preserve" or "fit". Default: "preserve". +% OnScrolled - Optional callback state = callback(state,event,context), +% where event is labkit.app.event.IntervalScroll. Default: []. +% +% Outputs: +% spec - Immutable interaction declaration. +% +% Errors: +% Throws labkit:app:contract:* for invalid values. +% +% Typical Call: +% spec = labkit.app.interaction.interval("window",@changeWindow); +% +% See also labkit.app.layout.plotArea, +% labkit.app.event.IntervalScroll +options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.interaction.interval", ... + ["Axis", "Style", "Instruction", "ViewportPolicy", "OnScrolled"], ... + varargin{:}); +spec = labkit.app.internal.InteractionSpec("interval",id,onChanged,options); +end diff --git a/+labkit/+app/+interaction/pairedAnchors.m b/+labkit/+app/+interaction/pairedAnchors.m new file mode 100644 index 000000000..301a123a1 --- /dev/null +++ b/+labkit/+app/+interaction/pairedAnchors.m @@ -0,0 +1,40 @@ +function spec = pairedAnchors(id, onChanged, varargin) +%PAIREDANCHORS Declare matching editable points across plot axes. +% +% Usage: +% spec = labkit.app.interaction.pairedAnchors(id,onChanged,Name=Value) +% +% Description: +% Creates the semantic declaration for corresponding editable points across +% two or more axes while the runtime owns their native editors. +% +% Inputs: +% id - Unique MATLAB identifier. +% onChanged - Callback state = callback(state,pointSets,context). +% +% Options: +% Axes - Two or more axis IDs within the owning plotArea. Required. +% Style - Scalar visual-option struct. Default: struct(). +% Instruction - Scalar guidance text. Default: "". +% ViewportPolicy - "preserve" or "fit". Default: "preserve". +% +% Outputs: +% spec - Immutable interaction declaration. +% +% Errors: +% Throws labkit:app:contract:* for invalid values. +% +% Typical Call: +% spec = labkit.app.interaction.pairedAnchors("matches",@changeMatches); +% +% See also labkit.app.layout.plotArea +options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.interaction.pairedAnchors", ... + ["Axes", "Style", "Instruction", "ViewportPolicy"], varargin{:}); +if ~isfield(options, "Axes") + error("labkit:app:contract:UnknownArgument", ... + "labkit.app.interaction.pairedAnchors requires Axes."); +end +spec = labkit.app.internal.InteractionSpec( ... + "pairedAnchors", id, onChanged, options); +end diff --git a/+labkit/+app/+interaction/pointSlots.m b/+labkit/+app/+interaction/pointSlots.m new file mode 100644 index 000000000..45f3d5dd5 --- /dev/null +++ b/+labkit/+app/+interaction/pointSlots.m @@ -0,0 +1,35 @@ +function spec = pointSlots(id, onChanged, varargin) +%POINTSLOTS Declare a fixed set of editable labeled point positions. +% +% Usage: +% spec = labkit.app.interaction.pointSlots(id,onChanged,Name=Value) +% +% Description: +% Creates the semantic declaration for a fixed, labeled set of editable +% points whose structured value is committed by the runtime. +% +% Inputs: +% id - Unique MATLAB identifier. +% onChanged - Callback state = callback(state,value,context). +% +% Options: +% Axis - Axis ID. Default: "main". +% Style - Scalar visual-option struct. Default: struct(). +% Instruction - Scalar guidance text. Default: "". +% ViewportPolicy - "preserve" or "fit". Default: "preserve". +% +% Outputs: +% spec - Immutable interaction declaration. +% +% Errors: +% Throws labkit:app:contract:* for invalid values. +% +% Typical Call: +% spec = labkit.app.interaction.pointSlots("markers",@changeMarkers); +% +% See also labkit.app.layout.plotArea +options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.interaction.pointSlots", ... + ["Axis", "Style", "Instruction", "ViewportPolicy"], varargin{:}); +spec = labkit.app.internal.InteractionSpec("pointSlots",id,onChanged,options); +end diff --git a/+labkit/+ui/+interaction/private/anchorCurvePoints.m b/+labkit/+app/+interaction/private/anchorCurvePoints.m similarity index 98% rename from +labkit/+ui/+interaction/private/anchorCurvePoints.m rename to +labkit/+app/+interaction/private/anchorCurvePoints.m index ba98887bd..b5a7d6822 100644 --- a/+labkit/+ui/+interaction/private/anchorCurvePoints.m +++ b/+labkit/+app/+interaction/private/anchorCurvePoints.m @@ -5,7 +5,7 @@ %ANCHORCURVEPOINTS Build displayed anchor path samples for curve editors. % % Expected caller: -% Runtime V2 anchor editors and sibling private insertion helpers. +% App runtime anchor editors and sibling private insertion helpers. % % Inputs: % points - N-by-2 anchor coordinates in image pixel coordinates. diff --git a/+labkit/+ui/+interaction/private/defaultScaleBarUnits.m b/+labkit/+app/+interaction/private/defaultScaleBarUnits.m similarity index 77% rename from +labkit/+ui/+interaction/private/defaultScaleBarUnits.m rename to +labkit/+app/+interaction/private/defaultScaleBarUnits.m index d2817a2ec..79a2885a3 100644 --- a/+labkit/+ui/+interaction/private/defaultScaleBarUnits.m +++ b/+labkit/+app/+interaction/private/defaultScaleBarUnits.m @@ -1,11 +1,11 @@ -% Private scale-bar unit defaults. Expected caller: labkit.ui.interaction scale-bar +% Private scale-bar unit defaults. Expected caller: labkit.app.interaction scale-bar % helpers. No inputs; output is the app-neutral unit label order. No side % effects or app-specific assumptions. function units = defaultScaleBarUnits() %DEFAULTSCALEBARUNITS Return the app-neutral scale-bar unit order. % % Expected caller: -% Public labkit.ui scale-bar helpers. +% Public labkit.app scale-bar helpers. % % Inputs/outputs: % No inputs. Returns a row cellstr used for scale-bar unit controls and diff --git a/+labkit/+ui/+interaction/private/normalizeScaleBarUnit.m b/+labkit/+app/+interaction/private/normalizeScaleBarUnit.m similarity index 87% rename from +labkit/+ui/+interaction/private/normalizeScaleBarUnit.m rename to +labkit/+app/+interaction/private/normalizeScaleBarUnit.m index e92b393ef..4ac1c51e1 100644 --- a/+labkit/+ui/+interaction/private/normalizeScaleBarUnit.m +++ b/+labkit/+app/+interaction/private/normalizeScaleBarUnit.m @@ -1,12 +1,12 @@ % Private scale-bar unit normalization helper. Expected caller: -% labkit.ui.interaction scale-bar panel/calibration code. Inputs are a candidate unit, +% labkit.app.interaction scale-bar calibration code. Inputs are a candidate unit, % allowed unit labels, and fallback unit; output is a valid string scalar. No % side effects. function unitName = normalizeScaleBarUnit(unitName, units, defaultUnit) %NORMALIZESCALEBARUNIT Normalize a scale-bar unit against allowed UI units. % % Expected caller: -% labkit.ui scale-bar calibration and control helpers. +% labkit.app scale-bar calibration helpers. % % Inputs/outputs: % unitName - string-like candidate value. diff --git a/+labkit/+app/+interaction/rectangle.m b/+labkit/+app/+interaction/rectangle.m new file mode 100644 index 000000000..d5ea0c445 --- /dev/null +++ b/+labkit/+app/+interaction/rectangle.m @@ -0,0 +1,38 @@ +function spec = rectangle(id, onChanged, varargin) +%RECTANGLE Declare an editable rectangular plot region. +% +% Usage: +% spec = labkit.app.interaction.rectangle(id,onChanged,Name=Value) +% +% Description: +% Creates the semantic declaration for a persistent editable rectangle, +% including an optional background-point callback on the same axis. +% +% Inputs: +% id - Unique MATLAB identifier. +% onChanged - Callback state = callback(state,position,context). +% +% Options: +% Axis - Axis ID. Default: "main". +% Style - Scalar visual-option struct. Default: struct(). +% Instruction - Scalar guidance text. Default: "". +% ViewportPolicy - "preserve" or "fit". Default: "preserve". +% OnBackgroundPressed - Optional callback +% state = callback(state,point,context). Default: []. +% +% Outputs: +% spec - Immutable interaction declaration. +% +% Errors: +% Throws labkit:app:contract:* for invalid values. +% +% Typical Call: +% spec = labkit.app.interaction.rectangle("crop",@moveCrop); +% +% See also labkit.app.layout.plotArea +options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.interaction.rectangle", ... + ["Axis", "Style", "Instruction", "ViewportPolicy", ... + "OnBackgroundPressed"], varargin{:}); +spec = labkit.app.internal.InteractionSpec("rectangle",id,onChanged,options); +end diff --git a/+labkit/+app/+interaction/regionSelection.m b/+labkit/+app/+interaction/regionSelection.m new file mode 100644 index 000000000..b4238f2bf --- /dev/null +++ b/+labkit/+app/+interaction/regionSelection.m @@ -0,0 +1,40 @@ +function spec = regionSelection(id, onSelected, varargin) +%REGIONSELECTION Declare a transient click-or-drag region gesture. +% +% Usage: +% spec = labkit.app.interaction.regionSelection(id,onSelected,Name=Value) +% +% Description: +% Creates the semantic declaration for a transient click-or-drag selection +% whose result is delivered without exposing native interaction objects. +% +% Inputs: +% id - Unique MATLAB identifier. +% onSelected - Callback state = callback(state,position,context). +% +% Options: +% Axis - Axis ID. Default: "main". +% Style - Scalar visual-option struct. Default: struct(). +% Instruction - Scalar guidance text. Default: "". +% ViewportPolicy - "preserve" or "fit". Default: "preserve". +% OnBackgroundPressed - Optional point callback. Default: []. +% +% Outputs: +% spec - Immutable interaction declaration. +% +% Errors: +% Throws labkit:app:contract:* for invalid values. +% +% Typical Call: +% spec = labkit.app.interaction.regionSelection( ... +% "temperatureRegion",@measureRegion, ... +% OnBackgroundPressed=@measurePoint); +% +% See also labkit.app.layout.plotArea +options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.interaction.regionSelection", ... + ["Axis", "Style", "Instruction", "ViewportPolicy", ... + "OnBackgroundPressed"], varargin{:}); +spec = labkit.app.internal.InteractionSpec( ... + "regionSelection",id,onSelected,options); +end diff --git a/+labkit/+ui/+interaction/scaleBarGeometry.m b/+labkit/+app/+interaction/scaleBarGeometry.m similarity index 87% rename from +labkit/+ui/+interaction/scaleBarGeometry.m rename to +labkit/+app/+interaction/scaleBarGeometry.m index 17d5deba4..167acdfbc 100644 --- a/+labkit/+ui/+interaction/scaleBarGeometry.m +++ b/+labkit/+app/+interaction/scaleBarGeometry.m @@ -2,7 +2,7 @@ %SCALEBARGEOMETRY Compute serializable image scale-bar overlay geometry. % % Usage: -% geometry = labkit.ui.interaction.scaleBarGeometry(imageSize, ... +% geometry = labkit.app.interaction.scaleBarGeometry(imageSize, ... % calibration, barLength, position, colorName) % % Inputs: @@ -38,32 +38,32 @@ % creates no graphics and is suitable for saved project state. % % Errors: -% labkit:ui:interaction:InvalidImageSize - imageSize lacks positive finite +% labkit:app:interaction:InvalidImageSize - imageSize lacks positive finite % height and width. -% labkit:ui:interaction:InvalidScaleBar - calibration or barLength is not a +% labkit:app:interaction:InvalidScaleBar - calibration or barLength is not a % positive finite scalar. -% labkit:ui:interaction:ScaleBarTooLong - The requested bar does not fit +% labkit:app:interaction:ScaleBarTooLong - The requested bar does not fit % inside the horizontal margins. % % Example: -% cal = labkit.ui.interaction.scaleBarCalibration(80, 20, "mm"); -% geometry = labkit.ui.interaction.scaleBarGeometry( ... +% cal = labkit.app.interaction.scaleCalibration(80, 20, "mm"); +% geometry = labkit.app.interaction.scaleBarGeometry( ... % [600 800], cal, 10, "Bottom right", "White"); % assert(abs(diff(geometry.line(:,1))) == 40) % -% See also labkit.ui.interaction.scaleBarCalibration +% See also labkit.app.interaction.scaleCalibration imageSize = double(imageSize); assert(numel(imageSize) >= 2 && all(isfinite(imageSize(1:2))) && ... all(imageSize(1:2) > 0), ... - 'labkit:ui:interaction:InvalidImageSize', ... + 'labkit:app:interaction:InvalidImageSize', ... 'Image size must provide positive finite height and width.'); pixelsPerUnit = double(calibration.pixelsPerUnit); barLength = double(barLength); assert(isscalar(pixelsPerUnit) && isfinite(pixelsPerUnit) && ... pixelsPerUnit > 0 && isscalar(barLength) && ... isfinite(barLength) && barLength > 0, ... - 'labkit:ui:interaction:InvalidScaleBar', ... + 'labkit:app:interaction:InvalidScaleBar', ... 'A positive calibration and scale-bar length are required.'); height = imageSize(1); width = imageSize(2); @@ -75,7 +75,7 @@ margin = max(minimumMarginPixels, min(width, height) * marginFraction); available = width - 2 * margin; assert(barPixels <= available, ... - 'labkit:ui:interaction:ScaleBarTooLong', ... + 'labkit:app:interaction:ScaleBarTooLong', ... ['Scale bar is %.6g px, but the image only has %.6g px ' ... 'available horizontally.'], barPixels, available); position = string(position); diff --git a/+labkit/+app/+interaction/scaleCalibration.m b/+labkit/+app/+interaction/scaleCalibration.m new file mode 100644 index 000000000..8896aff17 --- /dev/null +++ b/+labkit/+app/+interaction/scaleCalibration.m @@ -0,0 +1,125 @@ +function cal = scaleCalibration(referencePixels, referenceLength, unitName, opts) +%SCALECALIBRATION Convert a known image distance into pixels per unit. +% +% Usage: +% cal = labkit.app.interaction.scaleCalibration(referencePixels, ... +% referenceLength, unitName) +% cal = labkit.app.interaction.scaleCalibration(..., opts) +% +% Inputs: +% referencePixels - Measured reference distance in image pixels. Empty, +% nonnumeric, nonfinite, or nonpositive values are treated as missing. +% referenceLength - Physical reference distance expressed in unitName. +% Missing, nonfinite, or negative values become 0. +% unitName - Unit label. With default options, legal values are "m", "cm", +% "mm", "um", and "nm". An unsupported value uses defaultUnit. +% opts - Optional scalar struct described below. Default: struct(). +% +% Options: +% units - Allowed unit labels. Default: {'m','cm','mm','um','nm'}. +% defaultUnit - Fallback unit. Default: the first entry in units. +% referenceLine - N-by-2 numeric reference points stored with the result. +% When it contains exactly two rows and referencePixels is missing, their +% Euclidean distance supplies referencePixels. Default: zeros(0,2). +% +% Outputs: +% cal - Scalar struct with the fields described below. +% +% Calibration Fields: +% referencePixels - Positive measured pixel distance, or NaN when missing. +% referenceLength - Nonnegative physical reference distance. +% unit - Normalized unit label as a character vector. +% pixelsPerUnit - referencePixels/referenceLength, or 0 when calibration is +% incomplete. +% isCalibrated - true when pixelsPerUnit is positive. +% referenceLine - Normalized N-by-2 numeric reference coordinates. +% +% Description: +% This function builds a serializable calibration value; it does not read an +% image or draw a scale bar. Repeating the call with identical inputs returns +% identical numeric fields. Divide a pixel distance by pixelsPerUnit to obtain +% a distance in cal.unit. +% +% Failure Behavior: +% Missing or invalid measurement values are normalized into an uncalibrated +% result instead of throwing. opts must be a scalar structure whose units +% can be converted to text and whose referenceLine can be converted to an +% N-by-2 numeric array; incompatible MATLAB values propagate conversion +% errors. +% +% Example: +% cal = labkit.app.interaction.scaleCalibration(80, 20, "mm"); +% physicalLength = 40 / cal.pixelsPerUnit; +% assert(cal.isCalibrated && physicalLength == 10) +% +% See also labkit.app.interaction.scaleBarGeometry + + if nargin < 1 || isempty(referencePixels) + referencePixels = NaN; + end + if nargin < 2 || isempty(referenceLength) + referenceLength = 0; + end + if nargin < 3 || isempty(unitName) + unitName = ""; + end + if nargin < 4 + opts = struct(); + end + + units = cellstr(string(optionValue(opts, 'units', defaultScaleBarUnits()))); + defaultUnit = char(string(optionValue(opts, 'defaultUnit', units{1}))); + referenceLine = normalizeReferenceLine(optionValue(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); + unitName = char(normalizeScaleBarUnit(unitName, units, defaultUnit)); + pixelsPerUnit = 0; + if isfinite(referencePixels) && referencePixels > 0 && referenceLength > 0 + pixelsPerUnit = referencePixels / referenceLength; + end + + cal = struct( ... + 'referencePixels', referencePixels, ... + 'referenceLength', referenceLength, ... + 'unit', unitName, ... + 'pixelsPerUnit', pixelsPerUnit, ... + 'isCalibrated', pixelsPerUnit > 0, ... + 'referenceLine', referenceLine); +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 value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isstruct(opts) && isfield(opts, name) + value = opts.(name); + end +end diff --git a/+labkit/+app/+interaction/scaleReference.m b/+labkit/+app/+interaction/scaleReference.m new file mode 100644 index 000000000..f636d15e4 --- /dev/null +++ b/+labkit/+app/+interaction/scaleReference.m @@ -0,0 +1,36 @@ +function spec = scaleReference(id, onChanged, varargin) +%SCALEREFERENCE Declare an editable two-point scale reference. +% +% Usage: +% spec = labkit.app.interaction.scaleReference(id,onChanged,Name=Value) +% +% Description: +% Creates the semantic declaration for an editable two-endpoint physical +% scale reference while the runtime owns its native editor lifecycle. +% +% Inputs: +% id - Unique MATLAB identifier. +% onChanged - Callback state = callback(state,endpoints,context). +% +% Options: +% Axis - Axis ID. Default: "main". +% Style - Scalar visual-option struct. Default: struct(). +% Instruction - Scalar guidance text. Default: "". +% ViewportPolicy - "preserve" or "fit". Default: "preserve". +% +% Outputs: +% spec - Immutable interaction declaration. +% +% Errors: +% Throws labkit:app:contract:* for invalid values. +% +% Typical Call: +% spec = labkit.app.interaction.scaleReference("scale",@changeScale); +% +% See also labkit.app.layout.plotArea +options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.interaction.scaleReference", ... + ["Axis", "Style", "Instruction", "ViewportPolicy"], varargin{:}); +spec = labkit.app.internal.InteractionSpec( ... + "scaleReference",id,onChanged,options); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m new file mode 100644 index 000000000..af01cf5e2 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m @@ -0,0 +1,662 @@ +classdef (Hidden, Sealed) MatlabPlatformAdapter < handle + % Private semantic-plan adapter for native MATLAB UI components. + % + % This is deliberately not a registry API. It owns native handles behind + % semantic IDs and only consumes the compiled Application plan plus a + % complete Presentation. RuntimeKernel is its production-only caller. + % Native callbacks call typed RuntimeKernel entry points only. Native + % handles stay inside this adapter and never become an App API. + + properties (Access = private) + Plan (1, 1) struct + Figure + Components + Axes + Layouts + WorkbenchControls + WorkbenchWorkspace + Runtime + InteractionController + InteractionDeclarations (1, :) cell = {} + BaseWindowTitle (1, 1) string = "LabKit application" + Busy (1, 1) logical = false + PriorPointer (1, 1) string = "arrow" + ClosePrompt + DialogFolders + Starting (1, 1) logical = false + StartupStarted + StartupPanel + StartupLabel + end + + methods (Access = { ... + ?labkit.app.internal.RuntimeKernel, ... + ?labkit.app.internal.RuntimeContractBoundary}) + function obj = MatlabPlatformAdapter(plan, title) + obj.Plan = labkit.app.internal.NativeAdapterValues.validatePlan(plan); + if nargin < 2 + title = "LabKit application"; + end + obj.BaseWindowTitle = string(title); + obj.Components = containers.Map("KeyType", "char", ... + "ValueType", "any"); + obj.Axes = containers.Map("KeyType", "char", ... + "ValueType", "any"); + obj.Layouts = containers.Map("KeyType", "char", ... + "ValueType", "any"); + obj.DialogFolders = containers.Map("KeyType", "char", ... + "ValueType", "char"); + policy = labkit.app.internal.NativeAdapterValues.layoutPolicy(); + obj.Figure = uifigure(Visible="off", ... + Name=char(obj.BaseWindowTitle), ... + Position=policy.InitialFigurePosition); + obj.Starting = true; + obj.StartupStarted = tic; + obj.PriorPointer = string(obj.Figure.Pointer); + obj.Figure.Pointer = "watch"; + setappdata(obj.Figure, "labkitAppBusy", true); + obj.buildTree(); + obj.createStartupSurface(); + obj.startupUpdate("Preparing runtime..."); + end + + function attachRuntime(obj, runtime) + if ~isa(runtime, "labkit.app.internal.RuntimeKernel") + error("labkit:app:runtime:InvariantFailure", ... + "MATLAB platform adapter requires its RuntimeKernel."); + end + obj.Runtime = runtime; + obj.Figure.CloseRequestFcn = @(~, ~) obj.requestClose(); + obj.Figure.WindowKeyPressFcn = @(~, event) obj.onKeyPress(event); + obj.installCallbacks(); + obj.installUtilityMenus(); + obj.InteractionDeclarations = obj.collectInteractionDeclarations(); + targets = obj.interactionTargetAxes(); + if ~isempty(targets) + obj.InteractionController = labkit.app.internal.NativeAdapterValues.interactionController( ... + obj.Figure, targets, ... + @(id, signal, value) ... + runtime.applyInteraction(id, signal, value)); + end + end + + function reconcile(obj, previous, view) + if ~isa(view, "labkit.app.view.Snapshot") + error("labkit:app:contract:InvalidValue", ... + "MATLAB platform adapter requires a Presentation value."); + end + try + obj.applyView(view); + catch cause + if isa(previous, "labkit.app.view.Snapshot") + try + obj.applyView(previous); + catch rollbackCause + failure = MException( ... + "labkit:app:runtime:InvariantFailure", ... + "MATLAB presentation commit and rollback both failed."); + failure = addCause(failure, cause); + failure = addCause(failure, rollbackCause); + throw(failure); + end + end + rethrow(cause); + end + end + + function close(obj) + if ~isempty(obj.InteractionController) + obj.InteractionController.delete(); + obj.InteractionController = []; + end + if ~isempty(obj.Figure) && isvalid(obj.Figure) + delete(obj.Figure); + end + end + + function value = figureForRuntime(obj) + value = obj.Figure; + end + + function show(obj, title) + if ~isempty(obj.Figure) && isvalid(obj.Figure) + obj.setWindowTitle(title); + obj.Figure.Visible = "on"; + end + end + + function setWindowTitle(obj, title) + obj.BaseWindowTitle = string(title); + if ~obj.Busy && ~isempty(obj.Figure) && isvalid(obj.Figure) + obj.Figure.Name = char(obj.BaseWindowTitle); + end + end + + function beginBusy(obj, message) + if obj.Starting || obj.Busy || ... + isempty(obj.Figure) || ~isvalid(obj.Figure) + return + end + message = strip(string(message)); + if strlength(message) == 0 + message = "Working"; + end + obj.Busy = true; + obj.PriorPointer = string(obj.Figure.Pointer); + obj.Figure.Pointer = "watch"; + obj.Figure.Name = char(obj.BaseWindowTitle + ... + " [Working: " + message + "]"); + setappdata(obj.Figure, "labkitAppBusy", true); + drawnow limitrate + end + + function endBusy(obj) + if obj.Starting + return + end + if ~obj.Busy + return + end + obj.Busy = false; + if isempty(obj.Figure) || ~isvalid(obj.Figure) + return + end + obj.Figure.Pointer = char(obj.PriorPointer); + obj.Figure.Name = char(obj.BaseWindowTitle); + if isappdata(obj.Figure, "labkitAppBusy") + rmappdata(obj.Figure, "labkitAppBusy"); + end + drawnow limitrate + end + + function startupUpdate(obj, message) + if ~obj.Starting || isempty(obj.Figure) || ~isvalid(obj.Figure) + return + end + if ~isempty(obj.StartupLabel) && isvalid(obj.StartupLabel) + obj.StartupLabel.Text = char(string(message)); + end + if toc(obj.StartupStarted) >= 0.25 && ... + ~any(labkit.app.internal.NativeAdapterValues.startupGuiMode() == ["hidden", "minimized"]) + obj.StartupPanel.Visible = "on"; + obj.Figure.Visible = "on"; + drawnow limitrate + end + end + + function finishStartup(obj) + if ~obj.Starting + return + end + obj.Starting = false; + if isempty(obj.Figure) || ~isvalid(obj.Figure) + return + end + if ~isempty(obj.StartupPanel) && isvalid(obj.StartupPanel) + delete(obj.StartupPanel); + end + obj.StartupPanel = []; + obj.StartupLabel = []; + obj.Figure.Pointer = char(obj.PriorPointer); + if isappdata(obj.Figure, "labkitAppBusy") + rmappdata(obj.Figure, "labkitAppBusy"); + end + if labkit.app.internal.NativeAdapterValues.startupGuiMode() == "minimized" && ... + isprop(obj.Figure, "WindowState") + obj.Figure.Visible = "on"; + obj.Figure.WindowState = "minimized"; + end + end + + function failStartup(obj, cause) + obj.Starting = false; + obj.Busy = false; + if isempty(obj.Figure) || ~isvalid(obj.Figure) + return + end + message = "Startup failed: " + labkit.app.internal.NativeAdapterValues.deepestCauseMessage(cause); + if ~isempty(obj.StartupLabel) && isvalid(obj.StartupLabel) + obj.StartupLabel.Text = char(message); + end + if ~isempty(obj.StartupPanel) && isvalid(obj.StartupPanel) + obj.StartupPanel.Visible = "on"; + end + obj.Figure.Pointer = char(obj.PriorPointer); + if isappdata(obj.Figure, "labkitAppBusy") + rmappdata(obj.Figure, "labkitAppBusy"); + end + setappdata(obj.Figure, "labkitAppStartupFailure", struct( ... + "failed", true, ... + "message", message, ... + "identifier", string(cause.identifier))); + obj.Figure.CloseRequestFcn = @(~, ~) obj.close(); + drawnow limitrate + end + + function alert(obj, message, title) + uialert(obj.Figure, char(string(message)), char(string(title))); + end + + function result = chooseOption(obj, prompt, choices, title, ... + defaultChoice, cancelChoice) + answer = uiconfirm(obj.Figure, char(string(prompt)), ... + char(string(title)), ... + Options=cellstr(string(choices)), ... + DefaultOption=char(string(defaultChoice)), ... + CancelOption=char(string(cancelChoice))); + result = labkit.app.dialog.Choice(string(answer)); + end + + function result = chooseInputFile(~, filters, startPath) + [name, folder] = uigetfile(filters, "Choose input file", ... + labkit.app.internal.NativeAdapterValues.safeStartPath(startPath)); + result = labkit.app.internal.NativeAdapterValues.dialogPath(name, folder); + end + + function result = chooseInputFolder(~, startPath) + folder = uigetdir(labkit.app.internal.NativeAdapterValues.safeStartPath(startPath), "Choose input folder"); + result = labkit.app.internal.NativeAdapterValues.folderDialogPath(folder); + end + + function result = chooseOutputFile(~, filters, startPath) + [name, folder] = uiputfile(filters, "Choose output file", ... + labkit.app.internal.NativeAdapterValues.safeStartPath(startPath)); + result = labkit.app.internal.NativeAdapterValues.dialogPath(name, folder); + end + + function result = chooseOutputFolder(~, startPath) + folder = uigetdir(labkit.app.internal.NativeAdapterValues.safeStartPath(startPath), "Choose output folder"); + result = labkit.app.internal.NativeAdapterValues.folderDialogPath(folder); + end + end + + methods (Access = private) + function createStartupSurface(obj) + figurePosition = obj.Figure.Position; + width = max(220, figurePosition(3) - 32); + obj.StartupPanel = uipanel(obj.Figure, ... + BorderType="line", Visible="off", ... + Tag="labkitAppStartupStatus", ... + Position=[16 12 width 34]); + grid = uigridlayout(obj.StartupPanel, [1 1], ... + Padding=[8 2 8 2]); + obj.StartupLabel = uilabel(grid, ... + Text="Building controls...", ... + Tag="labkitAppStartupStatusLabel"); + end + + buildTree(obj) + + parent = parentFor(obj, node) + + component = createComponent(obj, node, parent) + + component = createField(~, parent, config, id) + + spinner = createPanner(~, node, parent) + + first = createRangeField(~, node, parent) + + createAxes(obj, node, parent) + + table = createDataTable(obj, node, parent) + + applyView(obj, view) + + apply(obj, operation) + + applyListSelection(~, component, selection) + + applyFilePaths(~, component, paths) + + applyFileItemStatuses(~, component, statuses) + + applyTableCellSelection(~, component, selection) + + applyTableData(~, component, model) + + applyText(~, component, value) + + applyValue(~, component, value) + + applyLimits(~, component, limits) + + applyEnabled(~, component, enabled) + + renderPlot(obj, operation) + + declarations = collectInteractionDeclarations(obj) + + targets = interactionTargetAxes(obj) + + installContentGrid(obj, node, component) + + placeInParent(obj, node, component) + + owner = owningNode(obj, id) + + installWorkbenchLayout(obj, node, component) + + heights = childRowHeights(obj, ids) + + height = preferredRowHeight(obj, node) + + tf = isGrowableTabChild(obj, node) + + tf = sectionDrawsOwnTitle(obj, node) + + tf = usesAdaptiveActionGrid(obj, node) + + [rows, columns] = actionGridSize(obj, node) + + selected = nodes(obj, ids) + + parent = contentParent(obj, id) + + list = createFilePanel(obj, node, parent) + + textArea = createTextPanel(obj, node, parent, config, isLog) + + function toggleLogFollowLatest(~, textArea, button) + following = ~logical(getappdata( ... + textArea, "labkitAppLogFollowLatest")); + setappdata(textArea, "labkitAppLogFollowLatest", following); + menuItem = getappdata(textArea, "labkitAppLogFollowMenu"); + if following + label = "Pause auto-scroll"; + checked = "on"; + try + scroll(textArea, "bottom"); + catch + end + else + label = "Follow latest"; + checked = "off"; + end + button.Text = label; + menuItem.Text = label; + menuItem.Checked = checked; + end + + installUtilityMenus(obj) + + function runUtility(obj, callback) + try + callback(); + catch cause + obj.alert(cause.message, "LabKit Utility"); + end + end + + function handles = allAxes(obj) + values = obj.Axes.values; + if isempty(values) + handles = gobjects(0, 1); + else + handles = vertcat(values{:}); + handles = handles(isvalid(handles)); + end + end + + function popoutAllPlots(obj) + handles = obj.allAxes(); + for k = 1:numel(handles) + labkit.app.plot.enablePopout(handles(k)); + menu = findall(handles(k).ContextMenu, ... + Type="uimenu", Tag="labkitAxesPopoutMenu"); + if ~isempty(menu) + menu(1).MenuSelectedFcn(menu(1), []); + end + end + end + + function copyAllPlots(obj) + handles = obj.allAxes(); + if numel(handles) == 1 + copygraphics(handles(1), ContentType="image"); + elseif ~isempty(handles) + copygraphics(obj.Figure, ContentType="image"); + end + end + + function saveAllPlots(obj) + handles = obj.allAxes(); + if isempty(handles) + return + end + choice = obj.chooseOutputFile( ... + {"*.png", "PNG image (*.png)"; ... + "*.pdf", "PDF file (*.pdf)"}, "plots.png"); + if choice.Cancelled + return + end + filepath = string(choice.Value); + for k = 1:numel(handles) + output = filepath; + if numel(handles) > 1 + output = labkit.app.internal.NativeAdapterValues.plotFilepath(filepath, handles(k), k); + end + exportgraphics(handles(k), output, ContentType="image"); + end + end + + function saveScreenshot(obj) + choice = obj.chooseOutputFile( ... + {"*.png", "PNG image (*.png)"; ... + "*.pdf", "PDF file (*.pdf)"}, "app.png"); + if ~choice.Cancelled + exportapp(obj.Figure, choice.Value); + end + end + + function tf = hasProjectDocument(obj) + tf = true; + try + obj.Runtime.documentMetadata(); + catch + tf = false; + end + end + + function saveState(obj) + metadata = obj.Runtime.documentMetadata(); + startPath = string(metadata.path); + if strlength(startPath) == 0 + startPath = "project.mat"; + end + choice = obj.chooseOutputFile( ... + {"*.mat", "LabKit project (*.mat)"}, startPath); + if ~choice.Cancelled + obj.Runtime.saveProject(obj.Runtime.State, choice.Value); + end + end + + function loadState(obj) + choice = obj.chooseInputFile( ... + {"*.mat", "LabKit project (*.mat)"}, ""); + if ~choice.Cancelled + obj.Runtime.restoreProject(choice.Value); + end + end + + function onKeyPress(obj, event) + modifiers = strings(1, 0); + if isprop(event, "Modifier") + modifiers = string(event.Modifier); + end + if strcmpi(string(event.Key), "w") && ... + any(ismember(lower(modifiers), ["control", "command"])) + obj.requestClose(); + end + end + + function requestClose(obj) + if isempty(obj.Figure) || ~isvalid(obj.Figure) + return + end + if ~isempty(obj.ClosePrompt) && isvalid(obj.ClosePrompt) + obj.Runtime.close(); + return + end + message = "Close this LabKit app?"; + if obj.Busy + message = "LabKit is still working. Close anyway?"; + elseif obj.hasProjectDocument() + metadata = obj.Runtime.documentMetadata(); + if metadata.dirty + message = "This project has unsaved changes. Close anyway?"; + end + end + obj.ClosePrompt = uipanel(obj.Figure, ... + Title="Close LabKit app?", ... + Tag="labkitAppClosePrompt", ... + Position=labkit.app.internal.NativeAdapterValues.closePromptPosition(obj.Figure)); + grid = uigridlayout(obj.ClosePrompt, [2 3], ... + RowHeight={'1x', 34}, ColumnWidth={'1x', 86, 86}, ... + Padding=[10 8 10 8], RowSpacing=6, ColumnSpacing=8); + label = uilabel(grid, Text=char(message + ... + " Close again to confirm."), ... + WordWrap="on", FontWeight="bold"); + label.Layout.Row = 1; + label.Layout.Column = [1 3]; + closeButton = uibutton(grid, Text="Close", ... + ButtonPushedFcn=@(~, ~) obj.Runtime.close()); + closeButton.Layout.Row = 2; + closeButton.Layout.Column = 2; + cancelButton = uibutton(grid, Text="Cancel", ... + ButtonPushedFcn=@(~, ~) obj.clearClosePrompt()); + cancelButton.Layout.Row = 2; + cancelButton.Layout.Column = 3; + drawnow + end + + function clearClosePrompt(obj) + if ~isempty(obj.ClosePrompt) && isvalid(obj.ClosePrompt) + delete(obj.ClosePrompt); + end + obj.ClosePrompt = []; + end + + installCallbacks(obj) + + function pannerChanged(obj, target, value) + component = obj.component(target); + value = min(component.Limits(2), ... + max(component.Limits(1), double(value))); + component.Value = value; + linked = labkit.app.internal.NativeAdapterValues.linkedPannerSlider(component); + if ~isempty(linked) + linked.Value = value; + end + obj.Runtime.applyControlValue(target, value); + end + + function rangeChanged(obj, target) + component = obj.component(target); + rangeEnd = labkit.app.internal.NativeAdapterValues.linkedRangeEnd(component); + value = [component.Value, rangeEnd.Value]; + obj.Runtime.applyControlValue(target, value); + end + + installTableCallbacks(obj, node, component) + + function dispatchTableEdit(obj, target, component, event) + indices = event.Indices; + rowId = labkit.app.internal.NativeAdapterValues.tableLabel(component.RowName, indices(1)); + columnId = labkit.app.internal.NativeAdapterValues.tableLabel(component.ColumnName, indices(2)); + edit = labkit.app.event.TableCellEdit( ... + RowIndex=indices(1), ColumnIndex=indices(2), ... + RowId=rowId, ColumnId=columnId, ... + PreviousValue=event.PreviousData, ... + NewValue=labkit.app.internal.NativeAdapterValues.editedValue(event), Data=component.Data); + obj.Runtime.applyTableEdit(target, edit); + end + + installFilePanelCallbacks(obj, node, list) + + function chooseFiles(obj, target) + config = obj.node(target).Configuration; + startPath = obj.dialogStartFolder(target, config.StartPath); + [names, folder] = uigetfile(labkit.app.internal.NativeAdapterValues.dialogFilters(config.Filters), ... + char(config.ChooseLabel), ... + startPath, MultiSelect=labkit.app.internal.NativeAdapterValues.multiSelectValue(config.SelectionMode)); + if isequal(names, 0) + return; + end + obj.DialogFolders(char(target)) = char(folder); + paths = string(folder) + filesep + string(names); + if config.SelectionMode == "single" + paths = paths(1); + else + existing = obj.component(target).UserData.Paths; + paths = unique([reshape(existing, 1, []), ... + reshape(paths, 1, [])], "stable"); + end + obj.Runtime.applyFileSelection(target, paths, 1:numel(paths)); + end + + function chooseFolderFiles(obj, target, recursive) + config = obj.node(target).Configuration; + startPath = obj.dialogStartFolder(target, config.StartPath); + folder = uigetdir(startPath, "Choose folder"); + if isequal(folder, 0) + return + end + obj.DialogFolders(char(target)) = char(folder); + paths = labkit.app.internal.NativeAdapterValues.filesInFolder(folder, config.Filters, recursive); + if recursive && ... + numel(paths) > config.FolderWarningThreshold + message = sprintf([ ... + "Recursive scan found %d matching file(s) under:\n%s\n\n" ... + "Loading a very large folder may take a while. Continue?"], ... + numel(paths), folder); + answer = uiconfirm(obj.Figure, message, ... + "Large folder scan", ... + Options={"Continue", "Cancel"}, ... + DefaultOption="Cancel", CancelOption="Cancel"); + if string(answer) ~= "Continue" + return + end + end + existing = obj.component(target).UserData.Paths; + paths = unique([reshape(existing, 1, []), ... + reshape(paths, 1, [])], "stable"); + if isfinite(config.MaxFiles) + paths = paths(1:min(numel(paths), config.MaxFiles)); + end + obj.Runtime.applyFileSelection(target, paths, 1:numel(paths)); + end + + function removeSelectedFiles(obj, target, list) + obj.Runtime.removeFileSelection( ... + target, labkit.app.internal.NativeAdapterValues.selectedIndices(list)); + end + + function folder = dialogStartFolder(obj, target, configured) + key = char(target); + if isKey(obj.DialogFolders, key) && ... + isfolder(obj.DialogFolders(key)) + folder = obj.DialogFolders(key); + return + end + folder = char(string(configured)); + if isempty(folder) || ~isfolder(folder) + folder = labkit.app.internal.NativeAdapterValues.userDialogFolder(); + end + end + + function component = component(obj, id) + key = char(id); + if ~isKey(obj.Components, key) + error("labkit:app:contract:UnknownReference", ... + "MATLAB adapter target is undeclared: %s.", id); + end + component = obj.Components(key); + end + + function node = node(obj, id) + index = find(string({obj.Plan.Nodes.Id}) == string(id), 1); + node = obj.Plan.Nodes(index); + end + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/actionGridSize.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/actionGridSize.m new file mode 100644 index 000000000..9f54eea15 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/actionGridSize.m @@ -0,0 +1,13 @@ +function [rows, columns] = actionGridSize(obj, node) +% Class-folder implementation of MatlabPlatformAdapter.actionGridSize. + children = obj.nodes(node.ChildIds); + columns = min(2, numel(children)); + labels = strings(1, numel(children)); + for k = 1:numel(children) + labels(k) = children(k).Configuration.Label; + end + if any(strlength(labels) > 28) + columns = 1; + end + rows = max(1, ceil(numel(children) / columns)); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/apply.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/apply.m new file mode 100644 index 000000000..25f45b450 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/apply.m @@ -0,0 +1,34 @@ +function apply(obj, operation) +% Class-folder implementation of MatlabPlatformAdapter.apply. + component = obj.component(operation.Target); + switch operation.Kind + case "value" + obj.applyValue(component, operation.Value); + case "choices" + labkit.app.internal.NativeAdapterValues.applyChoices(component, operation.Value); + case "limits" + obj.applyLimits(component, operation.Value); + case "enabled" + obj.applyEnabled(component, operation.Value); + case "visible" + labkit.app.internal.NativeAdapterValues.setIfProperty(labkit.app.internal.NativeAdapterValues.layoutHandle(component), ... + "Visible", labkit.app.internal.NativeAdapterValues.onOff(operation.Value)); + case "text" + obj.applyText(component, operation.Value); + case "filePaths" + obj.applyFilePaths(component, operation.Value); + case "fileItemStatuses" + obj.applyFileItemStatuses(component, operation.Value); + case "listSelection" + obj.applyListSelection(component, operation.Value); + case "tableCellSelection" + obj.applyTableCellSelection(component, operation.Value); + case "tableData" + obj.applyTableData(component, operation.Value); + case "renderPlot" + obj.renderPlot(operation); + case "workspacePage" + labkit.app.internal.NativeAdapterValues.setIfProperty(component, "Enable", labkit.app.internal.NativeAdapterValues.onOff(operation.Value.Enabled)); + component.UserData = struct("Status", operation.Value.Status); + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyEnabled.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyEnabled.m new file mode 100644 index 000000000..9f34b271a --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyEnabled.m @@ -0,0 +1,25 @@ +function applyEnabled(~, component, enabled) +% Class-folder implementation of MatlabPlatformAdapter.applyEnabled. + value = labkit.app.internal.NativeAdapterValues.onOff(enabled); + labkit.app.internal.NativeAdapterValues.setIfProperty(component, "Enable", value); + if isprop(component, "Tag") && strlength(string(component.Tag)) > 0 + figure = ancestor(component, "figure"); + labels = findall(figure, "Tag", ... + char(string(component.Tag) + ".label")); + for k = 1:numel(labels) + labkit.app.internal.NativeAdapterValues.setIfProperty(labels(k), "Enable", value); + end + end + mode = labkit.app.internal.NativeAdapterValues.linkedPlotMode(component); + if ~isempty(mode) + mode.Enable = value; + end + linked = labkit.app.internal.NativeAdapterValues.linkedPannerSlider(component); + if ~isempty(linked) + linked.Enable = value; + end + rangeEnd = labkit.app.internal.NativeAdapterValues.linkedRangeEnd(component); + if ~isempty(rangeEnd) + rangeEnd.Enable = value; + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFileItemStatuses.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFileItemStatuses.m new file mode 100644 index 000000000..6b8064e2e --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFileItemStatuses.m @@ -0,0 +1,16 @@ +function applyFileItemStatuses(~, component, statuses) +% Class-folder implementation of MatlabPlatformAdapter.applyFileItemStatuses. + data = component.UserData; + statuses = reshape(string(statuses), 1, []); + if ~isempty(statuses) && numel(statuses) ~= numel(data.Paths) + error("labkit:app:contract:InvalidValue", ... + "File item statuses must be empty or match file paths."); + end + data.ItemStatuses = statuses; + component.UserData = data; + labels = labkit.app.internal.NativeAdapterValues.formatFileLabels(data.Paths, statuses); + if isempty(labels) && ~data.Compact + labels = data.EmptyText; + end + labkit.app.internal.NativeAdapterValues.setIfProperty(component, "Items", labels); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFilePaths.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFilePaths.m new file mode 100644 index 000000000..355409ad3 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFilePaths.m @@ -0,0 +1,35 @@ +function applyFilePaths(~, component, paths) +% Class-folder implementation of MatlabPlatformAdapter.applyFilePaths. + data = component.UserData; + data.Paths = reshape(string(paths), 1, []); + if numel(data.ItemStatuses) ~= numel(data.Paths) + data.ItemStatuses = strings(1, 0); + end + component.UserData = data; + labels = labkit.app.internal.NativeAdapterValues.formatFileLabels(paths, data.ItemStatuses); + if isempty(labels) && ~data.Compact + labels = data.EmptyText; + end + labkit.app.internal.NativeAdapterValues.setIfProperty(component, "Items", labels); + if ~isstruct(component.UserData) || ... + ~isfield(component.UserData, "Status") || ... + isempty(component.UserData.Status) || ... + ~isvalid(component.UserData.Status) + return + end + text = component.UserData.EmptyText; + if ~isempty(paths) + if component.UserData.Compact + text = string(paths(1)); + elseif numel(paths) == 1 + text = "1 file"; + else + text = string(numel(paths)) + " files"; + end + end + if component.UserData.Compact + component.UserData.Status.Value = char(string(text)); + else + component.UserData.Status.Value = cellstr(string(text)); + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyLimits.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyLimits.m new file mode 100644 index 000000000..a82bc761e --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyLimits.m @@ -0,0 +1,20 @@ +function applyLimits(~, component, limits) +% Class-folder implementation of MatlabPlatformAdapter.applyLimits. + rangeEnd = labkit.app.internal.NativeAdapterValues.linkedRangeEnd(component); + if ~isempty(rangeEnd) + component.Limits = limits; + rangeEnd.Limits = limits; + return + end + linked = labkit.app.internal.NativeAdapterValues.linkedPannerSlider(component); + if isempty(linked) + labkit.app.internal.NativeAdapterValues.setIfProperty(component, "Limits", limits); + return + end + value = min(limits(2), ... + max(limits(1), double(component.Value))); + component.Limits = limits; + component.Value = value; + linked.Limits = limits; + linked.Value = value; +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyListSelection.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyListSelection.m new file mode 100644 index 000000000..107cdb65e --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyListSelection.m @@ -0,0 +1,10 @@ +function applyListSelection(~, component, selection) +% Class-folder implementation of MatlabPlatformAdapter.applyListSelection. + if ~isprop(component, "Items") || ~isprop(component, "Value") + return; + end + items = string(component.Items); + indices = selection.Indices; + indices = indices(indices >= 1 & indices <= numel(items)); + component.Value = items(indices); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyTableCellSelection.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyTableCellSelection.m new file mode 100644 index 000000000..baac7c349 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyTableCellSelection.m @@ -0,0 +1,6 @@ +function applyTableCellSelection(~, component, selection) +% Class-folder implementation of MatlabPlatformAdapter.applyTableCellSelection. + if isprop(component, "Selection") + component.Selection = selection.CellIndices; + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyTableData.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyTableData.m new file mode 100644 index 000000000..f440814ed --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyTableData.m @@ -0,0 +1,11 @@ +function applyTableData(~, component, model) +% Class-folder implementation of MatlabPlatformAdapter.applyTableData. + component.Data = labkit.app.internal.NativeAdapterValues.nativeTableData(model.Data); + if ~isempty(model.Columns) + component.ColumnName = cellstr(model.Columns); + end + if ~isempty(model.RowNames) + component.RowName = cellstr(model.RowNames); + end + component.ColumnEditable = model.ColumnEditable; +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyText.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyText.m new file mode 100644 index 000000000..90c082e20 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyText.m @@ -0,0 +1,30 @@ +function applyText(~, component, value) +% Class-folder implementation of MatlabPlatformAdapter.applyText. + if isstruct(component.UserData) && ... + isfield(component.UserData, "Status") && ... + isfield(component.UserData, "Compact") && ... + ~isempty(component.UserData.Status) && ... + isvalid(component.UserData.Status) + if component.UserData.Compact + component.UserData.Status.Value = char(string(value)); + else + component.UserData.Status.Value = cellstr(string(value)); + end + labkit.app.internal.NativeAdapterValues.fitText(component.UserData.Status); + return + elseif isprop(component, "Text") + component.Text = value; + elseif isprop(component, "Value") + component.Value = value; + elseif isprop(component, "Title") + component.Title = value; + end + labkit.app.internal.NativeAdapterValues.fitText(component); + if isappdata(component, "labkitAppLogFollowLatest") && ... + getappdata(component, "labkitAppLogFollowLatest") + try + scroll(component, "bottom"); + catch + end + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyValue.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyValue.m new file mode 100644 index 000000000..8f80659d1 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyValue.m @@ -0,0 +1,20 @@ +function applyValue(~, component, value) +% Class-folder implementation of MatlabPlatformAdapter.applyValue. + mode = labkit.app.internal.NativeAdapterValues.linkedPlotMode(component); + if ~isempty(mode) + mode.Value = value; + return + end + rangeEnd = labkit.app.internal.NativeAdapterValues.linkedRangeEnd(component); + if ~isempty(rangeEnd) + component.Value = value(1); + rangeEnd.Value = value(2); + return + end + labkit.app.internal.NativeAdapterValues.setIfProperty(component, "Value", value); + linked = labkit.app.internal.NativeAdapterValues.linkedPannerSlider(component); + if ~isempty(linked) + linked.Value = min(linked.Limits(2), ... + max(linked.Limits(1), double(value))); + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyView.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyView.m new file mode 100644 index 000000000..e19f8c74e --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyView.m @@ -0,0 +1,15 @@ +function applyView(obj, view) +% Class-folder implementation of MatlabPlatformAdapter.applyView. + operations = labkit.app.internal.NativeAdapterValues.orderedOperations(view.operationsForCompiler()); + interactionOperations = operations(cellfun(@(operation) ... + labkit.app.internal.NativeAdapterValues.isInteractionKind(operation.Kind), operations)); + operations = operations(~cellfun(@(operation) ... + labkit.app.internal.NativeAdapterValues.isInteractionKind(operation.Kind), operations)); + for k = 1:numel(operations) + obj.apply(operations{k}); + end + if ~isempty(obj.InteractionController) + obj.InteractionController.reconcile( ... + obj.InteractionDeclarations, interactionOperations); + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/buildTree.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/buildTree.m new file mode 100644 index 000000000..e7a159015 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/buildTree.m @@ -0,0 +1,23 @@ +function buildTree(obj) +% Class-folder implementation of MatlabPlatformAdapter.buildTree. + nodes = obj.Plan.Nodes; + for k = 1:numel(nodes) + node = nodes(k); + parent = obj.parentFor(node); + component = obj.createComponent(node, parent); + if ~isempty(component) + component.Tag = char(node.Id); + end + obj.placeInParent(node, component); + obj.Components(char(node.Id)) = component; + end + workspaceIndex = find(string({nodes.Kind}) == "workspace", 1); + if ~isempty(workspaceIndex) + workspace = nodes(workspaceIndex); + if strlength(workspace.InitialPage) > 0 + group = obj.component(workspace.Id); + page = obj.component(workspace.InitialPage); + group.SelectedTab = page; + end + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/childRowHeights.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/childRowHeights.m new file mode 100644 index 000000000..61ce6f926 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/childRowHeights.m @@ -0,0 +1,8 @@ +function heights = childRowHeights(obj, ids) +% Class-folder implementation of MatlabPlatformAdapter.childRowHeights. + nodes = obj.nodes(ids); + heights = repmat({'fit'}, 1, numel(nodes)); + for k = 1:numel(nodes) + heights{k} = obj.preferredRowHeight(nodes(k)); + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/collectInteractionDeclarations.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/collectInteractionDeclarations.m new file mode 100644 index 000000000..be3232808 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/collectInteractionDeclarations.m @@ -0,0 +1,10 @@ +function declarations = collectInteractionDeclarations(obj) +% Class-folder implementation of MatlabPlatformAdapter.collectInteractionDeclarations. + declarations = {}; + for k = 1:numel(obj.Plan.Nodes) + config = obj.Plan.Nodes(k).Configuration; + if isfield(config, "Interactions") + declarations = [declarations config.Interactions]; + end + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/contentParent.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/contentParent.m new file mode 100644 index 000000000..695278126 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/contentParent.m @@ -0,0 +1,9 @@ +function parent = contentParent(obj, id) +% Class-folder implementation of MatlabPlatformAdapter.contentParent. + key = char(id); + if isKey(obj.Layouts, key) + parent = obj.Layouts(key); + else + parent = obj.component(id); + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createAxes.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/createAxes.m new file mode 100644 index 000000000..f3a7c2498 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/createAxes.m @@ -0,0 +1,65 @@ +function createAxes(obj, node, parent) +% Class-folder implementation of MatlabPlatformAdapter.createAxes. + config = node.Configuration; + hasModes = ~isempty(config.ViewModes); + root = uigridlayout(parent, [1 + double(hasModes) 1], ... + Padding=[2 2 2 2], RowSpacing=4, ColumnSpacing=2); + root.RowHeight = {'1x'}; + axesRow = 1; + mode = []; + if hasModes + root.RowHeight = {'fit', '1x'}; + mode = uidropdown(root, Items=config.ViewModes, ... + Value=config.ViewModes(1), ... + Tag=char(node.Id + ".viewMode")); + mode.Layout.Row = 1; + mode.Layout.Column = 1; + axesRow = 2; + end + axisCount = numel(node.AxisIds); + switch config.Layout + case "pair" + layout = uigridlayout(root, [1 axisCount]); + layout.RowHeight = {'1x'}; + layout.ColumnWidth = labkit.app.internal.NativeAdapterValues.repeatedOrConfigured( ... + config.ColumnWidths, axisCount); + case "stack" + layout = uigridlayout(root, [axisCount 1]); + layout.RowHeight = labkit.app.internal.NativeAdapterValues.repeatedOrConfigured( ... + config.RowHeights, axisCount); + layout.ColumnWidth = {'1x'}; + otherwise + layout = uigridlayout(root, [1 1]); + layout.RowHeight = {'1x'}; + layout.ColumnWidth = {'1x'}; + end + layout.Layout.Row = axesRow; + layout.Layout.Column = 1; + layout.Padding = [0 0 0 0]; + layout.RowSpacing = 2; + layout.ColumnSpacing = 2; + for k = 1:numel(node.AxisIds) + axisId = node.AxisIds(k); + key = labkit.app.internal.NativeAdapterValues.axisKey(node.Id, axisId); + ax = uiaxes(layout, Tag=char(key)); + if config.Layout == "stack" + ax.Layout.Row = k; + ax.Layout.Column = 1; + elseif config.Layout == "pair" + ax.Layout.Row = 1; + ax.Layout.Column = k; + else + ax.Layout.Row = 1; + ax.Layout.Column = 1; + end + title(ax, labkit.app.internal.NativeAdapterValues.axisText(config.AxisTitles, node.AxisIds, k)); + xlabel(ax, labkit.app.internal.NativeAdapterValues.axisText(config.XLabels, strings(1, axisCount), k)); + ylabel(ax, labkit.app.internal.NativeAdapterValues.axisText(config.YLabels, strings(1, axisCount), k)); + if config.ScrollZoomAxes(k) ~= "xy" + setappdata(ax, "labkitPreviewScrollZoomAxes", ... + config.ScrollZoomAxes(k)); + end + obj.Axes(char(key)) = ax; + end + parent.UserData = struct("ValueControl", mode); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createComponent.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/createComponent.m new file mode 100644 index 000000000..1eaaa9d98 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/createComponent.m @@ -0,0 +1,80 @@ +function component = createComponent(obj, node, parent) +% Class-folder implementation of MatlabPlatformAdapter.createComponent. + config = node.Configuration; + switch node.Kind + case "workbench" + component = uipanel(parent, BorderType="none", ... + Units="normalized", Position=[0 0 1 1]); + obj.installWorkbenchLayout(node, component); + case {"group", "section"} + title = ""; + if isfield(config, "Title") + title = config.Title; + end + border = "line"; + if node.Kind == "group" + if strlength(title) == 0 + border = "none"; + end + elseif ~obj.sectionDrawsOwnTitle(node) + title = ""; + border = "none"; + end + component = uipanel(parent, Title=title, ... + BorderType=border); + obj.installContentGrid(node, component); + case "tab" + if isa(parent, "matlab.ui.container.TabGroup") + component = uitab(parent, Title=config.Title); + else + component = uipanel(parent, Title=config.Title); + end + obj.installContentGrid(node, component); + case "button" + component = uibutton(parent, Text=config.Label, ... + Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled), ... + Tooltip=char(config.Tooltip)); + labkit.app.internal.NativeAdapterValues.fitText(component, ... + CharsPerStep=18, MaxShrinkSteps=3); + case "field" + component = obj.createField(parent, config, node.Id); + case "rangeField" + component = obj.createRangeField(node, parent); + case "slider" + component = obj.createPanner(node, parent); + case "fileList" + component = obj.createFilePanel(node, parent); + case "dataTable" + component = obj.createDataTable(node, parent); + case {"logPanel", "statusPanel"} + component = obj.createTextPanel( ... + node, parent, config, node.Kind == "logPanel"); + case "plotArea" + plotTitle = config.Title; + owner = obj.owningNode(node.Id); + if strlength(plotTitle) == 0 && ~isempty(owner) && ... + owner.Kind == "section" && ... + isfield(owner.Configuration, "Title") + plotTitle = owner.Configuration.Title; + end + if strlength(plotTitle) > 0 + component = uipanel(parent, Title=char(plotTitle)); + else + component = uipanel(parent, BorderType="none"); + end + obj.createAxes(node, component); + case "workspace" + if isempty(node.PageIds) + component = uipanel(parent, BorderType="none"); + obj.installContentGrid(node, component); + else + component = uitabgroup(parent); + end + case "workspacePage" + component = uitab(parent, Title=config.Title); + obj.installContentGrid(node, component); + otherwise + error("labkit:app:runtime:InvariantFailure", ... + "MATLAB adapter cannot create Layout kind %s.", node.Kind); + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createDataTable.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/createDataTable.m new file mode 100644 index 000000000..371012f0e --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/createDataTable.m @@ -0,0 +1,19 @@ +function table = createDataTable(obj, node, parent) +% Class-folder implementation of MatlabPlatformAdapter.createDataTable. + config = node.Configuration; + title = config.Title; + owner = obj.owningNode(node.Id); + if strlength(title) == 0 && ~isempty(owner) && ... + owner.Kind == "section" && ... + isfield(owner.Configuration, "Title") + title = owner.Configuration.Title; + end + panel = uipanel(parent, Title=char(title)); + grid = uigridlayout(panel, [1 1], Padding=[8 8 8 8]); + table = uitable(grid, ... + ColumnName=cellstr(config.Columns), ... + RowName=cellstr(config.RowNames), ... + ColumnEditable=config.ColumnEditable); + table.UserData = struct("LayoutContainer", panel); + panel.Tag = char(node.Id + ".panel"); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createField.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/createField.m new file mode 100644 index 000000000..d98570657 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/createField.m @@ -0,0 +1,32 @@ +function component = createField(~, parent, config, id) +% Class-folder implementation of MatlabPlatformAdapter.createField. + value = labkit.app.internal.NativeAdapterValues.neutralValue(config.Value, config.Kind, config.Choices); + if config.Kind == "logical" + component = uicheckbox(parent, Text=config.Label, ... + Value=logical(value), Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled)); + return; + end + parent = labkit.app.internal.NativeAdapterValues.labeledParent(parent, config.Label, id); + layoutContainer = parent; + switch config.Kind + case "numeric" + component = uieditfield(parent, "numeric", ... + Value=value, Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled)); + case "choice" + choices = config.Choices; + if isempty(choices) + choices = ""; + end + component = uidropdown(parent, Items=choices, ... + Value=value, Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled)); + case "readonly" + component = uitextarea(parent, Editable="off", ... + Value=char(string(value)), ... + Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled)); + otherwise + component = uieditfield(parent, "text", ... + Value=string(value), Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled)); + end + component.UserData = struct( ... + "LayoutContainer", layoutContainer); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createFilePanel.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/createFilePanel.m new file mode 100644 index 000000000..6a0103d49 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/createFilePanel.m @@ -0,0 +1,79 @@ +function list = createFilePanel(obj, node, parent) +% Class-folder implementation of MatlabPlatformAdapter.createFilePanel. + config = node.Configuration; + panel = uipanel(parent, BorderType="line", ... + Title=char(config.Label)); + if config.SelectionMode == "single" && config.MaxFiles == 1 + grid = uigridlayout(panel, [1 2], Padding=[7 6 7 6], ... + ColumnWidth={140, '1x'}, ColumnSpacing=7); + grid.Tag = char(node.Id + ".layout"); + choose = uibutton(grid, Text=config.ChooseLabel, ... + Tag=char(node.Id + ".choose"), ... + Tooltip=char(config.ChooseTooltip)); + labkit.app.internal.NativeAdapterValues.fitText(choose, CharsPerStep=18, MaxShrinkSteps=3); + status = uieditfield(grid, Editable="off", ... + Value=char(config.EmptyText), ... + Tag=char(node.Id + ".status")); + list = uilistbox(panel, Items=strings(1, 0), ... + Visible="off", Position=[0 0 1 1]); + list.UserData = struct("Panel", panel, ... + "Choose", choose, "Folder", [], ... + "RecursiveFolder", [], "Remove", [], "Clear", [], ... + "Status", status, "EmptyText", config.EmptyText, ... + "Target", node.Id, "Compact", true, ... + "Paths", strings(1, 0), ... + "ItemStatuses", strings(1, 0)); + return + end + statusHeight = 0; + if config.ShowStatus + statusHeight = 64; + end + grid = uigridlayout(panel, [4 1], Padding=[5 5 5 5], ... + RowHeight={'fit', 'fit', '1x', statusHeight}, ... + RowSpacing=4); + grid.Tag = char(node.Id + ".layout"); + list = uilistbox(grid, Items=strings(1, 0), ... + Multiselect=config.SelectionMode == "multiple"); + list.Layout.Row = 3; + addButtons = uigridlayout(grid, [1 3], ... + Padding=[0 0 0 0], ... + ColumnWidth={'1x', '1x', '1x'}, ColumnSpacing=4); + addButtons.Layout.Row = 1; + choose = uibutton(addButtons, Text=config.ChooseLabel, Tag= ... + char(node.Id + ".choose"), Tooltip=char(config.ChooseTooltip)); + folder = uibutton(addButtons, Text=config.FolderLabel, Tag= ... + char(node.Id + ".folder"), Tooltip=char(config.FolderTooltip)); + recursive = uibutton(addButtons, ... + Text=config.RecursiveFolderLabel, Tag= ... + char(node.Id + ".recursiveFolder"), ... + Tooltip=char(config.RecursiveFolderTooltip)); + selectionButtons = uigridlayout(grid, [1 2], ... + Padding=[0 0 0 0], ... + ColumnWidth={'2x', '1x'}, ColumnSpacing=4); + selectionButtons.Layout.Row = 2; + remove = uibutton(selectionButtons, ... + Text=config.RemoveLabel, Tag= ... + char(node.Id + ".remove"), Tooltip=char(config.RemoveTooltip)); + clear = uibutton(selectionButtons, ... + Text=config.ClearLabel, Tag= ... + char(node.Id + ".clear"), Tooltip=char(config.ClearTooltip)); + buttons = [choose, folder, recursive, remove, clear]; + for k = 1:numel(buttons) + labkit.app.internal.NativeAdapterValues.fitText(buttons(k), ... + CharsPerStep=18, MaxShrinkSteps=3); + end + status = []; + if config.ShowStatus + status = uitextarea(grid, Editable="off", ... + Value=char(config.EmptyText), ... + Tag=char(node.Id + ".status")); + status.Layout.Row = 4; + end + list.UserData = struct("Panel", panel, "Choose", choose, ... + "Folder", folder, "RecursiveFolder", recursive, ... + "Remove", remove, "Clear", clear, "Status", status, ... + "EmptyText", config.EmptyText, "Target", node.Id, ... + "Compact", false, "Paths", strings(1, 0), ... + "ItemStatuses", strings(1, 0)); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createPanner.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/createPanner.m new file mode 100644 index 000000000..657b3e5ee --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/createPanner.m @@ -0,0 +1,35 @@ +function spinner = createPanner(~, node, parent) +% Class-folder implementation of MatlabPlatformAdapter.createPanner. + config = node.Configuration; + [limits, value] = labkit.app.internal.NativeAdapterValues.sliderInitialValue(config); + outer = labkit.app.internal.NativeAdapterValues.labeledParent(parent, config.Label, node.Id); + grid = uigridlayout(outer, [1 2], ... + Padding=[0 0 0 0], ColumnSpacing=6, ... + ColumnWidth={76, '1x'}, ... + Tag=char(node.Id + ".panner")); + grid.Layout.Row = 1; + grid.Layout.Column = 2; + step = config.Step; + if isempty(step) + step = max(eps, diff(limits) * 0.002); + end + spinner = uispinner(grid, Limits=limits, Value=value, ... + Step=step, Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled)); + spinner.Layout.Row = 1; + spinner.Layout.Column = 1; + if strlength(config.ValueDisplayFormat) > 0 + spinner.ValueDisplayFormat = ... + char(config.ValueDisplayFormat); + end + slider = uislider(grid, Limits=limits, Value=value, ... + Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled), ... + Tag=char(node.Id + ".slider")); + slider.Layout.Row = 1; + slider.Layout.Column = 2; + if ~config.ShowTicks + slider.MajorTicks = []; + slider.MinorTicks = []; + end + spinner.UserData = struct( ... + "LayoutContainer", outer, "Slider", slider); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createRangeField.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/createRangeField.m new file mode 100644 index 000000000..18eca0352 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/createRangeField.m @@ -0,0 +1,23 @@ +function first = createRangeField(~, node, parent) +% Class-folder implementation of MatlabPlatformAdapter.createRangeField. + config = node.Configuration; + [limits, value] = labkit.app.internal.NativeAdapterValues.rangeSliderInitialValue(config); + outer = labkit.app.internal.NativeAdapterValues.labeledParent(parent, config.Label, node.Id); + grid = uigridlayout(outer, [1 2], ... + Padding=[0 0 0 0], ColumnSpacing=6, ... + ColumnWidth={'1x', '1x'}, ... + Tag=char(node.Id + ".range")); + grid.Layout.Row = 1; + grid.Layout.Column = 2; + first = uieditfield(grid, "numeric", Limits=limits, ... + Value=value(1), Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled)); + first.Layout.Row = 1; + first.Layout.Column = 1; + second = uieditfield(grid, "numeric", Limits=limits, ... + Value=value(2), Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled), ... + Tag=char(node.Id + ".end")); + second.Layout.Row = 1; + second.Layout.Column = 2; + first.UserData = struct( ... + "LayoutContainer", outer, "EndField", second); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createTextPanel.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/createTextPanel.m new file mode 100644 index 000000000..89df133f5 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/createTextPanel.m @@ -0,0 +1,43 @@ +function textArea = createTextPanel(obj, node, parent, config, isLog) +% Class-folder implementation of MatlabPlatformAdapter.createTextPanel. + title = string(config.Title); + owner = obj.owningNode(node.Id); + redundantTitle = ~isempty(owner) && owner.Kind == "section" && ... + obj.sectionDrawsOwnTitle(owner) && ... + isfield(owner.Configuration, "Title") && ... + string(owner.Configuration.Title) == title; + if redundantTitle + panel = uipanel(parent, BorderType="none"); + else + panel = uipanel(parent, Title=char(title)); + end + if isLog + grid = uigridlayout(panel, [2 1], ... + Padding=[7 7 7 7], RowHeight={30, '1x'}, ... + RowSpacing=4); + follow = uibutton(grid, Text="Pause auto-scroll"); + labkit.app.internal.NativeAdapterValues.fitText(follow, ... + CharsPerStep=18, MaxShrinkSteps=2); + follow.Tag = char(node.Id + ".follow"); + follow.Layout.Row = 1; + textArea = uitextarea(grid, Editable="off"); + textArea.Value = {'Ready.'}; + textArea.Layout.Row = 2; + setappdata(textArea, "labkitAppLogFollowLatest", true); + follow.ButtonPushedFcn = @(~, ~) ... + obj.toggleLogFollowLatest(textArea, follow); + menu = uicontextmenu(obj.Figure); + menuItem = uimenu(menu, Text="Pause auto-scroll", ... + Checked="on"); + menuItem.MenuSelectedFcn = @(~, ~) ... + obj.toggleLogFollowLatest(textArea, follow); + textArea.ContextMenu = menu; + setappdata(textArea, "labkitAppLogFollowButton", follow); + setappdata(textArea, "labkitAppLogFollowMenu", menuItem); + else + grid = uigridlayout(panel, [1 1], Padding=[7 7 7 7]); + textArea = uitextarea(grid, Editable="off"); + end + textArea.UserData = struct("Panel", panel); + panel.Tag = char(node.Id + ".panel"); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installCallbacks.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/installCallbacks.m new file mode 100644 index 000000000..5f909b6d5 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/installCallbacks.m @@ -0,0 +1,55 @@ +function installCallbacks(obj) +% Class-folder implementation of MatlabPlatformAdapter.installCallbacks. + nodes = obj.Plan.Nodes; + for k = 1:numel(nodes) + node = nodes(k); + if ~isKey(obj.Components, char(node.Id)) + continue; + end + component = obj.Components(char(node.Id)); + switch node.Kind + case "button" + component.ButtonPushedFcn = @(~, ~) ... + obj.Runtime.invokeAction(node.Id); + case "field" + if isprop(component, "ValueChangedFcn") + component.ValueChangedFcn = @(src, ~) ... + obj.Runtime.applyControlValue(node.Id, src.Value); + end + case "rangeField" + component.ValueChangedFcn = @(~, ~) ... + obj.rangeChanged(node.Id); + rangeEnd = labkit.app.internal.NativeAdapterValues.linkedRangeEnd(component); + rangeEnd.ValueChangedFcn = @(~, ~) ... + obj.rangeChanged(node.Id); + case "slider" + component.ValueChangedFcn = @(src, ~) ... + obj.pannerChanged(node.Id, src.Value); + linked = labkit.app.internal.NativeAdapterValues.linkedPannerSlider(component); + linked.ValueChangedFcn = @(src, ~) ... + obj.pannerChanged(node.Id, src.Value); + if isprop(linked, "ValueChangingFcn") + linked.ValueChangingFcn = @(src, event) ... + obj.pannerChanged(node.Id, ... + labkit.app.internal.NativeAdapterValues.changingValue(event, src.Value)); + end + case "fileList" + obj.installFilePanelCallbacks(node, component); + case "dataTable" + obj.installTableCallbacks(node, component); + case "plotArea" + mode = labkit.app.internal.NativeAdapterValues.linkedPlotMode(component); + if ~isempty(mode) + mode.ValueChangedFcn = @(src, ~) ... + obj.Runtime.applyControlValue( ... + node.Id, string(src.Value)); + end + case "workspace" + if ~isempty(node.PageIds) + component.SelectionChangedFcn = @(src, ~) ... + obj.Runtime.applyControlValue( ... + node.Id, string(src.SelectedTab.Tag)); + end + end + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installContentGrid.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/installContentGrid.m new file mode 100644 index 000000000..c95d34cca --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/installContentGrid.m @@ -0,0 +1,68 @@ +function installContentGrid(obj, node, component) +% Class-folder implementation of MatlabPlatformAdapter.installContentGrid. + if isempty(node.ChildIds) + return; + end + policy = labkit.app.internal.NativeAdapterValues.layoutPolicy(); + padding = policy.ContentPadding; + if node.Kind == "group" + padding = [0 0 0 0]; + end + horizontal = node.Kind == "group" && ... + isfield(node.Configuration, "Layout") && ... + node.Configuration.Layout == "horizontal"; + actionGrid = obj.usesAdaptiveActionGrid(node); + if actionGrid + [rows, columns] = obj.actionGridSize(node); + grid = uigridlayout(component, [rows columns], ... + Padding=padding, ... + RowSpacing=policy.ContentSpacing, ... + ColumnSpacing=policy.ContentSpacing); + grid.RowHeight = repmat({'fit'}, 1, rows); + grid.ColumnWidth = repmat({'1x'}, 1, columns); + elseif horizontal + grid = uigridlayout(component, [1 numel(node.ChildIds)], ... + Padding=padding, ... + RowSpacing=policy.ContentSpacing, ... + ColumnSpacing=policy.ContentSpacing); + grid.ColumnWidth = repmat({'1x'}, 1, numel(node.ChildIds)); + else + rowCount = numel(node.ChildIds); + if node.Kind == "tab" + rowCount = 2 * rowCount; + end + grid = uigridlayout(component, [rowCount 1], ... + Padding=padding, ... + RowSpacing=policy.ContentSpacing, ... + ColumnSpacing=policy.ContentSpacing); + heights = obj.childRowHeights(node.ChildIds); + singleGrowable = numel(node.ChildIds) == 1 && ... + obj.isGrowableTabChild(obj.node(node.ChildIds(1))); + if node.Kind == "workspacePage" + for k = 1:numel(node.ChildIds) + if obj.isGrowableTabChild(obj.node(node.ChildIds(k))) + heights{k} = "1x"; + end + end + elseif singleGrowable + heights{1} = "1x"; + end + if node.Kind == "tab" + expanded = cell(1, 2 * numel(heights)); + expanded(1:2:end) = heights; + expanded(2:2:end) = {policy.SplitterThickness}; + heights = expanded; + end + grid.RowHeight = heights; + if node.Kind == "tab" && isprop(grid, "Scrollable") + grid.Scrollable = labkit.app.internal.NativeAdapterValues.onOff(~singleGrowable); + end + end + grid.Tag = char(node.Id + ".layout"); + obj.Layouts(char(node.Id)) = grid; + if node.Kind == "tab" + for k = 1:numel(node.ChildIds) + labkit.app.internal.NativeAdapterValues.installRowDivider(obj.Figure, grid, 2 * k - 1, 2 * k); + end + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installFilePanelCallbacks.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/installFilePanelCallbacks.m new file mode 100644 index 000000000..7062a07f2 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/installFilePanelCallbacks.m @@ -0,0 +1,24 @@ +function installFilePanelCallbacks(obj, node, list) +% Class-folder implementation of MatlabPlatformAdapter.installFilePanelCallbacks. + handles = list.UserData; + list.ValueChangedFcn = @(src, ~) obj.Runtime.applyFilePanelSelection( ... + node.Id, labkit.app.internal.NativeAdapterValues.selectedIndices(src)); + handles.Choose.ButtonPushedFcn = @(~, ~) obj.chooseFiles(node.Id); + if ~isempty(handles.Folder) + handles.Folder.ButtonPushedFcn = @(~, ~) ... + obj.chooseFolderFiles(node.Id, false); + end + if ~isempty(handles.RecursiveFolder) + handles.RecursiveFolder.ButtonPushedFcn = @(~, ~) ... + obj.chooseFolderFiles(node.Id, true); + end + if ~isempty(handles.Remove) + handles.Remove.ButtonPushedFcn = @(~, ~) ... + obj.removeSelectedFiles(node.Id, list); + end + if ~isempty(handles.Clear) + handles.Clear.ButtonPushedFcn = @(~, ~) ... + obj.Runtime.applyFileSelection( ... + node.Id, strings(1, 0), zeros(1, 0)); + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installTableCallbacks.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/installTableCallbacks.m new file mode 100644 index 000000000..f0b983038 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/installTableCallbacks.m @@ -0,0 +1,20 @@ +function installTableCallbacks(obj, node, component) +% Class-folder implementation of MatlabPlatformAdapter.installTableCallbacks. + roles = string(cellfun(@(value) value.Signal, node.Signals, ... + "UniformOutput", false)); + if any(roles == "cellEdited") + component.CellEditCallback = @(src, event) ... + obj.dispatchTableEdit(node.Id, src, event); + end + if any(roles == "cellSelectionChanged") + if isprop(component, "SelectionChangedFcn") + component.SelectionChangedFcn = @(~, event) ... + obj.Runtime.applyTableSelection( ... + node.Id, labkit.app.internal.NativeAdapterValues.tableSelectionCells(event)); + else + component.CellSelectionCallback = @(~, event) ... + obj.Runtime.applyTableSelection( ... + node.Id, labkit.app.internal.NativeAdapterValues.tableSelectionCells(event)); + end + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m new file mode 100644 index 000000000..7c3f468e8 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m @@ -0,0 +1,31 @@ +function installUtilityMenus(obj) +% Class-folder implementation of MatlabPlatformAdapter.installUtilityMenus. + plotMenu = uimenu(obj.Figure, Text="Plot", ... + Tag="labkitAppUtilityPlotMenu"); + uimenu(plotMenu, Text="Pop out all plots", ... + Tag="labkitAppUtilityPopout", ... + MenuSelectedFcn=@(~, ~) obj.runUtility( ... + @() obj.popoutAllPlots())); + uimenu(plotMenu, Text="Copy all plots", ... + Tag="labkitAppUtilityCopyPlot", ... + MenuSelectedFcn=@(~, ~) obj.runUtility( ... + @() obj.copyAllPlots())); + uimenu(plotMenu, Text="Save all plots", ... + Tag="labkitAppUtilitySavePlot", ... + MenuSelectedFcn=@(~, ~) obj.runUtility( ... + @() obj.saveAllPlots())); + uimenu(obj.Figure, Text="Screenshot", ... + Tag="labkitAppUtilityScreenshot", ... + MenuSelectedFcn=@(~, ~) obj.runUtility( ... + @() obj.saveScreenshot())); + if obj.hasProjectDocument() + uimenu(obj.Figure, Text="Save State", ... + Tag="labkitAppUtilitySaveState", ... + MenuSelectedFcn=@(~, ~) obj.runUtility( ... + @() obj.saveState())); + uimenu(obj.Figure, Text="Load State", ... + Tag="labkitAppUtilityLoadState", ... + MenuSelectedFcn=@(~, ~) obj.runUtility( ... + @() obj.loadState())); + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installWorkbenchLayout.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/installWorkbenchLayout.m new file mode 100644 index 000000000..d0efa87b6 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/installWorkbenchLayout.m @@ -0,0 +1,57 @@ +function installWorkbenchLayout(obj, node, component) +% Class-folder implementation of MatlabPlatformAdapter.installWorkbenchLayout. + policy = labkit.app.internal.NativeAdapterValues.layoutPolicy(); + nodes = obj.nodes(node.ChildIds); + hasWorkspace = any(string({nodes.Kind}) == "workspace"); + columns = 1 + hasWorkspace; + gridColumns = columns + hasWorkspace; + grid = uigridlayout(component, [1 gridColumns], ... + Padding=policy.OuterPadding, ... + RowSpacing=0, ... + ColumnSpacing=policy.SplitterSpacing); + grid.Tag = "labkitAppWorkbenchGrid"; + if hasWorkspace + grid.ColumnWidth = {policy.ControlPaneWidth, ... + policy.SplitterThickness, '1x'}; + else + grid.ColumnWidth = {'1x'}; + end + controls = nodes(string({nodes.Kind}) ~= "workspace"); + if ~isempty(controls) && ... + all(string({controls.Kind}) == "tab") + controlPanel = uipanel(grid, Title="Controls"); + controlPanel.Tag = "labkitAppControlsPanel"; + controlGrid = uigridlayout(controlPanel, [1 1], ... + Padding=[10 8 10 8]); + controlParent = uitabgroup(controlGrid); + controlContainer = controlPanel; + else + controlPanel = uipanel(grid, BorderType="none"); + controlGrid = uigridlayout(controlPanel, ... + [max(1, numel(controls)) 1], ... + Padding=[0 0 0 0], RowSpacing=5, ColumnSpacing=0); + if ~isempty(controls) + controlGrid.RowHeight = ... + obj.childRowHeights(string({controls.Id})); + end + controlParent = controlGrid; + controlContainer = controlPanel; + end + controlContainer.Layout.Row = 1; + controlContainer.Layout.Column = 1; + obj.WorkbenchControls = controlParent; + if hasWorkspace + labkit.app.internal.NativeAdapterValues.installColumnDivider(obj.Figure, grid, 1, 2); + workspaceNode = nodes(string({nodes.Kind}) == "workspace"); + workspace = uipanel(grid, ... + Title=char(workspaceNode.Configuration.Title), ... + Tag="labkitAppWorkspacePanel"); + workspace.Layout.Row = 1; + workspace.Layout.Column = 3; + workspaceGrid = uigridlayout(workspace, [1 1], ... + Padding=[0 0 0 0], RowSpacing=0, ColumnSpacing=0); + obj.WorkbenchWorkspace = workspaceGrid; + else + obj.WorkbenchWorkspace = component; + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/interactionTargetAxes.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/interactionTargetAxes.m new file mode 100644 index 000000000..8f5a73331 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/interactionTargetAxes.m @@ -0,0 +1,19 @@ +function targets = interactionTargetAxes(obj) +% Class-folder implementation of MatlabPlatformAdapter.interactionTargetAxes. + targets = struct("id", {}, "axes", {}); + for k = 1:numel(obj.Plan.Nodes) + node = obj.Plan.Nodes(k); + if node.Kind ~= "plotArea" + continue; + end + for axisId = node.AxisIds + key = labkit.app.internal.NativeAdapterValues.axisKey(node.Id, axisId); + targetId = key; + if numel(node.AxisIds) == 1 + targetId = node.Id; + end + targets(end + 1) = struct( ... + "id", targetId, "axes", obj.Axes(char(key))); + end + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m new file mode 100644 index 000000000..f143f358c --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m @@ -0,0 +1,14 @@ +function tf = isGrowableTabChild(obj, node) +% Class-folder implementation of MatlabPlatformAdapter.isGrowableTabChild. + if node.Kind == "section" && numel(node.ChildIds) == 1 + tf = obj.isGrowableTabChild(obj.node(node.ChildIds(1))); + return + end + if node.Kind == "fileList" + tf = node.Configuration.SelectionMode == "multiple" || ... + node.Configuration.MaxFiles > 1; + return + end + tf = any(node.Kind == [ ... + "plotArea", "dataTable", "logPanel", "statusPanel"]); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/nodes.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/nodes.m new file mode 100644 index 000000000..804f98a9c --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/nodes.m @@ -0,0 +1,12 @@ +function selected = nodes(obj, ids) +% Class-folder implementation of MatlabPlatformAdapter.nodes. + selected = repmat(obj.Plan.Nodes(1), 0, 1); + for id = string(ids) + index = find(string({obj.Plan.Nodes.Id}) == id, 1); + if isempty(index) + error("labkit:app:runtime:InvariantFailure", ... + "Compiled Layout child is missing: %s.", id); + end + selected(end + 1, 1) = obj.Plan.Nodes(index); + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/owningNode.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/owningNode.m new file mode 100644 index 000000000..83bdad97c --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/owningNode.m @@ -0,0 +1,10 @@ +function owner = owningNode(obj, id) +% Class-folder implementation of MatlabPlatformAdapter.owningNode. + owner = []; + for k = 1:numel(obj.Plan.Nodes) + if any(obj.Plan.Nodes(k).ChildIds == id) + owner = obj.Plan.Nodes(k); + return + end + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/parentFor.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/parentFor.m new file mode 100644 index 000000000..bc32d1336 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/parentFor.m @@ -0,0 +1,23 @@ +function parent = parentFor(obj, node) +% Class-folder implementation of MatlabPlatformAdapter.parentFor. + if node.Kind == "workbench" + parent = obj.Figure; + return; + end + parent = obj.Figure; + nodes = obj.Plan.Nodes; + for k = 1:numel(nodes) + if any(nodes(k).ChildIds == node.Id) + if nodes(k).Kind == "workbench" + if node.Kind == "workspace" + parent = obj.WorkbenchWorkspace; + else + parent = obj.WorkbenchControls; + end + return; + end + parent = obj.contentParent(nodes(k).Id); + return; + end + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/placeInParent.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/placeInParent.m new file mode 100644 index 000000000..59c952b51 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/placeInParent.m @@ -0,0 +1,42 @@ +function placeInParent(obj, node, component) +% Class-folder implementation of MatlabPlatformAdapter.placeInParent. + if node.Kind == "workbench" || isempty(component) + return + end + parentNode = obj.owningNode(node.Id); + if isempty(parentNode) || parentNode.Kind == "workbench" + return + end + index = find(parentNode.ChildIds == node.Id, 1); + if isempty(index) + return + end + handle = labkit.app.internal.NativeAdapterValues.layoutHandle(component); + if isempty(handle) || ~isprop(handle, "Layout") + return + end + horizontal = parentNode.Kind == "group" && ... + isfield(parentNode.Configuration, "Layout") && ... + parentNode.Configuration.Layout == "horizontal"; + if obj.usesAdaptiveActionGrid(parentNode) + [~, columns] = obj.actionGridSize(parentNode); + row = ceil(index / columns); + column = mod(index - 1, columns) + 1; + if columns > 1 && index == numel(parentNode.ChildIds) && ... + mod(numel(parentNode.ChildIds), columns) == 1 + column = [1 columns]; + end + handle.Layout.Row = row; + handle.Layout.Column = column; + elseif horizontal + handle.Layout.Row = 1; + handle.Layout.Column = index; + else + row = index; + if parentNode.Kind == "tab" + row = 2 * index - 1; + end + handle.Layout.Row = row; + handle.Layout.Column = 1; + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m new file mode 100644 index 000000000..c92fcf0af --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m @@ -0,0 +1,84 @@ +function height = preferredRowHeight(obj, node) +% Class-folder implementation of MatlabPlatformAdapter.preferredRowHeight. + policy = labkit.app.internal.NativeAdapterValues.layoutPolicy(); + switch node.Kind + case {"tab", "workspace", "workspacePage", "plotArea"} + height = "1x"; + case "fileList" + if node.Configuration.SelectionMode == "single" && ... + node.Configuration.MaxFiles == 1 + height = policy.CompactFileHeight; + elseif ~node.Configuration.ShowStatus + height = policy.FileListNoStatusHeight; + else + height = policy.FileListHeight; + end + case "dataTable" + height = policy.TableHeight; + case "logPanel" + height = policy.LogHeight; + case "statusPanel" + if node.Id == "applicationUsage" + height = policy.UsageHeight; + else + height = policy.StatusHeight; + end + case "button" + height = labkit.app.internal.NativeAdapterValues.estimatedControlHeight( ... + node.Configuration.Label, 22, 2, ... + policy.ButtonHeight); + case "slider" + height = policy.SliderHeight; + case "field" + if node.Configuration.Kind == "readonly" + text = [string(node.Configuration.Label), ... + string(node.Configuration.Value)]; + height = labkit.app.internal.NativeAdapterValues.estimatedControlHeight( ... + text, 34, 3, policy.FieldHeight); + elseif node.Configuration.Kind == "logical" + height = labkit.app.internal.NativeAdapterValues.estimatedControlHeight( ... + node.Configuration.Label, 42, 2, ... + policy.FieldHeight); + else + height = labkit.app.internal.NativeAdapterValues.estimatedControlHeight( ... + node.Configuration.Label, 30, 2, ... + policy.FieldHeight); + end + case {"rangeField", "panner"} + height = policy.FieldHeight; + case {"section", "group"} + children = obj.nodes(node.ChildIds); + childHeights = zeros(1, numel(children)); + for k = 1:numel(children) + candidate = obj.preferredRowHeight(children(k)); + if ischar(candidate) || isstring(candidate) + candidate = policy.FileListHeight; + end + childHeights(k) = candidate; + end + horizontal = node.Kind == "group" && ... + isfield(node.Configuration, "Layout") && ... + node.Configuration.Layout == "horizontal"; + if node.Kind == "section" && ... + ~obj.sectionDrawsOwnTitle(node) && ... + numel(children) == 1 + height = childHeights(1) + ... + policy.UntitledSectionChromeHeight; + elseif obj.usesAdaptiveActionGrid(node) + [rows, ~] = obj.actionGridSize(node); + height = rows * policy.ButtonHeight + ... + max(0, rows - 1) * policy.ContentSpacing + ... + policy.GroupChromeHeight; + elseif horizontal + height = max(childHeights, [], "omitnan") + ... + policy.GroupChromeHeight; + else + height = sum(childHeights) + ... + max(0, numel(children) - 1) * ... + policy.ContentSpacing + ... + policy.SectionChromeHeight; + end + otherwise + height = "fit"; + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/renderPlot.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/renderPlot.m new file mode 100644 index 000000000..7eb221870 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/renderPlot.m @@ -0,0 +1,17 @@ +function renderPlot(obj, operation) +% Class-folder implementation of MatlabPlatformAdapter.renderPlot. + node = obj.node(operation.Target); + renderer = node.Renderer; + axesById = struct(); + axes = gobjects(1, numel(node.AxisIds)); + for k = 1:numel(node.AxisIds) + axes(k) = obj.Axes(char(labkit.app.internal.NativeAdapterValues.axisKey(node.Id, node.AxisIds(k)))); + axesById.(char(node.AxisIds(k))) = axes(k); + end + viewport = labkit.app.internal.NativeAdapterValues.captureViewport(axes); + renderer(axesById, operation.Value); + for k = 1:numel(axes) + labkit.app.plot.enablePopout(axes(k)); + end + labkit.app.internal.NativeAdapterValues.restoreViewport(axes, viewport); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/sectionDrawsOwnTitle.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/sectionDrawsOwnTitle.m new file mode 100644 index 000000000..0ffefa716 --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/sectionDrawsOwnTitle.m @@ -0,0 +1,11 @@ +function tf = sectionDrawsOwnTitle(obj, node) +% Class-folder implementation of MatlabPlatformAdapter.sectionDrawsOwnTitle. + tf = true; + if node.Kind ~= "section" || numel(node.ChildIds) ~= 1 + return + end + child = obj.node(node.ChildIds(1)); + tf = ~any(child.Kind == [ ... + "plotArea", "dataTable", "logPanel", ... + "statusPanel", "fileList"]); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/usesAdaptiveActionGrid.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/usesAdaptiveActionGrid.m new file mode 100644 index 000000000..4bef8478d --- /dev/null +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/usesAdaptiveActionGrid.m @@ -0,0 +1,12 @@ +function tf = usesAdaptiveActionGrid(obj, node) +% Class-folder implementation of MatlabPlatformAdapter.usesAdaptiveActionGrid. + tf = false; + if node.Kind ~= "group" || ... + ~isfield(node.Configuration, "Layout") || ... + node.Configuration.Layout ~= "auto" || ... + isempty(node.ChildIds) + return + end + children = obj.nodes(node.ChildIds); + tf = all(string({children.Kind}) == "button"); +end diff --git a/+labkit/+app/+internal/CallbackContextFactory.m b/+labkit/+app/+internal/CallbackContextFactory.m new file mode 100644 index 000000000..ed063e128 --- /dev/null +++ b/+labkit/+app/+internal/CallbackContextFactory.m @@ -0,0 +1,13 @@ +classdef (Hidden, Sealed) CallbackContextFactory + % Internal construction boundary for callback capability ports. + + methods (Static) + function context = create(backend) + context = labkit.app.CallbackContext(backend); + end + + function context = disconnected() + context = labkit.app.CallbackContext(struct()); + end + end +end diff --git a/+labkit/+app/+internal/CompiledDefinition.m b/+labkit/+app/+internal/CompiledDefinition.m new file mode 100644 index 000000000..a47adfb3d --- /dev/null +++ b/+labkit/+app/+internal/CompiledDefinition.m @@ -0,0 +1,170 @@ +classdef (Hidden, Sealed) CompiledDefinition + % Internal immutable layout, signal, and presentation contract. + + properties (SetAccess = immutable) + TargetIds (1, :) string + PlatformPlan (1, 1) struct + end + + properties (SetAccess = immutable, GetAccess = private) + TargetNodes (1, :) cell + SignalBindings (1, :) cell + OnStartBinding + end + + methods (Access = ?labkit.app.Definition) + function obj = CompiledDefinition(layout, startCallback) + if ~isa(layout, "labkit.app.internal.LayoutNode") || ... + layout.Kind ~= "workbench" + error("labkit:app:contract:InvalidValue", ... + "Definition Workbench must be a workbench Layout value."); + end + onStart = []; + if ~isempty(startCallback) + onStart = labkit.app.internal.SignalBinding( ... + "application", "started", startCallback); + end + nodes = layout.flattenForCompiler(); + ids = string(cellfun(@(value) value.Id, nodes, ... + "UniformOutput", false)); + interactions = collectInteractions(nodes); + interactionIds = string(cellfun(@(value) value.Id, ... + interactions, "UniformOutput", false)); + assertUnique([ids interactionIds], "Layout and interaction"); + targetMask = cellfun(@(value) ... + ~isempty(value.Capabilities), nodes); + obj.TargetNodes = [nodes(targetMask) interactions]; + obj.TargetIds = string(cellfun(@(value) value.Id, ... + obj.TargetNodes, "UniformOutput", false)); + obj.OnStartBinding = onStart; + obj.SignalBindings = collectSignalBindings( ... + [nodes interactions], onStart); + obj.PlatformPlan = compilePlatformPlan(nodes); + end + end + + methods + function accepted = validateViewSnapshot(obj, view) + if ~isa(view, "labkit.app.view.Snapshot") + error("labkit:app:contract:InvalidValue", ... + "Definition view snapshot must be a " + ... + "labkit.app.view.Snapshot value."); + end + operations = view.operationsForCompiler(); + covered = false(size(obj.TargetIds)); + for k = 1:numel(operations) + operation = operations{k}; + index = find(obj.TargetIds == operation.Target, 1); + if isempty(index) + error("labkit:app:contract:UnknownReference", ... + "Unknown view target: %s.", operation.Target); + end + node = obj.TargetNodes{index}; + if ~any(node.Capabilities == operation.Kind) + error("labkit:app:contract:UnsupportedOperation", ... + "Target %s does not support %s.", ... + operation.Target, operation.Kind); + end + if operation.Kind == "renderPlot" && isempty(node.Renderer) + error("labkit:app:contract:UnknownReference", ... + "Target %s does not declare a renderer.", ... + operation.Target); + end + covered(index) = true; + end + missing = obj.TargetIds(~covered); + if ~isempty(missing) + error("labkit:app:contract:UnknownReference", ... + "Complete view snapshot is missing target: %s.", ... + missing(1)); + end + accepted = true; + end + + function ids = signalIds(obj) + ids = string(cellfun(@(binding) binding.Id, ... + obj.SignalBindings, "UniformOutput", false)); + end + + function binding = onStartBinding(obj) + binding = obj.OnStartBinding; + end + + function tf = hasSignal(obj, binding) + tf = any(cellfun(@(candidate) ... + isequaln(candidate, binding), obj.SignalBindings)); + end + end +end + +function plan = compilePlatformPlan(nodes) +compiled = repmat(struct( ... + "Kind", "", "Id", "", "ChildIds", strings(1, 0), ... + "Capabilities", strings(1, 0), "Signals", {{}}, ... + "Renderer", [], "AxisIds", strings(1, 0), ... + "PageIds", strings(1, 0), "InitialPage", "", ... + "Configuration", struct()), 1, numel(nodes)); +for k = 1:numel(nodes) + node = nodes{k}; + childIds = string(cellfun(@(child) child.Id, node.Children, ... + "UniformOutput", false)); + compiled(k) = struct( ... + "Kind", node.Kind, "Id", node.Id, "ChildIds", childIds, ... + "Capabilities", node.Capabilities, ... + "Signals", {node.Signals}, ... + "Renderer", node.Renderer, "AxisIds", node.AxisIds, ... + "PageIds", node.PageIds, "InitialPage", node.InitialPage, ... + "Configuration", node.configurationForCompiler()); +end +plan = struct("Nodes", compiled); +end + +function bindings = collectSignalBindings(nodes, start) +bindings = {}; +for k = 1:numel(nodes) + signals = nodes{k}.Signals; + for s = 1:numel(signals) + bindings{end + 1} = signals{s}; + end +end +if ~isempty(start) + bindings{end + 1} = start; +end +bindings = uniqueBindings(bindings); +end + +function interactions = collectInteractions(nodes) +interactions = {}; +for k = 1:numel(nodes) + if nodes{k}.Kind ~= "plotArea" + continue; + end + configuration = nodes{k}.configurationForCompiler(); + if isfield(configuration, "Interactions") + interactions = [interactions configuration.Interactions]; + end +end +end + +function assertUnique(values, label) +if numel(unique(values)) ~= numel(values) + error("labkit:app:contract:DuplicateId", ... + "%s IDs must be globally unique.", label); +end +end + +function values = uniqueBindings(values) +uniqueValues = {}; +for k = 1:numel(values) + value = values{k}; + sameId = find(cellfun(@(candidate) candidate.Id == value.Id, ... + uniqueValues), 1); + if isempty(sameId) + uniqueValues{end + 1} = value; + elseif ~isequaln(uniqueValues{sameId}, value) + error("labkit:app:contract:DuplicateId", ... + "Layout signal ID %s has conflicting callbacks.", value.Id); + end +end +values = uniqueValues; +end diff --git a/+labkit/+app/+internal/DefinitionInspector.m b/+labkit/+app/+internal/DefinitionInspector.m new file mode 100644 index 000000000..9734a41c5 --- /dev/null +++ b/+labkit/+app/+internal/DefinitionInspector.m @@ -0,0 +1,28 @@ +classdef (Hidden, Sealed) DefinitionInspector + % Internal test inspection boundary for compiled App definitions. + + methods (Static) + function ids = targetIds(definition) + contract = compiledContract(definition); + ids = contract.TargetIds; + end + + function ids = signalIds(definition) + contract = compiledContract(definition); + ids = contract.signalIds(); + end + + function plan = platformPlan(definition) + contract = compiledContract(definition); + plan = contract.PlatformPlan; + end + end +end + +function contract = compiledContract(definition) +if ~isa(definition, "labkit.app.Definition") || ~isscalar(definition) + error("labkit:app:runtime:InvariantFailure", ... + "DefinitionInspector requires one Definition."); +end +contract = definition.Compiled; +end diff --git a/+labkit/+app/+internal/DiagnosticRecorder.m b/+labkit/+app/+internal/DiagnosticRecorder.m new file mode 100644 index 000000000..ce17796c8 --- /dev/null +++ b/+labkit/+app/+internal/DiagnosticRecorder.m @@ -0,0 +1,393 @@ +classdef (Hidden, Sealed) DiagnosticRecorder < handle + %DIAGNOSTICRECORDER Record sanitized App SDK semantic operations. + % + % Expected callers are RuntimeKernel and focused framework tests. Inputs + % are one compiled Definition and one diagnostic Options value. Events use + % a fixed schema, relative elapsed time, semantic IDs, and sanitized error + % details. Verbose sessions append JSON lines and atomically maintain the + % active-operation marker. Recorder failures never alter App execution. + + properties (Access = private) + Application + Options + StartTimer + Sequence (1, 1) double = 0 + OperationSequence (1, 1) double = 0 + EventBuffer (1, :) cell = {} + OperationStack (1, :) cell = {} + EventsFile (1, 1) string = "" + ActiveFile (1, 1) string = "" + ManifestFile (1, 1) string = "" + DiskEnabled (1, 1) logical = false + Closed (1, 1) logical = false + end + + methods + function obj = DiagnosticRecorder(application, options) + if ~isa(application, "labkit.app.Definition") || ... + ~isscalar(application) + error("labkit:app:runtime:InvariantFailure", ... + "DiagnosticRecorder requires one Definition."); + end + if nargin < 2 || isempty(options) + options = labkit.app.diagnostic.Options(); + end + if ~isa(options, "labkit.app.diagnostic.Options") || ... + ~isscalar(options) + error("labkit:app:runtime:InvariantFailure", ... + "DiagnosticRecorder requires diagnostic Options."); + end + obj.Application = application; + obj.Options = options; + obj.StartTimer = tic; + obj.initializeDisk(); + obj.note("lifecycle", "runtime", "created", "completed"); + end + + function operation = begin(obj, category, targetId, signal) + operation = struct( ... + "Id", obj.nextOperationId(), ... + "ParentId", obj.currentOperationId(), ... + "Category", semanticText(category), ... + "TargetId", semanticText(targetId), ... + "Signal", semanticText(signal), ... + "Timer", tic); + obj.OperationStack{end + 1} = operation; + if obj.Options.Level == "verbose" + obj.appendEvent(operation.Category, operation.Id, ... + operation.ParentId, operation.TargetId, ... + operation.Signal, "begin", [], [], []); + obj.writeActiveOperation(operation); + end + end + + function finish(obj, operation, outcome, exception) + if nargin < 4 + exception = []; + end + duration = elapsed(operation); + outcome = semanticText(outcome); + failed = outcome ~= "completed"; + if obj.Options.Level == "verbose" || failed || duration >= 30 + obj.appendEvent(operation.Category, operation.Id, ... + operation.ParentId, operation.TargetId, ... + operation.Signal, outcome, duration, exception, []); + end + obj.removeOperation(operation.Id); + obj.refreshActiveOperation(); + end + + function note(obj, category, targetId, signal, outcome) + category = semanticText(category); + outcome = semanticText(outcome); + if obj.Options.Level == "verbose" || ... + category == "lifecycle" || outcome ~= "completed" + obj.appendEvent(category, "", obj.currentOperationId(), ... + semanticText(targetId), semanticText(signal), ... + outcome, [], [], []); + end + end + + function count(obj, id, value) + if ~(isnumeric(value) && isscalar(value) && isfinite(value) && ... + value >= 0 && value == fix(value)) + error("labkit:app:contract:InvalidValue", ... + "Diagnostic count must be a nonnegative integer."); + end + if obj.Options.Level == "verbose" + obj.appendEvent("app", "", obj.currentOperationId(), ... + semanticText(id), "count", "completed", ... + [], [], double(value)); + end + end + + function events = events(obj) + if isempty(obj.EventBuffer) + events = repmat(eventTemplate(), 0, 1); + else + events = vertcat(obj.EventBuffer{:}); + end + end + + function folder = artifactFolder(obj) + folder = obj.Options.ArtifactFolder; + end + + function close(obj) + if obj.Closed + return; + end + obj.note("lifecycle", "runtime", "closed", "completed"); + obj.Closed = true; + obj.OperationStack = {}; + obj.removeActiveFile(); + obj.writeManifest("closed"); + end + + function delete(obj) + obj.close(); + end + end + + methods (Access = private) + function initializeDisk(obj) + if obj.Options.Level ~= "verbose" || ... + strlength(obj.Options.ArtifactFolder) == 0 + return; + end + try + folder = obj.Options.ArtifactFolder; + if exist(char(folder), "dir") ~= 7 + mkdir(char(folder)); + end + obj.EventsFile = string(fullfile(folder, "events.jsonl")); + obj.ActiveFile = string(fullfile( ... + folder, "active-operation.json")); + obj.ManifestFile = string(fullfile(folder, "manifest.json")); + initializeTextFile(obj.EventsFile); + obj.DiskEnabled = true; + obj.writeManifest("active"); + catch + obj.DiskEnabled = false; + obj.EventsFile = ""; + obj.ActiveFile = ""; + obj.ManifestFile = ""; + end + end + + function id = nextOperationId(obj) + obj.OperationSequence = obj.OperationSequence + 1; + id = "op-" + string(obj.OperationSequence); + end + + function id = currentOperationId(obj) + id = ""; + if ~isempty(obj.OperationStack) + id = string(obj.OperationStack{end}.Id); + end + end + + function appendEvent(obj, category, operationId, parentId, ... + targetId, signal, outcome, duration, exception, count) + obj.Sequence = obj.Sequence + 1; + event = eventTemplate(); + event.Sequence = obj.Sequence; + event.ElapsedSeconds = toc(obj.StartTimer); + event.Level = obj.Options.Level; + event.Category = string(category); + event.OperationId = string(operationId); + event.ParentOperationId = string(parentId); + event.TargetId = string(targetId); + event.Signal = string(signal); + event.Outcome = string(outcome); + event.DurationSeconds = duration; + event.Count = count; + if isa(exception, "MException") && isscalar(exception) + event.ErrorId = string(exception.identifier); + event.ErrorMessage = obj.sanitizeText(exception.message); + stack = exception.stack; + event.ErrorStack = strings(numel(stack), 1); + for k = 1:numel(stack) + event.ErrorStack(k) = string(stack(k).name) + ... + ":" + string(stack(k).line); + end + end + obj.EventBuffer{1, end + 1} = event; + if numel(obj.EventBuffer) > 512 + obj.EventBuffer(1) = []; + end + obj.appendJsonLine(event); + end + + function appendJsonLine(obj, event) + if ~obj.DiskEnabled + return; + end + try + file = fopen(char(obj.EventsFile), "a", "n", "UTF-8"); + if file < 0 + obj.DiskEnabled = false; + return; + end + cleanup = onCleanup(@() fclose(file)); + fprintf(file, "%s\n", jsonencode(event)); + clear cleanup + catch + obj.DiskEnabled = false; + end + end + + function writeActiveOperation(obj, operation) + if ~obj.DiskEnabled + return; + end + payload = struct( ... + "operationId", operation.Id, ... + "parentOperationId", operation.ParentId, ... + "category", operation.Category, ... + "targetId", operation.TargetId, ... + "signal", operation.Signal, ... + "elapsedSeconds", toc(obj.StartTimer)); + obj.writeJsonAtomically(obj.ActiveFile, payload); + end + + function refreshActiveOperation(obj) + if ~obj.DiskEnabled + return; + end + if isempty(obj.OperationStack) + obj.removeActiveFile(); + else + obj.writeActiveOperation(obj.OperationStack{end}); + end + end + + function removeOperation(obj, id) + if isempty(obj.OperationStack) + return; + end + ids = string(cellfun(@(value) value.Id, obj.OperationStack, ... + "UniformOutput", false)); + index = find(ids == string(id), 1, "last"); + if ~isempty(index) + obj.OperationStack(index) = []; + end + end + + function removeActiveFile(obj) + if strlength(obj.ActiveFile) == 0 + return; + end + try + if exist(char(obj.ActiveFile), "file") == 2 + delete(char(obj.ActiveFile)); + end + catch + end + end + + function writeManifest(obj, status) + if ~obj.DiskEnabled && status ~= "active" + return; + end + try + sdk = labkit.app.version(); + payload = struct( ... + "schemaVersion", 1, ... + "status", string(status), ... + "appId", obj.Application.AppId, ... + "entrypoint", obj.Application.Entrypoint, ... + "appVersion", obj.Application.AppVersion, ... + "appSdkVersion", sdk.current, ... + "matlabRelease", string(version("-release")), ... + "platform", string(computer), ... + "level", obj.Options.Level, ... + "sample", obj.Options.Sample, ... + "eventCount", obj.Sequence); + obj.writeJsonAtomically(obj.ManifestFile, payload); + catch + obj.DiskEnabled = false; + end + end + + function writeJsonAtomically(obj, filepath, payload) + if ~obj.DiskEnabled || strlength(filepath) == 0 + return; + end + temporary = string(tempname(fileparts(filepath))); + try + file = fopen(char(temporary), "w", "n", "UTF-8"); + if file < 0 + obj.DiskEnabled = false; + return; + end + cleanup = onCleanup(@() fclose(file)); + fprintf(file, "%s\n", jsonencode(payload, PrettyPrint=true)); + clear cleanup + [moved, ~] = movefile(char(temporary), char(filepath), "f"); + if ~moved + obj.DiskEnabled = false; + end + catch + obj.DiskEnabled = false; + end + if exist(char(temporary), "file") == 2 + try + delete(char(temporary)); + catch + end + end + end + + function text = sanitizeText(obj, value) + text = string(value); + roots = [ ... + string(getenv("USERPROFILE")) + string(getenv("HOME")) + string(userpath) + string(tempdir) + obj.Options.ArtifactFolder]; + roots = unique(roots(strlength(roots) > 0)); + for root = reshape(roots, 1, []) + pattern = regexptranslate("escape", root); + if ispc + text = regexprep(text, pattern, "", "ignorecase"); + else + text = regexprep(text, pattern, ""); + end + end + text = regexprep(text, ... + '(?'); + text = regexprep(text, ... + '(?'); + text = regexprep(text, ... + '(?i)\b[^\s\\/]+\.(csv|mat|json|txt|png|jpg|jpeg|tif|tiff|avi|xlsx|dta|rhs)\b', ... + ''); + end + end +end + +function event = eventTemplate() +event = struct( ... + "Sequence", 0, ... + "ElapsedSeconds", 0, ... + "Level", "", ... + "Category", "", ... + "OperationId", "", ... + "ParentOperationId", "", ... + "TargetId", "", ... + "Signal", "", ... + "Outcome", "", ... + "DurationSeconds", [], ... + "ErrorId", "", ... + "ErrorMessage", "", ... + "ErrorStack", strings(0, 1), ... + "Count", []); +end + +function value = semanticText(value) +if ~(ischar(value) || (isstring(value) && isscalar(value))) + error("labkit:app:runtime:InvariantFailure", ... + "Diagnostic semantic values must be scalar text."); +end +value = string(value); +end + +function seconds = elapsed(operation) +seconds = 0; +try + seconds = toc(operation.Timer); +catch +end +end + +function initializeTextFile(filepath) +file = fopen(char(filepath), "w", "n", "UTF-8"); +if file < 0 + error("labkit:app:runtime:InvariantFailure", ... + "Could not initialize diagnostic events file."); +end +cleanup = onCleanup(@() fclose(file)); +clear cleanup +end diff --git a/+labkit/+app/+internal/HeadlessPlatformAdapter.m b/+labkit/+app/+internal/HeadlessPlatformAdapter.m new file mode 100644 index 000000000..4daa5391c --- /dev/null +++ b/+labkit/+app/+internal/HeadlessPlatformAdapter.m @@ -0,0 +1,28 @@ +classdef (Hidden, Sealed) HeadlessPlatformAdapter < handle + % Private deterministic adapter used before concrete MATLAB UI creation. + properties (SetAccess = private) + CommitCount (1, 1) double = 0 + end + + properties (Access = private) + FailNext (1, 1) logical = false + end + + methods (Access = ?labkit.app.internal.RuntimeKernel) + function reconcile(obj, ~, ~) + if obj.FailNext + obj.FailNext = false; + error("labkit:app:runtime:InvariantFailure", ... + "Injected platform commit failure."); + end + obj.CommitCount = obj.CommitCount + 1; + end + + function failNextCommit(obj) + obj.FailNext = true; + end + + function close(~) + end + end +end diff --git a/+labkit/+app/+internal/InteractionSpec.m b/+labkit/+app/+internal/InteractionSpec.m new file mode 100644 index 000000000..3e5eeb76c --- /dev/null +++ b/+labkit/+app/+internal/InteractionSpec.m @@ -0,0 +1,140 @@ +classdef (Sealed, Hidden) InteractionSpec + %INTERACTIONSPEC Private immutable declaration for one managed gesture. + % + % Expected callers: public labkit.app.interaction constructors, + % LayoutNode.plotArea, Definition, and the native adapter. It owns static + % identity, styling, viewport policy, and direct callback bindings. Dynamic + % values remain in view Snapshot operations. + + properties (SetAccess = private) + Kind (1, 1) string + Id (1, 1) string + AxisIds (1, :) string + Targets (1, :) string + Capabilities (1, :) string + Signals (1, :) cell + Options (1, 1) struct + Instruction (1, 1) string + ViewportPolicy (1, 1) string + end + + methods + function obj = InteractionSpec(kind, id, onChanged, options) + obj.Kind = enumText(kind, [ ... + "anchorPath", "pairedAnchors", "pointSlots", ... + "rectangle", "regionSelection", "interval", ... + "scaleReference"], "kind"); + obj.Id = scalarId(id, "id"); + axes = optionValue(options, "Axes", ... + optionValue(options, "Axis", "main")); + obj.AxisIds = idRow(axes, "Axis"); + if obj.Kind ~= "pairedAnchors" && numel(obj.AxisIds) ~= 1 + error("labkit:app:contract:InvalidValue", ... + "Interaction %s requires exactly one Axis.", obj.Id); + elseif obj.Kind == "pairedAnchors" && numel(obj.AxisIds) < 2 + error("labkit:app:contract:InvalidValue", ... + "pairedAnchors requires at least two Axes."); + end + obj.Targets = strings(1, 0); + obj.Capabilities = obj.Kind; + obj.Signals = {labkit.app.internal.SignalBinding( ... + obj.Id, "interactionChanged", onChanged)}; + obj.Options = optionValue(options, "Style", struct()); + if ~isstruct(obj.Options) || ~isscalar(obj.Options) + error("labkit:app:contract:InvalidValue", ... + "Interaction Style must be a scalar struct."); + end + obj.Instruction = scalarText(optionValue( ... + options, "Instruction", ""), "Instruction"); + obj.ViewportPolicy = enumText(optionValue( ... + options, "ViewportPolicy", "preserve"), ... + ["preserve", "fit"], "ViewportPolicy"); + + background = optionValue(options, "OnBackgroundPressed", []); + if ~isempty(background) + obj.Signals{end + 1} = labkit.app.internal.SignalBinding( ... + obj.Id, "backgroundPressed", background); + end + scrolled = optionValue(options, "OnScrolled", []); + if ~isempty(scrolled) + obj.Signals{end + 1} = labkit.app.internal.SignalBinding( ... + obj.Id, "scrolled", scrolled); + end + end + + function result = attachToPlot(obj, plotId, axisIds) + plotId = scalarId(plotId, "plot id"); + missing = setdiff(obj.AxisIds, axisIds, "stable"); + if ~isempty(missing) + error("labkit:app:contract:UnknownReference", ... + "Interaction %s references undeclared axis %s.", ... + obj.Id, missing(1)); + end + result = obj; + if numel(axisIds) == 1 + result.Targets = repmat(plotId, size(obj.AxisIds)); + else + result.Targets = plotId + "." + obj.AxisIds; + end + end + + function binding = signal(obj, signalName) + match = find(cellfun(@(value) ... + value.Signal == signalName, obj.Signals), 1); + binding = []; + if ~isempty(match) + binding = obj.Signals{match}; + end + end + end +end + +function values = idRow(values, label) +if ischar(values) + values = string(values); +elseif iscellstr(values) + values = string(values); +elseif ~isstring(values) + error("labkit:app:contract:InvalidValue", ... + "Interaction %s must be text.", label); +end +values = reshape(values, 1, []); +for k = 1:numel(values) + values(k) = scalarId(values(k), label); +end +if isempty(values) || numel(unique(values)) ~= numel(values) + error("labkit:app:contract:InvalidValue", ... + "Interaction %s values must be nonempty and unique.", label); +end +end + +function value = scalarId(value, label) +value = scalarText(value, label); +if strlength(value) == 0 || ~isvarname(char(value)) + error("labkit:app:contract:InvalidValue", ... + "Interaction %s must be a MATLAB identifier.", label); +end +end + +function value = scalarText(value, label) +if ~(ischar(value) || (isstring(value) && isscalar(value))) + error("labkit:app:contract:InvalidValue", ... + "Interaction %s must be scalar text.", label); +end +value = string(value); +end + +function value = enumText(value, allowed, label) +value = scalarText(value, label); +if ~any(value == allowed) + error("labkit:app:contract:InvalidValue", ... + "Interaction %s is unsupported: %s.", label, value); +end +end + +function value = optionValue(options, name, defaultValue) +value = defaultValue; +if isfield(options, name) + value = options.(name); +end +end diff --git a/+labkit/+app/+internal/LayoutNode.m b/+labkit/+app/+internal/LayoutNode.m new file mode 100644 index 000000000..3328cce86 --- /dev/null +++ b/+labkit/+app/+internal/LayoutNode.m @@ -0,0 +1,601 @@ +classdef (Sealed, Hidden) LayoutNode + %LAYOUT Compose an immutable semantic UI ownership graph. + % + % Usage: + % node = labkit.app.internal.LayoutNode.button(id, label, onPressed, Name=Value) + % node = labkit.app.layout.field(id, Name=Value) + % node = labkit.app.layout.rangeField(id, Name=Value) + % node = labkit.app.internal.LayoutNode.slider(id, Name=Value) + % node = labkit.app.internal.LayoutNode.fileList(id, Name=Value) + % node = labkit.app.internal.LayoutNode.plotArea(id, Name=Value) + % node = labkit.app.internal.LayoutNode.dataTable(id, Name=Value) + % node = labkit.app.internal.LayoutNode.logPanel(id) + % node = labkit.app.internal.LayoutNode.statusPanel(id) + % node = labkit.app.layout.group(id, children, Name=Value) + % node = labkit.app.layout.section(id, title, children, Name=Value) + % node = labkit.app.internal.LayoutNode.tab(id, title, children) + % workspace = labkit.app.layout.workspace() + % layout = labkit.app.layout.workbench(children, Workspace=workspace) + % + % Description: + % Layout exposes the fourteen audited semantic concepts through one + % sealed value class. Constructors reject unknown options and + % role-mismatched Command signals. The compiler validates global IDs, + % single-parent ownership, nesting, workspace pages, renderers, and + % target capabilities without creating MATLAB graphics. + % + % Inputs: + % id - Nonempty MATLAB identifier unique within one Application. + % label - Nonempty reader-facing action text. + % title - Nonempty reader-facing section or tab title. + % onPressed - Callback state = callback(state,context). + % children - Row cell array of labkit.app.internal.LayoutNode values. + % + % Name-Value Arguments: + % OnValueChanged - Callback state = callback(state,value,context) for + % field, rangeField, slider, or plot-area mode changes. Default: + % empty. + % Bind - Optional strict state field path rooted at project or session. + % Bound controls need no ValueChanged handler for ordinary updates. + % Expressions, indexing, and function calls are not supported. + % Default: empty. + % SelectionBind - Optional strict state field path for fileList + % selection. Default: empty. + % OnCellEdited - Callback state = callback(state,edit,context) for dataTable. + % Default: empty. + % OnCellSelectionChanged - Callback + % state = callback(state,selection,context) for dataTable. Default: + % empty. + % Columns - Row text array of initial dataTable column labels. + % Default: empty. + % RowNames - Row text array of initial dataTable row labels. + % Default: empty. + % ColumnEditable - Logical scalar or row marking editable dataTable + % columns. Default: false. + % renderer - Callback renderer(axesById,model) owned by plotArea. + % axesById is always a scalar struct keyed by AxisIds. + % AxisIds - Unique axes-ID row for previewArea. Default: "main". + % Workspace - One workspace Layout for workbench. Default: empty. + % Layout - "auto", "vertical", or "horizontal" for group. Default: + % "auto". + % Collapsible - Logical scalar for section. Default: false. + % Expanded - Logical scalar for section. Default: true. + % Kind - "text", "numeric", "choice", or "logical" for field. + % Default: "text". + % Label - Reader-facing field, rangeField, or panner label. Default: + % the semantic id. + % Mode - "files" or "folder" for fileList. Default: "files". + % SelectionMode - "single" or "multiple" for fileList. Default: + % "multiple". + % + % Outputs: + % node - Immutable semantic labkit.app.internal.LayoutNode value. + % workspace - Workspace value supporting page and initialPage methods. + % layout - Root workbench value accepted by labkit.app.Definition. + % + % Layout Methods: + % page(id,title,content) - Return a workspace with one additional page. + % content is one workspace Layout value or a nonempty row cell + % array of workspace Layout values arranged vertically. + % initialPage(id) - Return a workspace selecting a declared page. + % + % Errors: + % labkit:app:contract:UnknownArgument - An option is unknown, duplicated, + % or unpaired. + % labkit:app:contract:InvalidValue - A value, child, or ID is malformed. + % labkit:app:contract:DuplicateId - A workspace page ID repeats. + % labkit:app:contract:UnknownReference - InitialPage is undeclared. + % labkit:app:contract:CallbackRoleMismatch - A signal has the wrong Role. + % labkit:app:contract:UnsupportedOperation - Nesting is illegal. + % + % Typical Call: + % controls = {labkit.app.internal.LayoutNode.button( ... + % "run", "Run", @runAnalysis)}; + % workspace = labkit.app.layout.workspace( ... + % labkit.app.internal.LayoutNode.plotArea( ... + % "result", @drawResult)); + % layout = labkit.app.layout.workbench(controls, Workspace=workspace); + % + % See also labkit.app.Definition, labkit.app.view.Snapshot + + properties (SetAccess = private) + Kind (1, 1) string + Id (1, 1) string + Children (1, :) cell + Capabilities (1, :) string + Signals (1, :) cell + Renderer + AxisIds (1, :) string + PageIds (1, :) string + InitialPage (1, 1) string + end + + properties (SetAccess = private, GetAccess = private) + Configuration (1, 1) struct + end + + methods (Access = private) + function obj = LayoutNode(kind, id, children, capabilities, signals, ... + renderer, axisIds, configuration) + obj.Kind = kind; + obj.Id = id; + obj.Children = children; + obj.Capabilities = capabilities; + obj.Signals = signals; + obj.Renderer = renderer; + obj.AxisIds = axisIds; + obj.PageIds = strings(1, 0); + obj.InitialPage = ""; + obj.Configuration = configuration; + end + end + + methods (Static) + function obj = button(id, label, onPressed, varargin) + options = labkit.app.internal.OptionParser.parse("labkit.app.layout.button", ... + ["BusyMessage", "Enabled", "Tooltip"], varargin{:}); + signal = labkit.app.internal.LayoutNodeValues.bindSignal(id, "pressed", onPressed); + label = labkit.app.internal.LayoutNodeValues.nonemptyText( ... + label, "action label"); + configuration = struct( ... + "Label", label, ... + "BusyMessage", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "BusyMessage", ""), "BusyMessage"), ... + "Enabled", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Enabled", true), "Enabled"), ... + "Tooltip", labkit.app.internal.LayoutNodeValues.nonemptyText( ... + labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Tooltip", label), "Tooltip")); + obj = makeLeaf("button", id, ... + ["enabled", "visible", "text"], {signal}, configuration); + end + + function obj = field(id, varargin) + names = ["Label", "Kind", "Value", "Choices", "Limits", "Step", "Bind", ... + "ValueDisplayFormat", "ShowTicks", "Enabled", ... + "OnValueChanged"]; + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.layout.field", names, varargin{:}); + kind = labkit.app.internal.LayoutNodeValues.enumText(labkit.app.internal.LayoutNodeValues.optionValue(options, "Kind", "text"), ... + ["text", "numeric", "choice", "logical", "readonly"], ... + "field Kind"); + signal = labkit.app.internal.LayoutNodeValues.optionalSignal( ... + id, "valueChanged", labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "OnValueChanged", [])); + configuration = struct( ... + "Label", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue(options, ... + "Label", id), "Label"), ... + "Kind", kind, ... + "Value", labkit.app.internal.LayoutNodeValues.optionValue(options, "Value", []), ... + "Choices", labkit.app.internal.LayoutNodeValues.textRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Choices", strings(1, 0)), "Choices"), ... + "Limits", labkit.app.internal.LayoutNodeValues.optionalLimits(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Limits", []), "Limits"), ... + "Step", labkit.app.internal.LayoutNodeValues.optionalPositive(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Step", []), "Step"), ... + "ValueDisplayFormat", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "ValueDisplayFormat", ""), ... + "ValueDisplayFormat"), ... + "ShowTicks", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "ShowTicks", false), "ShowTicks"), ... + "Enabled", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Enabled", true), "Enabled"), ... + "Bind", labkit.app.internal.LayoutNodeValues.bindingPath(labkit.app.internal.LayoutNodeValues.optionValue(options, "Bind", ""))); + obj = makeLeaf("field", id, ... + ["value", "choices", "limits", "enabled", "visible", "text"], ... + labkit.app.internal.LayoutNodeValues.signalCell(signal), configuration); + end + + function obj = rangeField(id, varargin) + options = labkit.app.internal.OptionParser.parse("labkit.app.layout.rangeField", ... + ["Label", "Value", "Limits", "Enabled", "Bind", ... + "OnValueChanged"], varargin{:}); + signal = labkit.app.internal.LayoutNodeValues.optionalSignal(id, "valueChanged", ... + labkit.app.internal.LayoutNodeValues.optionValue(options, "OnValueChanged", [])); + configuration = struct( ... + "Label", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue(options, ... + "Label", id), "Label"), ... + "Value", labkit.app.internal.LayoutNodeValues.optionalPair(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Value", []), "Value"), ... + "Limits", labkit.app.internal.LayoutNodeValues.optionalLimits(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Limits", []), "Limits"), ... + "Enabled", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Enabled", true), "Enabled"), ... + "Bind", labkit.app.internal.LayoutNodeValues.bindingPath(labkit.app.internal.LayoutNodeValues.optionValue(options, "Bind", ""))); + obj = makeLeaf("rangeField", id, ... + ["value", "limits", "enabled", "visible"], ... + labkit.app.internal.LayoutNodeValues.signalCell(signal), configuration); + end + + function obj = slider(id, varargin) + names = ["Label", "Value", "Limits", "Step", "ShowTicks", ... + "ValueDisplayFormat", "Bind", "Enabled", "OnValueChanged"]; + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.layout.slider", names, varargin{:}); + signal = labkit.app.internal.LayoutNodeValues.optionalSignal(id, "valueChanged", ... + labkit.app.internal.LayoutNodeValues.optionValue(options, "OnValueChanged", [])); + limits = labkit.app.internal.LayoutNodeValues.optionalLimits(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Limits", [0 1]), "Limits"); + value = labkit.app.internal.LayoutNodeValues.optionValue(options, "Value", limits(1)); + if ~(isnumeric(value) && isscalar(value) && isfinite(value)) + error("labkit:app:contract:InvalidValue", ... + "layout.slider Value must be a finite scalar."); + end + configuration = struct( ... + "Label", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue(options, ... + "Label", id), "Label"), ... + "Value", double(value), "Limits", limits, ... + "Step", labkit.app.internal.LayoutNodeValues.optionalPositive(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Step", []), "Step"), ... + "ShowTicks", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "ShowTicks", false), "ShowTicks"), ... + "ValueDisplayFormat", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "ValueDisplayFormat", ""), ... + "ValueDisplayFormat"), ... + "Enabled", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Enabled", true), "Enabled"), ... + "Bind", labkit.app.internal.LayoutNodeValues.bindingPath(labkit.app.internal.LayoutNodeValues.optionValue(options, "Bind", ""))); + obj = makeLeaf("slider", id, ... + ["value", "limits", "enabled", "visible", "text"], ... + labkit.app.internal.LayoutNodeValues.signalCell(signal), configuration); + end + + function obj = fileList(id, varargin) + names = ["Label", "Mode", "Filters", "SelectionMode", "MaxFiles", ... + "FolderWarningThreshold", "ShowStatus", "StartPath", ... + "ChooseLabel", "FolderLabel", "RecursiveFolderLabel", ... + "RemoveLabel", "ClearLabel", "EmptyText", "Bind", ... + "SelectionBind", "SourceRole", "SourceIdPrefix", "Required", ... + "AllowDuplicatePaths", "OnSelectionChanged", ... + "ChooseTooltip", "FolderTooltip", ... + "RecursiveFolderTooltip", "RemoveTooltip", "ClearTooltip"]; + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.layout.fileList", names, varargin{:}); + selectionSignal = labkit.app.internal.LayoutNodeValues.optionalSignal( ... + id, "listSelectionChanged", labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "OnSelectionChanged", [])); + chooseLabel = labkit.app.internal.LayoutNodeValues.scalarText( ... + labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "ChooseLabel", "Choose"), "ChooseLabel"); + folderLabel = labkit.app.internal.LayoutNodeValues.scalarText( ... + labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "FolderLabel", "Choose Folder"), "FolderLabel"); + recursiveFolderLabel = labkit.app.internal.LayoutNodeValues.scalarText( ... + labkit.app.internal.LayoutNodeValues.optionValue(options, ... + "RecursiveFolderLabel", "Choose Folder Recursively"), ... + "RecursiveFolderLabel"); + removeLabel = labkit.app.internal.LayoutNodeValues.scalarText( ... + labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "RemoveLabel", "Remove"), "RemoveLabel"); + clearLabel = labkit.app.internal.LayoutNodeValues.scalarText( ... + labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "ClearLabel", "Clear"), "ClearLabel"); + configuration = struct( ... + "Label", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Label", id), "Label"), ... + "Mode", labkit.app.internal.LayoutNodeValues.enumText(labkit.app.internal.LayoutNodeValues.optionValue(options, "Mode", "files"), ... + ["files", "folder"], "fileList Mode"), ... + "Filters", labkit.app.internal.LayoutNodeValues.textRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Filters", strings(1, 0)), "Filters"), ... + "SelectionMode", labkit.app.internal.LayoutNodeValues.enumText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "SelectionMode", "multiple"), ... + ["single", "multiple"], "SelectionMode"), ... + "MaxFiles", labkit.app.internal.LayoutNodeValues.positiveOrInf(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "MaxFiles", Inf), "MaxFiles"), ... + "FolderWarningThreshold", labkit.app.internal.LayoutNodeValues.positiveOrInf(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "FolderWarningThreshold", 500), ... + "FolderWarningThreshold"), ... + "ShowStatus", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "ShowStatus", true), "ShowStatus"), ... + "StartPath", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "StartPath", ""), "StartPath"), ... + "ChooseLabel", chooseLabel, ... + "FolderLabel", folderLabel, ... + "RecursiveFolderLabel", recursiveFolderLabel, ... + "RemoveLabel", removeLabel, ... + "ClearLabel", clearLabel, ... + "ChooseTooltip", tooltipValue(options, ... + "ChooseTooltip", chooseLabel), ... + "FolderTooltip", tooltipValue(options, ... + "FolderTooltip", folderLabel), ... + "RecursiveFolderTooltip", tooltipValue(options, ... + "RecursiveFolderTooltip", recursiveFolderLabel), ... + "RemoveTooltip", tooltipValue(options, ... + "RemoveTooltip", removeLabel), ... + "ClearTooltip", tooltipValue(options, ... + "ClearTooltip", clearLabel), ... + "EmptyText", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "EmptyText", "No files selected"), "EmptyText"), ... + "AllowDuplicatePaths", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "AllowDuplicatePaths", false), ... + "AllowDuplicatePaths"), ... + "Bind", labkit.app.internal.LayoutNodeValues.bindingPath(labkit.app.internal.LayoutNodeValues.optionValue(options, "Bind", "")), ... + "SelectionBind", labkit.app.internal.LayoutNodeValues.bindingPath(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "SelectionBind", "")), ... + "SourceRole", labkit.app.internal.LayoutNodeValues.nonemptyText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "SourceRole", id), "SourceRole"), ... + "SourceIdPrefix", labkit.app.internal.LayoutNodeValues.nonemptyText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "SourceIdPrefix", id), "SourceIdPrefix"), ... + "Required", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Required", true), "Required")); + obj = makeLeaf("fileList", id, ... + ["filePaths", "fileItemStatuses", "listSelection", ... + "enabled", "visible", "text"], ... + labkit.app.internal.LayoutNodeValues.signalCell(selectionSignal), configuration); + end + + function obj = plotArea(id, renderer, varargin) + names = [ ... + "Title", "Layout", "AxisIds", "AxisTitles", ... + "XLabels", "YLabels", "ColumnWidths", "RowHeights", ... + "ScrollZoomAxes", "ViewModes", "OnValueChanged", ... + "Interactions"]; + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.layout.plotArea", names, varargin{:}); + signal = labkit.app.internal.LayoutNodeValues.optionalSignal(id, "valueChanged", ... + labkit.app.internal.LayoutNodeValues.optionValue(options, "OnValueChanged", [])); + axisIds = labkit.app.internal.LayoutNodeValues.idRow(labkit.app.internal.LayoutNodeValues.optionValue(options, "AxisIds", "main"), "axis"); + renderer = labkit.app.internal.LayoutNodeValues.rendererCallback(renderer); + interactions = labkit.app.internal.LayoutNodeValues.interactionSpecs(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Interactions", {}), id, axisIds); + axisCount = numel(axisIds); + configuration = struct( ... + "Title", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue(options, ... + "Title", ""), "Title"), ... + "Layout", labkit.app.internal.LayoutNodeValues.enumText(labkit.app.internal.LayoutNodeValues.optionValue(options, ... + "Layout", labkit.app.internal.LayoutNodeValues.defaultAxesLayout(axisCount)), ... + ["single", "pair", "stack"], "Layout"), ... + "AxisTitles", labkit.app.internal.LayoutNodeValues.optionalAxisText(options, ... + "AxisTitles", axisCount), ... + "XLabels", labkit.app.internal.LayoutNodeValues.optionalAxisText(options, ... + "XLabels", axisCount), ... + "YLabels", labkit.app.internal.LayoutNodeValues.optionalAxisText(options, ... + "YLabels", axisCount), ... + "ColumnWidths", {labkit.app.internal.LayoutNodeValues.optionalLayoutSizes(options, ... + "ColumnWidths", axisCount)}, ... + "RowHeights", {labkit.app.internal.LayoutNodeValues.optionalLayoutSizes(options, ... + "RowHeights", axisCount)}, ... + "ScrollZoomAxes", labkit.app.internal.LayoutNodeValues.scrollZoomAxes(options, axisCount), ... + "ViewModes", labkit.app.internal.LayoutNodeValues.textRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "ViewModes", strings(1, 0)), "ViewModes"), ... + "Interactions", {interactions}); + labkit.app.internal.LayoutNodeValues.assertAxesLayout(configuration.Layout, axisCount); + obj = labkit.app.internal.LayoutNode("plotArea", labkit.app.internal.LayoutNodeValues.normalizeId(id), {}, ... + ["renderPlot", "value", "visible"], labkit.app.internal.LayoutNodeValues.signalCell(signal), ... + renderer, axisIds, configuration); + end + + function obj = dataTable(id, varargin) + options = labkit.app.internal.OptionParser.parse("labkit.app.layout.dataTable", ... + ["Title", "Columns", "RowNames", "ColumnEditable", ... + "OnCellEdited", "OnCellSelectionChanged"], varargin{:}); + signals = { + labkit.app.internal.LayoutNodeValues.namedSignal(id, options, "OnCellEdited", "cellEdited") + labkit.app.internal.LayoutNodeValues.namedSignal(id, options, "OnCellSelectionChanged", ... + "cellSelectionChanged")}.'; + signals = signals(~cellfun(@isempty, signals)); + columns = labkit.app.internal.LayoutNodeValues.textRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Columns", strings(1, 0)), "Columns"); + editable = labkit.app.internal.LayoutNodeValues.logicalRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "ColumnEditable", false), "ColumnEditable"); + labkit.app.internal.LayoutNodeValues.assertEditableWidth(editable, columns); + configuration = struct( ... + "Title", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Title", ""), "data table Title"), ... + "Columns", columns, ... + "RowNames", labkit.app.internal.LayoutNodeValues.textRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "RowNames", strings(1, 0)), "RowNames"), ... + "ColumnEditable", editable); + obj = makeLeaf("dataTable", id, ... + ["tableData", "tableCellSelection", ... + "enabled", "visible"], ... + signals, configuration); + end + + function obj = logPanel(id, varargin) + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.layout.logPanel", "Title", varargin{:}); + configuration = struct("Title", labkit.app.internal.LayoutNodeValues.nonemptyText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Title", "Log"), "log panel Title")); + obj = makeLeaf("logPanel", id, ["text", "visible"], {}, ... + configuration); + end + + function obj = statusPanel(id, varargin) + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.layout.statusPanel", ["Title", "Text"], varargin{:}); + configuration = struct( ... + "Title", labkit.app.internal.LayoutNodeValues.nonemptyText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Title", "Status"), "status panel Title"), ... + "Text", labkit.app.internal.LayoutNodeValues.textRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Text", strings(1, 0)), ... + "status panel Text")); + obj = makeLeaf( ... + "statusPanel", id, ["text", "visible"], {}, configuration); + end + + function obj = group(id, children, varargin) + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.layout.group", ["Layout", "Title"], varargin{:}); + children = labkit.app.internal.LayoutNodeValues.normalizeChildren(children); + labkit.app.internal.LayoutNodeValues.validateChildKinds(children, labkit.app.internal.LayoutNodeValues.controlGroupKinds(), "group"); + configuration = struct( ... + "Layout", labkit.app.internal.LayoutNodeValues.enumText(labkit.app.internal.LayoutNodeValues.optionValue(options, "Layout", "auto"), ... + ["auto", "vertical", "horizontal"], "group Layout"), ... + "Title", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Title", ""), "group Title")); + obj = makeContainer("group", id, children, configuration); + end + + function obj = section(id, title, children, varargin) + options = labkit.app.internal.OptionParser.parse("labkit.app.layout.section", ... + ["Collapsible", "Expanded"], varargin{:}); + children = labkit.app.internal.LayoutNodeValues.normalizeChildren(children); + labkit.app.internal.LayoutNodeValues.validateChildKinds(children, labkit.app.internal.LayoutNodeValues.leafAndGroupKinds(), "section"); + configuration = struct( ... + "Title", labkit.app.internal.LayoutNodeValues.nonemptyText(title, "section title"), ... + "Collapsible", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Collapsible", false), "Collapsible"), ... + "Expanded", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Expanded", true), "Expanded")); + obj = makeContainer("section", id, children, configuration); + end + + function obj = tab(id, title, children) + children = labkit.app.internal.LayoutNodeValues.normalizeChildren(children); + labkit.app.internal.LayoutNodeValues.validateChildKinds(children, ... + [labkit.app.internal.LayoutNodeValues.leafAndGroupKinds(), "section"], "tab"); + obj = makeContainer("tab", id, children, ... + struct("Title", labkit.app.internal.LayoutNodeValues.nonemptyText(title, "tab title"))); + end + + function obj = workspace(varargin) + content = {}; + if ~isempty(varargin) && isa(varargin{1}, "labkit.app.internal.LayoutNode") + content = {varargin{1}}; + varargin = varargin(2:end); + end + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.layout.workspace", ... + ["Title", "OnPageChanged"], varargin{:}); + signal = labkit.app.internal.LayoutNodeValues.optionalSignal("workspace", "pageChanged", ... + labkit.app.internal.LayoutNodeValues.optionValue(options, "OnPageChanged", [])); + if ~isempty(content) + labkit.app.internal.LayoutNodeValues.validateChildKinds(content, labkit.app.internal.LayoutNodeValues.workspaceContentKinds(), ... + "workspace"); + end + obj = labkit.app.internal.LayoutNode("workspace", "workspace", content, ... + strings(1, 0), labkit.app.internal.LayoutNodeValues.signalCell(signal), [], ... + strings(1, 0), struct("Title", labkit.app.internal.LayoutNodeValues.nonemptyText( ... + labkit.app.internal.LayoutNodeValues.optionValue(options, "Title", "Workspace"), ... + "workspace title"))); + end + + function obj = workbench(children, varargin) + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.layout.workbench", ... + ["Workspace", "Usage", "UsageTitle"], varargin{:}); + children = labkit.app.internal.LayoutNodeValues.normalizeChildren(children); + labkit.app.internal.LayoutNodeValues.validateChildKinds(children, ... + [labkit.app.internal.LayoutNodeValues.leafAndGroupKinds(), "section", "tab"], "workbench"); + usage = labkit.app.internal.LayoutNodeValues.textRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "Usage", strings(1, 0)), "Usage"); + if ~isempty(usage) + usageTitle = labkit.app.internal.LayoutNodeValues.nonemptyText(labkit.app.internal.LayoutNodeValues.optionValue( ... + options, "UsageTitle", "Usage"), "UsageTitle"); + usagePanel = labkit.app.internal.LayoutNode.statusPanel( ... + "applicationUsage", Title=usageTitle, Text=usage); + usageSection = labkit.app.internal.LayoutNode.section( ... + "applicationUsageSection", usageTitle, {usagePanel}); + if ~isempty(children) && children{1}.Kind == "tab" + first = children{1}; + children{1} = labkit.app.internal.LayoutNode( ... + "tab", first.Id, ... + [first.Children, {usageSection}], ... + first.Capabilities, first.Signals, ... + first.Renderer, first.AxisIds, ... + first.Configuration); + else + children{end + 1} = usageSection; + end + end + workspace = labkit.app.internal.LayoutNodeValues.optionValue(options, "Workspace", []); + if ~isempty(workspace) + if ~isa(workspace, "labkit.app.internal.LayoutNode") || ... + workspace.Kind ~= "workspace" + error("labkit:app:contract:InvalidValue", ... + "Layout workbench Workspace must be a workspace value."); + end + children{end + 1} = workspace; + end + obj = labkit.app.internal.LayoutNode("workbench", "application", children, ... + strings(1, 0), {}, [], strings(1, 0), struct()); + end + end + + methods + function obj = page(obj, id, title, content) + if obj.Kind ~= "workspace" + error("labkit:app:contract:UnsupportedOperation", ... + "Layout page is available only on a workspace."); + end + if ~isempty(obj.Children) && isempty(obj.PageIds) + error("labkit:app:contract:UnsupportedOperation", ... + "A single-content workspace cannot also declare " + ... + "named pages."); + end + id = labkit.app.internal.LayoutNodeValues.normalizeId(id); + if any(obj.PageIds == id) + error("labkit:app:contract:DuplicateId", ... + "Workspace page ID repeats: %s.", id); + end + if isa(content, "labkit.app.internal.LayoutNode") + content = {content}; + else + content = labkit.app.internal.LayoutNodeValues.normalizeChildren(content); + end + if isempty(content) + error("labkit:app:contract:InvalidValue", ... + "Workspace page content must not be empty."); + end + labkit.app.internal.LayoutNodeValues.validateChildKinds(content, labkit.app.internal.LayoutNodeValues.workspaceContentKinds(), ... + "workspace page"); + pageNode = labkit.app.internal.LayoutNode("workspacePage", id, content, ... + ["workspacePage"], {}, [], strings(1, 0), ... + struct("Title", labkit.app.internal.LayoutNodeValues.nonemptyText(title, "workspace page title"))); + obj.Children{end + 1} = pageNode; + obj.PageIds(end + 1) = id; + if strlength(obj.InitialPage) == 0 + obj.InitialPage = id; + end + end + + function obj = initialPage(obj, id) + if obj.Kind ~= "workspace" + error("labkit:app:contract:UnsupportedOperation", ... + "Layout initialPage is available only on a workspace."); + end + id = labkit.app.internal.LayoutNodeValues.normalizeId(id); + if ~any(obj.PageIds == id) + error("labkit:app:contract:UnknownReference", ... + "Workspace initial page is undeclared: %s.", id); + end + obj.InitialPage = id; + end + end + + methods (Access = ?labkit.app.internal.CompiledDefinition) + function nodes = flattenForCompiler(obj) + chunks = cell(1, 1 + numel(obj.Children)); + chunks{1} = {obj}; + for k = 1:numel(obj.Children) + chunks{k + 1} = obj.Children{k}.flattenForCompiler(); + end + nodes = [chunks{:}]; + end + + function value = configurationForCompiler(obj) + value = obj.Configuration; + end + end +end + +function obj = makeLeaf(kind, id, capabilities, signals, configuration) + obj = labkit.app.internal.LayoutNode(kind, labkit.app.internal.LayoutNodeValues.normalizeId(id), {}, capabilities, ... + signals, [], strings(1, 0), configuration); +end + +function obj = makeContainer(kind, id, children, configuration) + obj = labkit.app.internal.LayoutNode(kind, labkit.app.internal.LayoutNodeValues.normalizeId(id), children, ... + strings(1, 0), {}, [], strings(1, 0), configuration); +end + +function value = tooltipValue(options, name, defaultValue) +value = labkit.app.internal.LayoutNodeValues.nonemptyText( ... + labkit.app.internal.LayoutNodeValues.optionValue( ... + options, name, defaultValue), name); +end + +function state = runAnalysis(state, ~) + state.finished = true; +end diff --git a/+labkit/+app/+internal/LayoutNodeValues.m b/+labkit/+app/+internal/LayoutNodeValues.m new file mode 100644 index 000000000..ae9ca742e --- /dev/null +++ b/+labkit/+app/+internal/LayoutNodeValues.m @@ -0,0 +1,309 @@ +% Normalize and validate values for the owning internal class. +% Expected callers are internal class methods; inputs and outputs retain +% their declared MATLAB shapes. Methods have no side effects except +% raising stable contract errors for malformed values. +classdef (Sealed, Hidden) LayoutNodeValues + methods (Static) + function value = optionValue(options, name, defaultValue) + value = defaultValue; + if isfield(options, name) + value = options.(name); + end + end + + function value = normalizeId(value) + values = labkit.app.internal.LayoutNodeValues.idRow(value, "layout"); + if numel(values) ~= 1 + error("labkit:app:contract:InvalidValue", ... + "Layout id must be a scalar MATLAB identifier."); + end + value = values; + end + + function values = idRow(values, label) + values = labkit.app.internal.LayoutNodeValues.textRow(values, label + " IDs"); + if any(strlength(values) == 0) || ... + any(~arrayfun(@(value) isvarname(char(value)), values)) || ... + numel(unique(values)) ~= numel(values) + error("labkit:app:contract:InvalidValue", ... + "Layout %s IDs must be unique MATLAB identifiers.", label); + end + end + + function children = normalizeChildren(children) + if ~iscell(children) || (~isempty(children) && ~isrow(children)) || ... + ~all(cellfun(@(value) isa(value, "labkit.app.internal.LayoutNode"), children)) + error("labkit:app:contract:InvalidValue", ... + "Layout children must be a row cell array of Layout values."); + end + end + + function validateChildKinds(children, allowed, parent) + for k = 1:numel(children) + if ~any(children{k}.Kind == allowed) + error("labkit:app:contract:UnsupportedOperation", ... + "%s cannot own a %s Layout.", parent, children{k}.Kind); + end + end + end + + function kinds = leafAndGroupKinds() + kinds = ["button", "field", "rangeField", "slider", "fileList", ... + "plotArea", "dataTable", "logPanel", "statusPanel", "group"]; + end + + function kinds = controlGroupKinds() + kinds = ["button", "field", "rangeField", "slider", ... + "fileList", "group"]; + end + + function kinds = workspaceContentKinds() + kinds = [labkit.app.internal.LayoutNodeValues.leafAndGroupKinds(), "section"]; + end + + function values = signalCell(signal) + values = {}; + if ~isempty(signal) + values = {signal}; + end + end + + function signal = namedSignal(target, options, optionName, signalName) + signal = labkit.app.internal.LayoutNodeValues.optionalSignal( ... + target, signalName, labkit.app.internal.LayoutNodeValues.optionValue(options, optionName, [])); + end + + function signal = optionalSignal(target, signalName, callback) + signal = []; + if ~isempty(callback) + signal = labkit.app.internal.LayoutNodeValues.bindSignal(target, signalName, callback); + end + end + + function signal = bindSignal(target, signalName, callback) + signal = labkit.app.internal.SignalBinding(target, signalName, callback); + end + + function callback = rendererCallback(callback) + if ~isa(callback, "function_handle") || ~isscalar(callback) + error("labkit:app:contract:InvalidValue", ... + "layout.plotArea renderer must be a function handle."); + end + if nargin(callback) ~= 2 || nargout(callback) > 0 + error("labkit:app:contract:CallbackRoleMismatch", ... + "layout.plotArea renderer must accept axes and model with no output."); + end + end + + function specs = interactionSpecs(specs, plotId, axisIds) + if isempty(specs) + specs = {}; + return; + end + if ~iscell(specs) || ~isrow(specs) || ... + ~all(cellfun(@(value) ... + isa(value, "labkit.app.internal.InteractionSpec"), specs)) + error("labkit:app:contract:InvalidValue", ... + "layout.plotArea Interactions must be a row cell array of " + ... + "labkit.app.interaction declarations."); + end + for k = 1:numel(specs) + specs{k} = specs{k}.attachToPlot(plotId, axisIds); + end + ids = string(cellfun(@(value) value.Id, specs, "UniformOutput", false)); + if numel(unique(ids)) ~= numel(ids) + error("labkit:app:contract:DuplicateId", ... + "layout.plotArea interaction IDs must be unique."); + end + end + + function value = scalarText(value, label) + if ~(ischar(value) || (isstring(value) && isscalar(value))) + error("labkit:app:contract:InvalidValue", ... + "Layout %s must be scalar text.", label); + end + value = string(value); + end + + function value = nonemptyText(value, label) + value = labkit.app.internal.LayoutNodeValues.scalarText(value, label); + if strlength(value) == 0 + error("labkit:app:contract:InvalidValue", ... + "Layout %s must be nonempty.", label); + end + end + + function value = enumText(value, allowed, label) + value = labkit.app.internal.LayoutNodeValues.scalarText(value, label); + if ~any(value == allowed) + error("labkit:app:contract:InvalidValue", ... + "Layout %s has an unsupported value: %s.", label, value); + end + end + + function value = bindingPath(value) + value = labkit.app.internal.LayoutNodeValues.scalarText(value, "Bind"); + if strlength(value) == 0 + return; + end + if isempty(regexp(char(value), ... + '^(project|session)(\.[A-Za-z]\w*)+$', "once")) + error("labkit:app:contract:InvalidValue", ... + "Layout Bind must be a project or session field path."); + end + end + + function values = textRow(values, label) + if ischar(values) + values = string(values); + elseif iscellstr(values) + values = string(values); + elseif ~isstring(values) + error("labkit:app:contract:InvalidValue", ... + "Layout %s must be text.", label); + end + values = reshape(values, 1, []); + end + + function value = defaultAxesLayout(axisCount) + value = "single"; + if axisCount > 1 + value = "stack"; + end + end + + function values = optionalAxisText(options, name, axisCount) + values = strings(1, 0); + if isfield(options, name) + values = labkit.app.internal.LayoutNodeValues.textRow(options.(name), name); + if numel(values) ~= axisCount + error("labkit:app:contract:InvalidValue", ... + "Layout %s must contain one value per axis.", name); + end + end + end + + function values = optionalLayoutSizes(options, name, axisCount) + values = {}; + if ~isfield(options, name) + return; + end + values = options.(name); + if ~iscell(values) || ~isrow(values) || numel(values) ~= axisCount + error("labkit:app:contract:InvalidValue", ... + "Layout %s must be a row cell array with one value per axis.", ... + name); + end + for k = 1:numel(values) + value = values{k}; + numericSize = isnumeric(value) && isscalar(value) && ... + isfinite(value) && value > 0; + flexibleSize = (ischar(value) || ... + (isstring(value) && isscalar(value))) && ... + any(string(value) == ["fit", "1x"]); + if ~(numericSize || flexibleSize) + error("labkit:app:contract:InvalidValue", ... + "Layout %s entries must be positive pixels, fit, or 1x.", ... + name); + end + if isstring(value) + values{k} = char(value); + end + end + end + + function values = scrollZoomAxes(options, axisCount) + values = repmat("xy", 1, axisCount); + if ~isfield(options, "ScrollZoomAxes") + return; + end + values = labkit.app.internal.LayoutNodeValues.textRow(options.ScrollZoomAxes, "ScrollZoomAxes"); + if numel(values) ~= axisCount || ... + any(~ismember(values, ["xy", "x", "y"])) + error("labkit:app:contract:InvalidValue", ... + "Layout ScrollZoomAxes must contain xy, x, or y for every axis."); + end + end + + function assertAxesLayout(layout, axisCount) + if layout == "single" && axisCount ~= 1 + error("labkit:app:contract:InvalidValue", ... + "Layout single plot areas require exactly one axis."); + end + if layout == "pair" && axisCount < 2 + error("labkit:app:contract:InvalidValue", ... + "Layout pair plot areas require at least two axes."); + end + end + + function value = logicalValue(value, label) + if ~(islogical(value) && isscalar(value)) + error("labkit:app:contract:InvalidValue", ... + "Layout %s must be a logical scalar.", label); + end + end + + function values = logicalRow(values, label) + if ~(islogical(values) && (isscalar(values) || isrow(values))) + error("labkit:app:contract:InvalidValue", ... + "Layout %s must be a logical scalar or row.", label); + end + values = reshape(values, 1, []); + end + + function assertEditableWidth(editable, columns) + if ~isscalar(editable) && ~isempty(columns) && ... + numel(editable) ~= numel(columns) + error("labkit:app:contract:InvalidValue", ... + "Layout ColumnEditable must be scalar or match Columns."); + end + end + + function value = optionalLimits(value, label) + if isempty(value) + value = []; + return; + end + if ~(isnumeric(value) && isequal(size(value), [1 2]) && ... + all(isfinite(value)) && value(1) <= value(2)) + error("labkit:app:contract:InvalidValue", ... + "Layout %s must be an increasing finite 1-by-2 row.", label); + end + value = double(value); + end + + function value = optionalPair(value, label) + if isempty(value) + value = []; + return; + end + if ~(isnumeric(value) && isequal(size(value), [1 2]) && ... + all(isfinite(value))) + error("labkit:app:contract:InvalidValue", ... + "Layout %s must be a finite 1-by-2 row.", label); + end + value = double(value); + end + + function value = optionalPositive(value, label) + if isempty(value) + return; + end + if ~(isnumeric(value) && isscalar(value) && isfinite(value) && value > 0) + error("labkit:app:contract:InvalidValue", ... + "Layout %s must be a positive scalar.", label); + end + value = double(value); + end + + function value = positiveOrInf(value, label) + if ~(isnumeric(value) && isscalar(value) && value > 0 && ... + (isfinite(value) || isinf(value))) + error("labkit:app:contract:InvalidValue", ... + "Layout %s must be a positive scalar or Inf.", label); + end + value = double(value); + end + + end +end diff --git a/+labkit/+app/+internal/NativeAdapterValues.m b/+labkit/+app/+internal/NativeAdapterValues.m new file mode 100644 index 000000000..83202ed10 --- /dev/null +++ b/+labkit/+app/+internal/NativeAdapterValues.m @@ -0,0 +1,530 @@ +% Normalize and validate values for the owning internal class. +% Expected callers are internal class methods; inputs and outputs retain +% their declared MATLAB shapes. Methods have no side effects except +% raising stable contract errors for malformed values. +classdef (Sealed, Hidden) NativeAdapterValues + methods (Static) + function policy = layoutPolicy() + policy = nativeLayoutPolicy(); + end + + function fitText(component, options) + arguments + component + options.CharsPerStep = [] + options.MaxShrinkSteps = [] + end + supplied = {}; + if ~isempty(options.CharsPerStep) + supplied(end + 1:end + 2) = { ... + "CharsPerStep", options.CharsPerStep}; + end + if ~isempty(options.MaxShrinkSteps) + supplied(end + 1:end + 2) = { ... + "MaxShrinkSteps", options.MaxShrinkSteps}; + end + applyTextFit(component, supplied{:}); + end + + function installColumnDivider(figureHandle, grid, left, right) + installColumnResize(figureHandle, grid, left, right); + end + + function installRowDivider(figureHandle, grid, upper, lower) + installRowResize(figureHandle, grid, upper, lower); + end + + function controller = interactionController( ... + figureHandle, targets, dispatch) + controller = InteractionController( ... + figureHandle, targets, dispatch); + end + + function parent = labeledParent(parent, label, id) + policy = nativeLayoutPolicy(); + grid = uigridlayout(parent, [1 2], Padding=[0 0 0 0], ... + ColumnSpacing=8, ColumnWidth={policy.FormLabelWidth, '1x'}); + tag = ""; + if strlength(string(id)) > 0 + tag = string(id) + ".label"; + grid.Tag = char(string(id) + ".layout"); + end + labelHandle = uilabel(grid, Text=char(string(label)), Tag=char(tag), ... + HorizontalAlignment="right"); + applyTextFit(labelHandle); + parent = grid; + end + + function operations = orderedOperations(operations) + if isempty(operations) + return; + end + + priority = zeros(1, numel(operations)); + for k = 1:numel(operations) + switch operations{k}.Kind + case {"choices", "limits", "filePaths", "tableData"} + priority(k) = 1; + case "fileItemStatuses" + priority(k) = 2; + case "value" + priority(k) = 2; + case {"listSelection", "tableCellSelection"} + priority(k) = 3; + otherwise + priority(k) = 4; + end + end + [~, order] = sort(priority, "ascend"); + operations = operations(order); + end + + function tf = isInteractionKind(kind) + tf = any(string(kind) == [ ... + "anchorPath", "pairedAnchors", "pointSlots", "rectangle", ... + "regionSelection", "interval", "scaleReference"]); + end + + function plan = validatePlan(plan) + if ~isstruct(plan) || ~isscalar(plan) || ~isfield(plan, "Nodes") + error("labkit:app:contract:InvalidValue", ... + "MATLAB platform adapter requires a compiled Application plan."); + end + end + + function value = onOff(value) + if value + value = "on"; + else + value = "off"; + end + end + + function setIfProperty(component, name, value) + if isprop(component, name) + component.(name) = value; + end + end + + function handle = layoutHandle(component) + handle = component; + if ~isprop(component, "UserData") || ~isstruct(component.UserData) + return + end + if isfield(component.UserData, "LayoutContainer") + candidate = component.UserData.LayoutContainer; + elseif isfield(component.UserData, "Panel") + candidate = component.UserData.Panel; + else + return + end + if ~isempty(candidate) && isvalid(candidate) + handle = candidate; + end + end + + function slider = linkedPannerSlider(component) + slider = []; + if ~isprop(component, "UserData") || ~isstruct(component.UserData) || ... + ~isfield(component.UserData, "Slider") + return + end + candidate = component.UserData.Slider; + if ~isempty(candidate) && isvalid(candidate) + slider = candidate; + end + end + + function field = linkedRangeEnd(component) + field = []; + if ~isprop(component, "UserData") || ~isstruct(component.UserData) || ... + ~isfield(component.UserData, "EndField") + return + end + candidate = component.UserData.EndField; + if ~isempty(candidate) && isvalid(candidate) + field = candidate; + end + end + + function mode = linkedPlotMode(component) + mode = []; + if isempty(component) || ~isvalid(component) || ... + ~isstruct(component.UserData) || ... + ~isfield(component.UserData, "ValueControl") + return + end + candidate = component.UserData.ValueControl; + if ~isempty(candidate) && isvalid(candidate) + mode = candidate; + end + end + + function sizes = repeatedOrConfigured(configured, count) + sizes = repmat({'1x'}, 1, count); + if ~isempty(configured) + sizes = configured; + end + end + + function value = axisText(configured, fallback, index) + value = ""; + if ~isempty(configured) + value = configured(index); + elseif ~isempty(fallback) + value = fallback(index); + end + value = char(value); + end + + function value = changingValue(event, fallback) + value = fallback; + if isstruct(event) && isfield(event, "Value") + value = event.Value; + elseif isobject(event) && isprop(event, "Value") + value = event.Value; + end + end + + function applyChoices(component, choices) + if ~isprop(component, "Items") + return; + end + incoming = reshape(string(choices), 1, []); + if isprop(component, "Value") && ~isempty(incoming) + current = string(component.Value); + if isscalar(current) && ~any(incoming == current) + component.Items = unique([current, incoming], "stable"); + component.Value = incoming(1); + end + end + component.Items = choices; + end + + function [limits, value] = sliderInitialValue(config) + limits = config.Limits; + if isempty(limits) + limits = [0 1]; + end + value = config.Value; + if isempty(value) + value = limits(1); + end + end + + function [limits, value] = rangeSliderInitialValue(config) + limits = config.Limits; + if isempty(limits) + limits = [0 1]; + end + value = config.Value; + if isempty(value) + value = limits; + end + end + + function value = neutralValue(value, kind, choices) + if ~isempty(value) + return; + end + switch kind + case "numeric" + value = 0; + case "choice" + if isempty(choices) + value = ""; + else + value = choices(1); + end + case "logical" + value = false; + otherwise + value = ""; + end + end + + function key = axisKey(target, axisId) + key = string(target) + "." + string(axisId); + end + + function viewport = captureViewport(axes) + viewport = repmat(struct("XLim", [], "YLim", [], ... + "XLimMode", "", "YLimMode", ""), 1, numel(axes)); + for k = 1:numel(axes) + viewport(k) = struct("XLim", axes(k).XLim, "YLim", axes(k).YLim, ... + "XLimMode", axes(k).XLimMode, "YLimMode", axes(k).YLimMode); + end + end + + function restoreViewport(axes, viewport) + for k = 1:numel(axes) + if viewport(k).XLimMode == "manual" + axes(k).XLim = viewport(k).XLim; + axes(k).XLimMode = "manual"; + end + if viewport(k).YLimMode == "manual" + axes(k).YLim = viewport(k).YLim; + axes(k).YLimMode = "manual"; + end + end + end + + function indices = selectedIndices(list) + if isstruct(list.UserData) && isfield(list.UserData, "Paths") && ... + isempty(list.UserData.Paths) + indices = zeros(1, 0); + return + end + items = string(list.Items); + values = string(list.Value); + indices = find(ismember(items, values)); + indices = reshape(indices, 1, []); + end + + function cells = tableSelectionCells(event) + if isstruct(event) + if isfield(event, "Selection") + cells = event.Selection; + elseif isfield(event, "Indices") + cells = event.Indices; + else + cells = zeros(0, 2); + end + elseif isprop(event, "Selection") + cells = event.Selection; + elseif isprop(event, "Indices") + cells = event.Indices; + else + cells = zeros(0, 2); + end + if isempty(cells) + cells = zeros(0, 2); + elseif ~isnumeric(cells) + rows = [cells.Row]; + columns = [cells.Column]; + cells = [rows(:), columns(:)]; + end + cells = double(cells); + end + + function value = editedValue(event) + if isstruct(event) + if isfield(event, "NewData") + value = event.NewData; + else + value = event.EditData; + end + elseif isprop(event, "NewData") + value = event.NewData; + else + value = event.EditData; + end + end + + function value = tableLabel(labels, index) + value = ""; + if index < 1 || index > numel(labels) + return; + end + candidate = string(labels(index)); + if isscalar(candidate) + value = candidate; + end + end + + function value = nativeTableData(value) + if ~iscell(value) + return; + end + for k = 1:numel(value) + item = value{k}; + if isempty(item) + value{k} = ''; + elseif ischar(item) + continue; + elseif (isnumeric(item) || islogical(item)) && isscalar(item) + continue; + elseif isscalar(item) + text = string(item); + if ismissing(text) + value{k} = ''; + else + value{k} = char(text); + end + else + error("labkit:app:contract:InvalidValue", ... + "Table cells must contain scalar display values."); + end + end + end + + function value = multiSelectValue(selectionMode) + if selectionMode == "multiple" + value = "on"; + else + value = "off"; + end + end + + function value = dialogFilters(filters) + filters = string(filters(:)); + if isempty(filters) + value = "*.*"; + elseif mod(numel(filters), 2) == 0 + value = reshape(cellstr(filters), 2, []).'; + else + value = cellstr(filters); + end + end + + function paths = filesInFolder(folder, filters, recursive) + filters = string(filters(:)); + if mod(numel(filters), 2) == 0 + filters = filters(1:2:end); + end + patterns = strings(0, 1); + for filter = filters.' + patterns = [patterns; split(filter, ";")]; + end + patterns = unique(strtrim(patterns(strlength(strtrim(patterns)) > 0)), ... + "stable"); + parts = cell(numel(patterns), 1); + for k = 1:numel(patterns) + if recursive + entries = dir(fullfile(folder, "**", patterns(k))); + else + entries = dir(fullfile(folder, patterns(k))); + end + entries = entries(~[entries.isdir]); + values = strings(numel(entries), 1); + for index = 1:numel(entries) + values(index) = string(fullfile( ... + entries(index).folder, entries(index).name)); + end + parts{k} = values; + end + if isempty(parts) + paths = strings(0, 1); + else + paths = sort(unique(vertcat(parts{:}), "stable")); + end + end + + function path = safeStartPath(value) + path = char(string(value)); + if isempty(path) || ~isfolder(path) + path = pwd; + end + end + + function result = dialogPath(name, folder) + if isequal(name, 0) + result = labkit.app.dialog.Choice("", Cancelled=true); + else + result = labkit.app.dialog.Choice(string(folder) + filesep + string(name)); + end + end + + function result = folderDialogPath(folder) + if isequal(folder, 0) + result = labkit.app.dialog.Choice("", Cancelled=true); + else + result = labkit.app.dialog.Choice(string(folder)); + end + end + + function position = closePromptPosition(fig) + width = 430; + height = 118; + figurePosition = fig.Position; + promptWidth = min(width, max(160, figurePosition(3) - 24)); + x = max(12, (figurePosition(3) - promptWidth) / 2); + y = max(12, figurePosition(4) - height - 44); + position = [x y promptWidth height]; + end + + function labels = formatFileLabels(paths, statuses) + paths = string(paths(:)); + if nargin < 2 || isempty(statuses) + statuses = strings(size(paths)); + else + statuses = string(statuses(:)); + end + if numel(statuses) ~= numel(paths) + error("labkit:app:contract:InvalidValue", ... + "File item statuses must be empty or match file paths."); + end + names = strings(size(paths)); + parents = strings(size(paths)); + for k = 1:numel(paths) + [folder, base, extension] = fileparts(char(paths(k))); + names(k) = string(base) + string(extension); + [~, parents(k)] = fileparts(folder); + end + width = max(2, strlength(string(max(1, numel(paths))))); + labels = strings(size(paths)); + for k = 1:numel(paths) + suffix = ""; + if nnz(names == names(k)) > 1 && strlength(parents(k)) > 0 + suffix = " (" + parents(k) + ")"; + end + labels(k) = compose("%0" + width + "d %s%s", ... + k, names(k), suffix); + if strlength(statuses(k)) > 0 + labels(k) = labels(k) + " [" + statuses(k) + "]"; + end + end + labels = reshape(labels, 1, []); + end + + function height = estimatedControlHeight(text, charsPerLine, maxLines, minimum) + text = string(text); + if isempty(text) + height = minimum; + return + end + lines = splitlines(text(:)); + lineCount = max(1, ceil(double(max(strlength(lines))) / charsPerLine)); + lineCount = min(maxLines, lineCount); + height = max(minimum, 20 * lineCount + 6); + end + + function folder = userDialogFolder() + folder = string(getenv("USERPROFILE")); + if strlength(folder) == 0 || ~isfolder(folder) + folder = string(getenv("HOME")); + end + if strlength(folder) == 0 || ~isfolder(folder) + folder = string(tempdir); + end + folder = char(folder); + end + + function mode = startupGuiMode() + mode = lower(strip(string(getenv("LABKIT_GUI_TEST_MODE")))); + if strlength(mode) == 0 + mode = "visible"; + end + end + + function message = deepestCauseMessage(cause) + message = string(cause.message); + while ~isempty(cause.cause) + cause = cause.cause{1}; + message = string(cause.message); + end + end + + function filepath = plotFilepath(basePath, axesHandle, index) + [folder, name, extension] = fileparts(basePath); + label = join(string(axesHandle.Title.String), " "); + label = string(matlab.lang.makeValidName(char(label))); + if strlength(label) == 0 + label = "plot" + string(index); + end + filepath = string(fullfile(folder, sprintf( ... + "%s_%02d_%s%s", name, index, label, extension))); + end + + end +end diff --git a/+labkit/+app/+internal/OptionParser.m b/+labkit/+app/+internal/OptionParser.m new file mode 100644 index 000000000..8da385881 --- /dev/null +++ b/+labkit/+app/+internal/OptionParser.m @@ -0,0 +1,29 @@ +classdef (Sealed, Hidden) OptionParser + % Private strict Name-Value parser for App SDK contract values. + + methods (Static) + function values = parse(symbol, allowed, varargin) + if mod(numel(varargin), 2) ~= 0 + error("labkit:app:contract:UnknownArgument", ... + "%s requires paired Name-Value arguments.", symbol); + end + values = struct(); + allowed = string(allowed); + for k = 1:2:numel(varargin) + name = varargin{k}; + if ~(ischar(name) || ... + (isstring(name) && isscalar(name))) + error("labkit:app:contract:UnknownArgument", ... + "%s argument names must be text scalars.", symbol); + end + name = string(name); + if ~any(name == allowed) || isfield(values, name) + error("labkit:app:contract:UnknownArgument", ... + "%s received an unknown or duplicate argument: %s.", ... + symbol, name); + end + values.(name) = varargin{k + 1}; + end + end + end +end diff --git a/+labkit/+app/+internal/PortableSourceStore.m b/+labkit/+app/+internal/PortableSourceStore.m new file mode 100644 index 000000000..f2f0a3199 --- /dev/null +++ b/+labkit/+app/+internal/PortableSourceStore.m @@ -0,0 +1,417 @@ +classdef (Hidden, Sealed) PortableSourceStore < handle + % Private portable-source representation and resolution owner. + % + % RuntimeKernel and ProjectDocumentStore use this class to create and + % relocate durable source references. App code never receives this + % storage owner or relies on the nested reference representation. + + methods (Access = {?labkit.app.internal.RuntimeKernel, ?labkit.app.internal.ProjectDocumentStore}) + function obj = PortableSourceStore() + end + + function record = create(~, id, role, pathOrReference, required) + if nargin < 5 + required = true; + end + record = makeRecord(id, role, pathOrReference, required); + end + + function paths = sourcePaths(~, records, ids) + validateRecords(records); + recordIds = recordIdsOf(records); + indices = (1:numel(records)).'; + if nargin >= 3 + requested = normalizeIds(ids, "Requested source ids"); + if isempty(requested) + paths = strings(0, 1); + return; + end + [~, indices] = ismember(requested, recordIds); + end + paths = strings(numel(indices), 1); + for k = 1:numel(indices) + if indices(k) ~= 0 + paths(k) = records(indices(k)).reference.originalPath; + end + end + end + + function records = upsert(~, records, record) + validateRecords(records); + validateRecord(record); + if isempty(records) + records = reshape(record, 1, 1); + return; + end + match = find(recordIdsOf(records) == record.id, 1, "first"); + if isempty(match) + records(end + 1, 1) = record; + else + records(match) = record; + end + end + + function records = reconcile(~, current, incoming) + % Incoming records are the complete desired collection and order. + % Validate both sides before returning a canonical replacement so + % callers never partially publish malformed source state. + validateRecords(current); + validateRecords(incoming); + records = canonicalCollection(incoming); + end + + function records = reconcilePaths(obj, current, paths, role, prefix, ... + required, allowDuplicatePaths) + validateRecords(current); + paths = normalizePaths(paths); + role = requiredText(role, "Project source role"); + prefix = requiredText(prefix, "Project source id prefix"); + if nargin < 7 + allowDuplicatePaths = false; + end + allowDuplicatePaths = logicalScalar( ... + allowDuplicatePaths, "Allow duplicate source paths"); + if ~allowDuplicatePaths + paths = unique(paths, "stable"); + end + records = emptyRecords(); + currentPaths = obj.sourcePaths(current); + retained = false(numel(current), 1); + for k = 1:numel(paths) + candidates = currentPaths == paths(k); + if allowDuplicatePaths + candidates = candidates & ~retained; + end + match = find(candidates, 1); + if isempty(match) + id = nextId(current, records, prefix); + record = obj.create(id, role, paths(k), required); + else + record = current(match); + retained(match) = true; + end + records = appendRecord(records, record); + end + end + + function records = recordsForRole(~, records, role) + validateRecords(records); + role = requiredText(role, "Project source role"); + if isempty(records) + records = emptyRecords(); + return; + end + records = canonicalCollection(records( ... + string({records.role}) == role)); + end + + function records = reconcileRolePaths(obj, current, paths, ... + role, prefix, required, allowDuplicatePaths) + validateRecords(current); + role = requiredText(role, "Project source role"); + if nargin < 7 + allowDuplicatePaths = false; + end + currentRole = obj.recordsForRole(current, role); + replacement = obj.reconcilePaths( ... + currentRole, paths, role, prefix, required, ... + allowDuplicatePaths); + records = obj.replaceRole(current, role, replacement); + end + + function records = replaceRole(~, current, role, replacement) + validateRecords(current); + validateRecords(replacement); + role = requiredText(role, "Project source role"); + if ~isempty(replacement) && ... + any(string({replacement.role}) ~= role) + invalid("Replacement source records must match role %s.", role); + end + if isempty(current) + records = replacement; + return; + end + roleMask = string({current.role}) == role; + insertion = find(roleMask, 1, "first"); + if isempty(insertion) + records = canonicalCollection(current); + for k = 1:numel(replacement) + records = appendRecord(records, replacement(k)); + end + return; + end + records = emptyRecords(); + for k = 1:numel(current) + if k == insertion + for n = 1:numel(replacement) + records = appendRecord(records, replacement(n)); + end + end + if ~roleMask(k) + records = appendRecord(records, current(k)); + end + end + end + + function records = rebase(~, records, projectFile) + validateRecords(records); + projectFile = requiredPath(projectFile, "Project filepath"); + records = canonicalCollection(records); + for k = 1:numel(records) + target = records(k).reference.originalPath; + records(k).reference = makeReference(projectFile, target); + end + end + + function [resolved, unresolved] = resolve(~, records, projectFile) + validateRecords(records); + projectFile = requiredPath(projectFile, "Project filepath"); + resolved = emptyRecords(); + unresolved = emptyRecords(); + for k = 1:numel(records) + [pathValue, ~] = resolveReference(projectFile, records(k).reference); + if strlength(pathValue) > 0 + record = records(k); + record.reference = makeReference(projectFile, pathValue); + resolved = appendRecord(resolved, record); + elseif records(k).required + unresolved = appendRecord(unresolved, records(k)); + end + end + end + end + +end + +function record = makeRecord(id, role, pathOrReference, required) +id = requiredText(id, "Project source id"); +role = requiredText(role, "Project source role"); +if ~(islogical(required) || isnumeric(required)) || ~isscalar(required) || ... + ~isfinite(double(required)) || ~any(double(required) == [0 1]) + invalid("Project source required flag must be scalar logical."); +end +record = struct("id", id, "required", logical(required), "role", role, ... + "reference", {canonicalReference(pathOrReference)}); +end + +function records = canonicalCollection(records) +if isempty(records) + records = emptyRecords(); + return; +end +output = emptyRecords(); +for k = 1:numel(records) + record = records(k); + output = appendRecord(output, makeRecord( ... + record.id, record.role, record.reference, record.required)); +end +records = output; +end + +function records = appendRecord(records, record) +if isempty(records) + records = reshape(record, 1, 1); +else + records(end + 1, 1) = record; +end +end + +function records = emptyRecords() +reference = struct("schemaVersion", 1, "relativePath", "", ... + "originalPath", "", "fileName", ""); +prototype = struct("id", "", "required", true, "role", "", ... + "reference", {reference}); +records = repmat(prototype, 0, 1); +end + +function validateRecords(records) +if isempty(records) + if ~isstruct(records) + invalid("Project source records must be a struct array."); + end + return; +end +if ~isstruct(records) + invalid("Project source records must be a struct array."); +end +ids = strings(numel(records), 1); +for k = 1:numel(records) + validateRecord(records(k)); + ids(k) = records(k).id; +end +if numel(unique(ids, "stable")) ~= numel(ids) + [~, first] = unique(ids, "stable"); + repeated = ids(setdiff((1:numel(ids)).', first, "stable")); + invalid("Project source ids must be unique; duplicate ""%s"".", repeated); +end +end + +function validateRecord(record) +if ~isstruct(record) || ~isscalar(record) || ... + ~isequal(string(fieldnames(record)), ... + ["id"; "required"; "role"; "reference"]) + invalid("Project source record must have the canonical fields."); +end +makeRecord(record.id, record.role, record.reference, record.required); +end + +function ids = recordIdsOf(records) +if isempty(records) + ids = strings(0, 1); +else + ids = string({records.id}).'; +end +end + +function value = canonicalReference(value) +if ~isstruct(value) + pathValue = requiredPath(value, "Project source filepath"); + value = makeReference("", pathValue); + return; +end +fields = ["schemaVersion"; "relativePath"; "originalPath"; "fileName"]; +if ~isscalar(value) || ~isequal(string(fieldnames(value)), fields) + invalid("Portable reference must have the canonical fields."); +end +version = value.schemaVersion; +if ~(isnumeric(version) && isscalar(version) && isfinite(version) && version == 1) + invalid("Portable reference has an unsupported schema version."); +end +relative = optionalText(value.relativePath, "Portable relative path"); +original = optionalText(value.originalPath, "Portable original path"); +fileName = requiredText(value.fileName, "Portable file name"); +if strlength(relative) == 0 && strlength(original) == 0 + invalid("Portable reference must include a relative or original path."); +end +value = struct("schemaVersion", 1, "relativePath", relative, ... + "originalPath", original, "fileName", fileName); +end + +function reference = makeReference(projectFile, targetPath) +targetPath = requiredPath(targetPath, "Project source filepath"); +[~, name, extension] = fileparts(targetPath); +reference = struct("schemaVersion", 1, "relativePath", "", ... + "originalPath", targetPath, "fileName", string(name) + string(extension)); +if strlength(string(projectFile)) > 0 + folder = string(fileparts(projectFile)); + reference.relativePath = relativeToFolder(folder, targetPath); +end +end + +function [targetPath, matchKind] = resolveReference(projectFile, reference) +folder = string(fileparts(projectFile)); +candidates = strings(0, 1); +kinds = strings(0, 1); +if strlength(reference.relativePath) > 0 + parts = cellstr(split(replace(reference.relativePath, "\", "/"), "/")); + candidates(end + 1, 1) = fullfile(folder, parts{:}); + kinds(end + 1, 1) = "relative"; +end +if strlength(reference.originalPath) > 0 + candidates(end + 1, 1) = reference.originalPath; + kinds(end + 1, 1) = "original"; +end +if strlength(reference.fileName) > 0 + candidates(end + 1, 1) = fullfile(folder, reference.fileName); + kinds(end + 1, 1) = "same_folder"; +end +targetPath = ""; +matchKind = "none"; +for k = 1:numel(candidates) + [exists, attributes] = fileattrib(char(candidates(k))); + if exists && ~attributes.directory + targetPath = string(attributes.Name); + matchKind = kinds(k); + return; + end +end +end + +function relative = relativeToFolder(folder, target) +folder = replace(string(folder), "\", "/"); +target = replace(string(target), "\", "/"); +if ~isAbsolutePath(target) + relative = target; + return; +end +folderParts = split(strip(folder, "/"), "/"); +targetParts = split(strip(target, "/"), "/"); +limit = min(numel(folderParts), numel(targetParts)); +common = 0; +while common < limit && strcmpi(folderParts(common + 1), targetParts(common + 1)) + common = common + 1; +end +if common == 0 + relative = ""; + return; +end +parents = repmat("..", numel(folderParts) - common, 1); +relative = join([parents; targetParts(common + 1:end)], "/"); +end + +function tf = isAbsolutePath(pathValue) +tf = startsWith(pathValue, "/") || startsWith(pathValue, "\") || ... + ~isempty(regexp(pathValue, '^[A-Za-z]:/', 'once')); +end + +function ids = normalizeIds(value, label) +if ~(ischar(value) || isstring(value) || iscellstr(value)) + invalid("%s must be text.", label); +end +ids = string(value); +ids = ids(:); +if any(strlength(ids) == 0) + invalid("%s must be nonempty.", label); +end +end + +function paths = normalizePaths(value) +if ~(ischar(value) || isstring(value) || iscellstr(value)) + invalid("Source paths must be text."); +end +paths = string(value(:)); +if any(strlength(paths) == 0) + invalid("Source paths must be nonempty."); +end +end + +function value = logicalScalar(value, label) +if ~((islogical(value) || isnumeric(value)) && isscalar(value) && ... + isfinite(double(value)) && any(double(value) == [0 1])) + invalid("%s must be scalar logical.", label); +end +value = logical(value); +end + +function id = nextId(current, pending, prefix) +ids = [recordIdsOf(current); recordIdsOf(pending)]; +index = 1; +id = prefix + "-" + index; +while any(ids == id) + index = index + 1; + id = prefix + "-" + index; +end +end + +function value = requiredPath(value, label) +value = requiredText(value, label); +end + +function value = requiredText(value, label) +if ~(ischar(value) || (isstring(value) && isscalar(value))) || ... + strlength(string(value)) == 0 + invalid("%s must be nonempty scalar text.", label); +end +value = string(value); +end + +function value = optionalText(value, label) +if ~(ischar(value) || (isstring(value) && isscalar(value))) + invalid("%s must be scalar text.", label); +end +value = string(value); +end + +function invalid(message, varargin) +error("labkit:app:runtime:InvalidSourceRecords", message, varargin{:}); +end diff --git a/+labkit/+app/+internal/ProjectDocumentStore.m b/+labkit/+app/+internal/ProjectDocumentStore.m new file mode 100644 index 000000000..b9f73fb1c --- /dev/null +++ b/+labkit/+app/+internal/ProjectDocumentStore.m @@ -0,0 +1,448 @@ +classdef (Hidden, Sealed) ProjectDocumentStore < handle + % Private durable-project storage for RuntimeKernel. + properties (SetAccess = private) + Metadata (1, 1) struct + end + + properties (Access = private) + Application + Contract + Context + Sources + end + + methods (Access = ?labkit.app.internal.RuntimeKernel) + function obj = ProjectDocumentStore(application, context, contract) + if ~isa(application, "labkit.app.Definition") || ... + isempty(application.ProjectSchema) || ... + ~isa(context, "labkit.app.CallbackContext") || ... + ~isa(contract, "labkit.app.internal.CompiledDefinition") + error("labkit:app:runtime:InvariantFailure", ... + "Project document storage requires an Application with Project."); + end + obj.Application = application; + obj.Contract = contract; + obj.Context = context; + obj.Sources = labkit.app.internal.PortableSourceStore(); + nowUtc = utcNow(); + obj.Metadata = struct( ... + "id", newId(), ... + "createdAtUtc", nowUtc, ... + "modifiedAtUtc", nowUtc, ... + "revision", uint64(0), ... + "path", "", ... + "dirty", true); + end + + function result = save(obj, state, filepath) + filepath = projectPath(filepath); + candidate = obj.nextSavedMetadata(filepath); + envelope = obj.envelope(state, candidate); + writeProjectFile(filepath, envelope); + obj.Metadata = candidate; + result = labkit.app.dialog.Choice(filepath, Cancelled=false); + end + + function saveRecovery(obj, state, filepath) + filepath = projectPath(filepath); + candidate = obj.Metadata; + candidate.path = filepath; + envelope = obj.envelope(state, candidate); + writeProjectFile(filepath, envelope); + end + + function [state, metadata] = restore(obj, filepath, asRecovery) + filepath = projectPath(filepath); + if nargin < 3 + asRecovery = false; + end + if ~(islogical(asRecovery) && isscalar(asRecovery)) + error("labkit:app:contract:InvalidValue", ... + "Project restore asRecovery must be logical scalar."); + end + [project, resume, document] = obj.readProject(filepath); + project = obj.migrate(project, document.payloadVersion); + project = obj.resolveBoundSources(project, filepath); + obj.validateProject(project); + session = obj.createSession(project, resume); + state = struct("project", project, "session", session); + candidate = document.metadata; + candidate.path = filepath; + candidate.dirty = false; + if asRecovery + candidate.path = ""; + candidate.dirty = true; + end + metadata = candidate; + end + + function [state, metadata] = createNew(obj) + project = obj.Application.ProjectSchema.Create(); + obj.validateProject(project); + session = obj.createSession(project, []); + state = struct("project", project, "session", session); + metadata = obj.newImportedMetadata(); + end + + function acceptRestore(obj, metadata) + obj.Metadata = metadata; + end + + function markDirty(obj) + if ~obj.Metadata.dirty + obj.Metadata.dirty = true; + obj.Metadata.modifiedAtUtc = utcNow(); + end + end + end + + methods (Access = private) + function envelope = envelope(obj, state, metadata) + obj.validateState(state); + resume = struct(); + contract = obj.Application.ProjectSchema; + if ~isempty(contract.CreateResume) + resume = contract.CreateResume(state.session, state.project); + if isempty(resume) + resume = struct(); + end + end + sdk = labkit.app.version(); + [project, sources] = obj.rebaseBoundSources( ... + state.project, metadata.path); + envelope = struct( ... + "format", "labkit.project", ... + "formatVersion", struct("major", 1, "minor", 0), ... + "app", struct("id", obj.Application.AppId, ... + "payloadVersion", double(contract.Version)), ... + "document", documentEnvelope(metadata), ... + "producer", struct( ... + "appVersion", obj.Application.AppVersion, ... + "labkitAppVersion", string(sdk.current), ... + "matlabRelease", string(version("-release")), ... + "platform", string(computer)), ... + "sources", sources, ... + "payload", project, ... + "resume", resume); + end + + function [project, resume, decoded] = readProject(obj, filepath) + details = whos("-file", char(filepath)); + names = string({details.name}); + legacy = string(fieldnames(obj.Application.ProjectSchema.LegacyImports)).'; + recognized = intersect(names, ["labkitProject", legacy]); + if numel(recognized) ~= 1 + error("labkit:app:runtime:UnknownProjectFormat", ... + "Project file must contain exactly one recognized state variable."); + end + name = recognized(1); + loaded = load(char(filepath), char(name)); + if name == "labkitProject" + [project, resume, decoded] = obj.decodeEnvelope(loaded.labkitProject); + return; + end + importer = obj.Application.ProjectSchema.LegacyImports.(char(name)); + if nargout(importer) == 2 + [project, resume] = importer(loaded.(char(name))); + else + project = importer(loaded.(char(name))); + resume = struct(); + end + decoded = struct("payloadVersion", double(obj.Application.ProjectSchema.Version), ... + "metadata", obj.newImportedMetadata()); + end + + function [project, resume, decoded] = decodeEnvelope(obj, envelope) + requireStruct(envelope, "project envelope"); + requireFields(envelope, ["format", "formatVersion", "app", ... + "document", "producer", "payload", "resume"], "project envelope"); + if string(envelope.format) ~= "labkit.project" + invalidProject("Unsupported project format."); + end + requireFields(envelope.formatVersion, ["major", "minor"], ... + "formatVersion"); + major = positiveInteger(envelope.formatVersion.major, ... + "Project format major version"); + if major > 1 + error("labkit:app:runtime:NewerProjectFormat", ... + "Project format major version is newer than this LabKit reader."); + elseif major ~= 1 + invalidProject("Unsupported project format major version."); + end + requireFields(envelope.app, ["id", "payloadVersion"], "app"); + if string(envelope.app.id) ~= obj.Application.AppId + error("labkit:app:runtime:WrongProjectApp", ... + "Project app id does not match the running app."); + end + project = envelope.payload; + resume = envelope.resume; + decoded = struct( ... + "payloadVersion", positiveInteger(envelope.app.payloadVersion, ... + "Project payload version"), ... + "metadata", metadataFromEnvelope(envelope.document)); + end + + function project = migrate(obj, project, fromVersion) + current = double(obj.Application.ProjectSchema.Version); + if fromVersion > current + error("labkit:app:runtime:NewerProjectPayload", ... + "Project payload version is newer than supported version."); + end + for version = fromVersion:current - 1 + project = obj.Application.ProjectSchema.Migrate(project, version); + end + end + + function [project, collected] = rebaseBoundSources( ... + obj, project, filepath) + bindings = obj.projectSourceBindings(); + collected = struct([]); + for path = bindings + sources = getProjectBinding(project, path); + sources = obj.Sources.rebase(sources, filepath); + project = setProjectBinding(project, path, sources); + if isempty(collected) + collected = sources; + elseif ~isempty(sources) + collected = [collected; sources]; + end + end + end + + function project = resolveBoundSources(obj, project, filepath) + for path = obj.projectSourceBindings() + sources = getProjectBinding(project, path); + [resolved, unresolved] = obj.Sources.resolve( ... + sources, filepath); + project = setProjectBinding(project, path, resolved); + if isempty(unresolved) + continue; + end + callback = obj.Application.ProjectSchema.RelinkSources; + if isempty(callback) + error("labkit:app:runtime:MissingProjectSource", ... + "Project has unresolved required source files."); + end + project = callback(project, unresolved, filepath); + if isempty(project) + error("labkit:app:runtime:ProjectLoadCancelled", ... + "Project source relinking was cancelled."); + end + sources = getProjectBinding(project, path); + [resolved, remaining] = obj.Sources.resolve( ... + sources, filepath); + if ~isempty(remaining) + error("labkit:app:runtime:MissingProjectSource", ... + "Project source relinking left required files unresolved."); + end + project = setProjectBinding(project, path, resolved); + end + end + + function bindings = projectSourceBindings(obj) + plan = obj.Contract.PlatformPlan; + bindings = strings(1, 0); + for k = 1:numel(plan.Nodes) + node = plan.Nodes(k); + if node.Kind ~= "fileList" || ... + ~isfield(node.Configuration, "Bind") + continue; + end + path = node.Configuration.Bind; + if startsWith(path, "project.") + bindings(end + 1) = extractAfter(path, "project."); + end + end + bindings = unique(bindings, "stable"); + end + + function validateProject(obj, project) + if ~isstruct(project) || ~isscalar(project) + invalidProject("Project payload must be a scalar struct."); + end + accepted = obj.Application.ProjectSchema.Validate(project); + if ~(islogical(accepted) && isscalar(accepted) && accepted) + invalidProject("Project.Validate rejected the loaded project payload."); + end + end + + function session = createSession(obj, project, resume) + if isempty(obj.Application.CreateSession) + session = struct(); + else + session = obj.Application.CreateSession(project, obj.Context); + end + if ~isstruct(session) || ~isscalar(session) + error("labkit:app:runtime:InvariantFailure", ... + "Application Session must return a scalar struct."); + end + apply = obj.Application.ProjectSchema.ApplyResume; + if ~isempty(apply) && ~isempty(resume) + session = apply(session, resume, project); + end + if ~isstruct(session) || ~isscalar(session) + error("labkit:app:runtime:InvariantFailure", ... + "Project ApplyResume must return a scalar session struct."); + end + end + + function validateState(obj, state) + if ~isstruct(state) || ~isscalar(state) || ... + ~all(isfield(state, ["project", "session"])) || ... + ~isstruct(state.session) || ~isscalar(state.session) + error("labkit:app:runtime:InvariantFailure", ... + "Project save requires scalar project/session state."); + end + obj.validateProject(state.project); + end + + function metadata = nextSavedMetadata(obj, filepath) + metadata = obj.Metadata; + metadata.modifiedAtUtc = utcNow(); + metadata.revision = metadata.revision + uint64(1); + metadata.path = filepath; + metadata.dirty = false; + end + + function metadata = newImportedMetadata(~) + nowUtc = utcNow(); + metadata = struct("id", newId(), "createdAtUtc", nowUtc, ... + "modifiedAtUtc", nowUtc, "revision", uint64(0), ... + "path", "", "dirty", true); + end + end +end + +function value = getProjectBinding(project, path) +parts = cellstr(split(path, ".")); +value = project; +for k = 1:numel(parts) + name = parts{k}; + if ~isstruct(value) || ~isscalar(value) || ~isfield(value, name) + invalidProject("Bound source path is absent: project.%s.", path); + end + value = value.(name); +end +end + +function project = setProjectBinding(project, path, value) +project = assignProjectField(project, cellstr(split(path, ".")), value, path); +end + +function owner = assignProjectField(owner, parts, value, path) +name = parts{1}; +if ~isstruct(owner) || ~isscalar(owner) || ~isfield(owner, name) + invalidProject("Bound source path is absent: project.%s.", path); +end +if numel(parts) == 1 + owner.(name) = value; +else + owner.(name) = assignProjectField( ... + owner.(name), parts(2:end), value, path); +end +end + +function writeProjectFile(filepath, labkitProject) +folder = string(fileparts(filepath)); +if strlength(folder) == 0 + folder = string(pwd); + filepath = fullfile(folder, filepath); +end +if ~isfolder(folder) + error("labkit:app:runtime:ProjectWriteFailed", ... + "Project destination folder does not exist: %s.", folder); +end +temporary = string(tempname(folder)) + ".mat"; +cleanup = onCleanup(@() deleteIfPresent(temporary)); +save(char(temporary), "labkitProject"); +inventory = whos("-file", char(temporary)); +if numel(inventory) ~= 1 || string(inventory.name) ~= "labkitProject" + error("labkit:app:runtime:ProjectWriteFailed", ... + "Temporary project readback inventory was invalid."); +end +readback = load(char(temporary), "labkitProject"); +if ~isequaln(readback.labkitProject, labkitProject) + error("labkit:app:runtime:ProjectWriteFailed", ... + "Temporary project readback did not match the encoded document."); +end +[moved, message] = movefile(char(temporary), char(filepath), "f"); +if ~moved + error("labkit:app:runtime:ProjectWriteFailed", ... + "Could not replace project file: %s.", message); +end +clear cleanup +end + +function value = projectPath(value) +if ~(ischar(value) || (isstring(value) && isscalar(value))) || ... + strlength(string(value)) == 0 + error("labkit:app:contract:InvalidValue", ... + "Project filepath must be nonempty scalar text."); +end +value = string(value); +end + +function value = positiveInteger(value, label) +if ~(isnumeric(value) && isscalar(value) && isfinite(value) && ... + value >= 1 && value == fix(value)) + invalidProject("%s must be a positive integer.", label); +end +value = double(value); +end + +function metadata = metadataFromEnvelope(value) +requireFields(value, ["id", "createdAtUtc", "modifiedAtUtc", "revision"], ... + "document"); +metadata = struct( ... + "id", scalarText(value.id, "document id"), ... + "createdAtUtc", scalarText(value.createdAtUtc, "document createdAtUtc"), ... + "modifiedAtUtc", scalarText(value.modifiedAtUtc, "document modifiedAtUtc"), ... + "revision", uint64(positiveInteger(value.revision + 1, ... + "document revision") - 1), ... + "path", "", "dirty", true); +end + +function value = documentEnvelope(metadata) +value = rmfield(metadata, ["path", "dirty"]); +end + +function requireStruct(value, label) +if ~isstruct(value) || ~isscalar(value) + invalidProject("%s must be a scalar struct.", label); +end +end + +function requireFields(value, fields, label) +requireStruct(value, label); +for k = 1:numel(fields) + if ~isfield(value, fields(k)) + invalidProject("%s is missing field %s.", label, fields(k)); + end +end +end + +function value = scalarText(value, label) +if ~(ischar(value) || (isstring(value) && isscalar(value))) + invalidProject("%s must be scalar text.", label); +end +value = string(value); +end + +function value = utcNow() +value = string(datetime("now", "TimeZone", "UTC", ... + "Format", "yyyy-MM-dd'T'HH:mm:ss'Z'")); +end + +function value = newId() +value = string(char(java.util.UUID.randomUUID())); +end + +function deleteIfPresent(filepath) +if isfile(filepath) + delete(filepath); +end +end + +function invalidProject(message, varargin) +error("labkit:app:runtime:InvalidProject", message, varargin{:}); +end diff --git a/+labkit/+app/+internal/ResourceStore.m b/+labkit/+app/+internal/ResourceStore.m new file mode 100644 index 000000000..da8e3a28e --- /dev/null +++ b/+labkit/+app/+internal/ResourceStore.m @@ -0,0 +1,100 @@ +classdef (Hidden, Sealed) ResourceStore < handle + % Private parent-owned disposable resource store. + properties (Access = private) + Entries + end + + methods (Access = ?labkit.app.internal.RuntimeKernel) + function obj = ResourceStore() + obj.Entries = containers.Map( ... + "KeyType", "char", "ValueType", "any"); + end + + function set(obj, scope, id, value, cleanup) + key = resourceKey(scope, id); + if isKey(obj.Entries, key) + obj.dispose(obj.Entries(key)); + end + obj.Entries(key) = struct( ... + "Scope", string(scope), "Id", string(id), ... + "Value", value, "Cleanup", cleanup); + end + + function value = get(obj, scope, id) + key = resourceKey(scope, id); + if ~isKey(obj.Entries, key) + value = []; + return; + end + entry = obj.Entries(key); + value = entry.Value; + end + + function remove(obj, scope, id) + key = resourceKey(scope, id); + if ~isKey(obj.Entries, key) + return; + end + entry = obj.Entries(key); + remove(obj.Entries, key); + obj.dispose(entry); + end + + function clearScope(obj, scope) + keys = string(obj.Entries.keys); + selected = startsWith(keys, string(scope) + "|"); + failures = {}; + for key = keys(selected) + entry = obj.Entries(char(key)); + remove(obj.Entries, char(key)); + try + obj.dispose(entry); + catch cause + failures{end + 1} = cause; + end + end + if ~isempty(failures) + failure = MException( ... + "labkit:app:runtime:ResourceCleanupFailed", ... + "One or more %s resources failed to clean up.", scope); + for k = 1:numel(failures) + failure = addCause(failure, failures{k}); + end + throwAsCaller(failure); + end + end + + function clearAll(obj) + failures = {}; + for scope = ["event", "interaction", "document", "application"] + try + obj.clearScope(scope); + catch cause + failures{end + 1} = cause; + end + end + if ~isempty(failures) + failure = MException( ... + "labkit:app:runtime:ResourceCleanupFailed", ... + "One or more runtime resources failed to clean up."); + for k = 1:numel(failures) + failure = addCause(failure, failures{k}); + end + throwAsCaller(failure); + end + end + end + + methods (Static, Access = private) + function dispose(entry) + if isempty(entry.Cleanup) + return; + end + entry.Cleanup(entry.Value); + end + end +end + +function key = resourceKey(scope, id) + key = char(string(scope) + "|" + string(id)); +end diff --git a/+labkit/+app/+internal/ResultWriter.m b/+labkit/+app/+internal/ResultWriter.m new file mode 100644 index 000000000..51dab9c5a --- /dev/null +++ b/+labkit/+app/+internal/ResultWriter.m @@ -0,0 +1,197 @@ +classdef (Hidden, Sealed) ResultWriter < handle + % Private atomic writer for one explicit Result package. + % Apps use RuntimeContext.writeResult. + + properties (Access = private) + Application + Document + end + + methods (Access = ?labkit.app.internal.RuntimeKernel) + function obj = ResultWriter(application, document) + if ~isa(application, "labkit.app.Definition") + invalid("Result writer requires an Application value."); + end + obj.Application = application; + obj.Document = documentMetadata(document); + end + + function written = write(obj, folder, result) + folder = packageFolder(folder); + if ~isa(result, "labkit.app.result.Package") + invalid("Result writer requires a Result value."); + end + if ~isfolder(folder) && ~mkdir(folder) + invalid("Could not create result package folder."); + end + + outputs = verifiedOutputs(folder, result.Outputs); + manifest = resultManifest( ... + obj.Application, obj.Document, result, outputs); + manifestPath = fullfile(folder, result.ManifestName); + writeAtomic(manifestPath, manifest); + written = labkit.app.dialog.Choice(string(manifestPath)); + end + end +end + +function metadata = documentMetadata(value) + if nargin == 0 || isempty(value) + metadata = []; + return; + end + if ~isstruct(value) || ~isscalar(value) || ... + ~all(isfield(value, ["id", "revision"])) + invalid("Document metadata must be empty or contain id and revision."); + end + id = scalarText(value.id, "Document id"); + revision = value.revision; + if strlength(id) == 0 || ~(isnumeric(revision) && isscalar(revision) && ... + isfinite(revision) && revision >= 0 && revision == fix(revision)) + invalid("Document metadata is invalid."); + end + metadata = struct("id", id, "revision", uint64(revision)); +end + +function folder = packageFolder(value) + folder = scalarText(value, "Result folder"); + if strlength(folder) == 0 + invalid("Result folder must be nonempty."); + end +end + +function outputs = verifiedOutputs(folder, declarations) + outputs = struct("id", {}, "role", {}, "relativePath", {}, ... + "mediaType", {}, "bytes", {}, "sha256", {}, "status", {}, ... + "message", {}, "warnings", {}); + for k = 1:numel(declarations) + declaration = declarations{k}; + status = declaration.Status; + message = declaration.Message; + bytes = uint64(0); + digest = ""; + if status == "success" + target = packagePath(folder, declaration.RelativePath); + if ~isfile(target) + status = "failed"; + message = "Output file was not found after export."; + else + details = dir(target); + bytes = uint64(details.bytes); + digest = sha256File(target); + end + end + outputs(end + 1) = struct( ... + "id", declaration.Id, "role", declaration.Role, ... + "relativePath", declaration.RelativePath, ... + "mediaType", declaration.MediaType, "bytes", bytes, ... + "sha256", digest, "status", status, "message", message, ... + "warnings", declaration.Warnings); + end +end + +function path = packagePath(folder, relativePath) + parts = cellstr(split(relativePath, "/")); + path = fullfile(folder, parts{:}); +end + +function manifest = resultManifest(application, document, result, outputs) + manifest = struct( ... + "format", "labkit.result", ... + "formatVersion", struct("major", 1, "minor", 0), ... + "app", struct("id", application.AppId, ... + "version", application.AppVersion), ... + "run", struct("id", newId(), "createdAtUtc", utcNow(), ... + "status", aggregateStatus(outputs)), ... + "inputs", result.Inputs, "parameters", result.Parameters, ... + "outputs", outputs, "summary", result.Summary, ... + "provenance", struct( ... + "labkitAppVersion", string(labkit.app.version().current), ... + "matlabRelease", string(version("-release")), ... + "platform", string(computer), "warnings", result.Warnings)); + if ~isempty(document) + manifest.project = struct("documentId", document.id, ... + "revision", document.revision); + end +end + +function value = aggregateStatus(outputs) + statuses = string({outputs.status}); + if all(statuses == "success") + value = "success"; + elseif all(statuses == "failed") + value = "failed"; + else + value = "partial"; + end +end + +function digest = sha256File(filepath) + file = fopen(filepath, "rb"); + if file < 0 + invalid("Could not read exported output for checksum."); + end + cleanup = onCleanup(@() fclose(file)); + algorithm = java.security.MessageDigest.getInstance("SHA-256"); + while true + buffer = fread(file, 65536, "*uint8"); + if isempty(buffer) + break; + end + algorithm.update(typecast(buffer, "int8")); + end + raw = typecast(algorithm.digest(), "uint8"); + digest = lower(string(reshape(dec2hex(raw, 2).', 1, []))); + clear cleanup +end + +function writeAtomic(filepath, manifest) + try + payload = jsonencode(manifest, PrettyPrint=true); + catch cause + failure = MException("labkit:app:runtime:InvalidResultManifest", ... + "Result manifest is not JSON serializable."); + failure = addCause(failure, cause); + throwAsCaller(failure); + end + temporary = string(filepath) + ".tmp"; + cleanup = onCleanup(@() deleteIfPresent(temporary)); + file = fopen(temporary, "w"); + if file < 0 + invalid("Could not create result manifest temporary file."); + end + fileCleanup = onCleanup(@() fclose(file)); + fwrite(file, payload, "char"); + clear fileCleanup + [moved, message] = movefile(temporary, filepath, "f"); + if ~moved + invalid("Could not replace result manifest: %s.", message); + end + clear cleanup +end + +function value = scalarText(value, label) + if ~(ischar(value) || (isstring(value) && isscalar(value))) + invalid("%s must be scalar text.", label); + end + value = string(value); +end + +function value = newId() + value = string(char(java.util.UUID.randomUUID())); +end + +function value = utcNow() + value = string(datetime("now", "TimeZone", "UTC", ... + "Format", "yyyy-MM-dd'T'HH:mm:ss'Z'")); +end + +function deleteIfPresent(filepath) + if isfile(filepath) + delete(filepath); + end +end + +function invalid(message, varargin) + error("labkit:app:runtime:InvalidResultManifest", message, varargin{:}); +end diff --git a/+labkit/+app/+internal/RuntimeContractBoundary.m b/+labkit/+app/+internal/RuntimeContractBoundary.m new file mode 100644 index 000000000..b222159b3 --- /dev/null +++ b/+labkit/+app/+internal/RuntimeContractBoundary.m @@ -0,0 +1,160 @@ +% Resolve compiled callback, platform, and state invariants for RuntimeKernel. +% Expected caller: RuntimeKernel. Inputs are the compiled internal contract, +% immutable Definition metadata, and callback payloads. Outputs are validated +% bindings, initial state values, adapters, or reader-facing busy text. +classdef (Sealed, Hidden) RuntimeContractBoundary + methods (Static) + function binding = interactionSignal(contract, interactionId, signal) + plan = contract.PlatformPlan; + binding = []; + for k = 1:numel(plan.Nodes) + config = plan.Nodes(k).Configuration; + if ~isfield(config, "Interactions") + continue; + end + interactions = config.Interactions; + match = find(cellfun(@(value) ... + value.Id == string(interactionId), interactions), 1); + if ~isempty(match) + binding = interactions{match}.signal(string(signal)); + break; + end + end + if isempty(binding) + error("labkit:app:contract:UnknownReference", ... + "Interaction %s has no %s callback.", ... + interactionId, signal); + end + end + + function binding = signalForTarget( ... + contract, target, signal, required) + if nargin < 4 + required = true; + end + plan = contract.PlatformPlan; + index = find(string({plan.Nodes.Id}) == string(target), 1); + binding = []; + if ~isempty(index) + signals = plan.Nodes(index).Signals; + match = find(cellfun( ... + @(value) value.Signal == signal, signals), 1); + if ~isempty(match) + binding = signals{match}; + end + end + if required && isempty(binding) + error("labkit:app:contract:UnknownReference", ... + "Workbench target has no %s callback: %s.", ... + signal, target); + end + end + + function [config, current] = fileListState( ... + contract, state, target) + plan = contract.PlatformPlan; + index = find(string({plan.Nodes.Id}) == string(target), 1); + if isempty(index) || plan.Nodes(index).Kind ~= "fileList" + error("labkit:app:contract:UnknownReference", ... + "Layout target is not a fileList: %s.", target); + end + config = plan.Nodes(index).Configuration; + if strlength(config.Bind) == 0 + error("labkit:app:contract:UnknownReference", ... + "fileList target has no source binding: %s.", target); + end + current = labkit.app.internal.RuntimeStatePath.read( ... + state, config.Bind); + end + + function adapter = createAdapter(application, contract, platform) + if ~(ischar(platform) || ... + (isstring(platform) && isscalar(platform))) + error("labkit:app:runtime:InvariantFailure", ... + "Runtime platform must be scalar text."); + end + switch string(platform) + case "headless" + adapter = labkit.app.internal.HeadlessPlatformAdapter(); + case "matlab" + plan = contract.PlatformPlan; + title = application.Title + " v" + ... + application.AppVersion + " (" + ... + application.Updated + ")"; + if ~isempty(application.ProjectSchema) + title = title + " *"; + end + adapter = labkit.app.internal.MatlabPlatformAdapter( ... + plan, title); + otherwise + error("labkit:app:runtime:InvariantFailure", ... + "Runtime platform is unsupported: %s.", platform); + end + end + + function project = initialProject(application, supplied) + if ~isempty(supplied) + project = supplied; + elseif ~isempty(application.ProjectSchema) + project = application.ProjectSchema.Create(); + else + project = struct(); + end + if ~isstruct(project) || ~isscalar(project) + error("labkit:app:runtime:InvariantFailure", ... + "Application project must be a scalar struct."); + end + end + + function validateDispatch(contract, binding, payload) + if ~isa(binding, "labkit.app.internal.SignalBinding") || ... + ~contract.hasSignal(binding) + error("labkit:app:contract:UnknownReference", ... + "Runtime dispatch callback is undeclared."); + end + if ~binding.AcceptsPayload && ~isempty(payload) + error("labkit:app:contract:InvalidValue", ... + "Callback %s does not accept a payload.", binding.Id); + end + if strlength(binding.PayloadClass) > 0 && ... + ~isa(payload, binding.PayloadClass) + error("labkit:app:contract:InvalidValue", ... + "Callback %s payload must be %s.", ... + binding.Id, binding.PayloadClass); + end + end + + function validateState(application, state) + if ~isstruct(state) || ~isscalar(state) || ... + ~all(isfield(state, ["project", "session"])) || ... + ~isstruct(state.project) || ~isscalar(state.project) || ... + ~isstruct(state.session) || ~isscalar(state.session) + error("labkit:app:runtime:InvariantFailure", ... + "Command must return scalar project/session state."); + end + if ~isempty(application.ProjectSchema) + accepted = application.ProjectSchema.Validate(state.project); + if ~(islogical(accepted) && isscalar(accepted) && accepted) + error("labkit:app:runtime:InvariantFailure", ... + "Project validation rejected command state."); + end + end + end + + function message = busyMessage(contract, binding) + message = extractBefore(binding.Id, "__"); + plan = contract.PlatformPlan; + index = find(string({plan.Nodes.Id}) == message, 1); + if isempty(index) + return + end + config = plan.Nodes(index).Configuration; + if isfield(config, "BusyMessage") && ... + strlength(config.BusyMessage) > 0 + message = config.BusyMessage; + elseif isfield(config, "Label") && strlength(config.Label) > 0 + message = config.Label; + end + end + end +end diff --git a/+labkit/+app/+internal/RuntimeFactory.m b/+labkit/+app/+internal/RuntimeFactory.m new file mode 100644 index 000000000..eb6b68297 --- /dev/null +++ b/+labkit/+app/+internal/RuntimeFactory.m @@ -0,0 +1,164 @@ +classdef (Hidden, Sealed) RuntimeFactory + % Internal runtime and synthetic-diagnostic construction boundary. + + methods (Static) + function runtime = createHeadless( ... + definition, initialProject, backend, diagnostics) + if nargin < 2 + initialProject = []; + end + if nargin < 3 + backend = struct(); + end + if nargin < 4 + diagnostics = labkit.app.diagnostic.Options(); + end + runtime = labkit.app.internal.RuntimeFactory.create( ... + definition, initialProject, backend, ... + "headless", diagnostics); + end + + function runtime = createMatlab( ... + definition, initialProject, backend, diagnostics) + if nargin < 2 + initialProject = []; + end + if nargin < 3 + backend = struct(); + end + if nargin < 4 + diagnostics = labkit.app.diagnostic.Options(); + end + runtime = labkit.app.internal.RuntimeFactory.create( ... + definition, initialProject, backend, ... + "matlab", diagnostics); + end + end + + methods (Static, Access = private) + function runtime = create( ... + definition, initialProject, backend, platform, diagnostics) + if ~isa(definition, "labkit.app.Definition") || ... + ~isscalar(definition) + error("labkit:app:runtime:InvariantFailure", ... + "RuntimeFactory requires one Definition."); + end + recorder = labkit.app.internal.DiagnosticRecorder( ... + definition, diagnostics); + sampleOperation = []; + try + if diagnostics.Sample == "synthetic" + sampleOperation = recorder.begin( ... + "sample", "synthetic", "build"); + initialProject = buildSyntheticProject( ... + definition, initialProject, diagnostics); + recorder.finish(sampleOperation, "completed", []); + sampleOperation = []; + end + runtime = labkit.app.internal.RuntimeKernel( ... + definition, definition.Compiled, initialProject, ... + backend, platform, diagnostics, recorder); + catch cause + if ~isempty(sampleOperation) + recorder.finish(sampleOperation, "failed", cause); + end + recorder.close(); + rethrow(cause); + end + end + end +end + +function initialProject = buildSyntheticProject( ... + definition, initialProject, diagnostics) +if ~isempty(initialProject) + error("labkit:app:contract:InvalidValue", ... + "Definition launch cannot combine InitialProject with " + ... + "a synthetic diagnostic sample."); +end +if strlength(diagnostics.ArtifactFolder) == 0 + error("labkit:app:contract:InvalidValue", ... + "A synthetic diagnostic sample requires ArtifactFolder."); +end +if isempty(definition.BuildDebugSample) + error("labkit:app:contract:UnsupportedOperation", ... + "Definition does not declare BuildDebugSample."); +end +if isempty(definition.ProjectSchema) + error("labkit:app:contract:UnsupportedOperation", ... + "A synthetic diagnostic sample requires ProjectSchema."); +end +context = labkit.app.diagnostic.SampleContext(diagnostics.ArtifactFolder); +pack = definition.BuildDebugSample(context); +if ~isa(pack, "labkit.app.diagnostic.SamplePack") || ~isscalar(pack) + error("labkit:app:contract:InvalidValue", ... + "BuildDebugSample must return one " + ... + "labkit.app.diagnostic.SamplePack value."); +end +try + accepted = definition.ProjectSchema.Validate(pack.InitialProject); +catch cause + failure = MException( ... + "labkit:app:contract:InvalidValue", ... + "BuildDebugSample returned an invalid current project."); + failure = addCause(failure, cause); + throw(failure); +end +if ~isequal(accepted, true) + error("labkit:app:contract:InvalidValue", ... + "BuildDebugSample returned an invalid current project."); +end +verifySampleArtifacts(context, pack); +writeSampleManifest(context, pack); +initialProject = pack.InitialProject; +end + +function verifySampleArtifacts(context, pack) +for k = 1:numel(pack.Artifacts) + artifact = pack.Artifacts{k}; + if artifact.Expectation == "exports" + continue; + end + pathParts = cellstr(split(artifact.RelativePath, "/")); + filepath = string(fullfile( ... + char(context.ArtifactFolder), pathParts{:})); + if exist(char(filepath), "file") ~= 2 && ... + exist(char(filepath), "dir") ~= 7 + error("labkit:app:contract:InvalidValue", ... + "BuildDebugSample did not create artifact %s.", artifact.Id); + end +end +end + +function writeSampleManifest(context, pack) +artifacts = repmat(struct( ... + "id", "", "role", "", "relativePath", "", ... + "expectation", ""), 1, numel(pack.Artifacts)); +for k = 1:numel(pack.Artifacts) + artifact = pack.Artifacts{k}; + artifacts(k) = struct( ... + "id", artifact.Id, ... + "role", artifact.Role, ... + "relativePath", artifact.RelativePath, ... + "expectation", artifact.Expectation); +end +payload = struct( ... + "type", "labkit.diagnostic.sample-pack", ... + "scenario", pack.Scenario, ... + "artifacts", artifacts); +filepath = string(fullfile(context.ArtifactFolder, "sample-pack.json")); +temporary = filepath + ".tmp"; +file = fopen(char(temporary), "w"); +if file < 0 + error("labkit:app:runtime:DiagnosticWriteFailed", ... + "Could not write the diagnostic sample manifest."); +end +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "%s\n", jsonencode(payload, PrettyPrint=true)); +clear cleanup +[moved, message] = movefile(char(temporary), char(filepath), "f"); +if ~moved + error("labkit:app:runtime:DiagnosticWriteFailed", ... + "Could not publish the diagnostic sample manifest: %s", message); +end +end diff --git a/+labkit/+app/+internal/RuntimeKernel.m b/+labkit/+app/+internal/RuntimeKernel.m new file mode 100644 index 000000000..6e6b9d204 --- /dev/null +++ b/+labkit/+app/+internal/RuntimeKernel.m @@ -0,0 +1,686 @@ +classdef (Hidden, Sealed) RuntimeKernel < handle + % Private transactional runtime for compiled Application values. + properties (SetAccess = private) + State (1, 1) struct + Presentation + StatusLog (1, :) string = "Ready." + Diagnostics (1, :) cell = {} + Closed (1, 1) logical = false + StartupFailed (1, 1) logical = false + end + + properties (Access = private) + Application + Contract + Context + Queue (1, :) cell = {} + Processing (1, 1) logical = false + Resources + Adapter + Documents + Sources + Recorder + PendingDocumentMetadata = [] + end + + methods (Access = ?labkit.app.internal.RuntimeFactory) + function obj = RuntimeKernel( ... + application, contract, initialProject, backend, platform, ... + diagnostics, recorder) + obj.Application = application; + obj.Contract = contract; + if nargin < 6 + diagnostics = labkit.app.diagnostic.Options(); + end + if nargin < 7 + recorder = labkit.app.internal.DiagnosticRecorder( ... + application, diagnostics); + end + obj.Recorder = recorder; + startupOperation = obj.Recorder.begin( ... + "lifecycle", "runtime", "construct"); + obj.Resources = labkit.app.internal.ResourceStore(); + obj.Sources = labkit.app.internal.PortableSourceStore(); + if nargin < 4 + platform = "headless"; + end + obj.Adapter = ... + labkit.app.internal.RuntimeContractBoundary.createAdapter( ... + obj.Application, obj.Contract, platform); + try + backend = obj.completeBackend(backend); + obj.Context = ... + labkit.app.internal.CallbackContextFactory.create(backend); + obj.updateStartup("Creating app state..."); + if ~isempty(application.ProjectSchema) + obj.Documents = labkit.app.internal.ProjectDocumentStore( ... + application, obj.Context, contract); + end + project = ... + labkit.app.internal.RuntimeContractBoundary.initialProject( ... + obj.Application, initialProject); + session = struct(); + if ~isempty(application.CreateSession) + session = application.CreateSession(project, obj.Context); + end + if ~isstruct(session) || ~isscalar(session) + error("labkit:app:runtime:InvariantFailure", ... + "Application Session must return a scalar struct."); + end + obj.State = struct("project", project, "session", session); + labkit.app.internal.RuntimeContractBoundary.validateState( ... + obj.Application, obj.State); + obj.updateStartup("Preparing first view..."); + if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + obj.Adapter.attachRuntime(obj); + end + obj.Presentation = obj.present(obj.State); + obj.Adapter.reconcile([], obj.Presentation); + if ~isempty(application.OnStart) + obj.updateStartup("Running startup actions..."); + obj.dispatch( ... + contract.onStartBinding(), []); + end + obj.Recorder.finish( ... + startupOperation, "completed", []); + if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + obj.Adapter.finishStartup(); + end + catch cause + obj.Recorder.finish( ... + startupOperation, "failed", cause); + if isa(obj.Adapter, ... + "labkit.app.internal.MatlabPlatformAdapter") + obj.StartupFailed = true; + obj.Adapter.failStartup(cause); + return + end + obj.close(); + rethrow(cause); + end + end + end + + methods + function dispatch(obj, binding, payload) + obj.assertOpen(); + labkit.app.internal.RuntimeContractBoundary.validateDispatch( ... + obj.Contract, binding, payload); + obj.Queue{end + 1} = struct( ... + "Binding", binding, "Payload", {payload}); + if obj.Processing + return; + end + obj.Processing = true; + if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + obj.Adapter.beginBusy( ... + labkit.app.internal.RuntimeContractBoundary.busyMessage( ... + obj.Contract, binding)); + end + cleanup = onCleanup(@() obj.finishProcessing()); + while ~isempty(obj.Queue) + item = obj.Queue{1}; + obj.Queue(1) = []; + obj.execute(item.Binding, item.Payload); + end + clear cleanup + end + + function setResource(obj, scope, id, value, cleanup) + obj.Resources.set(scope, id, value, cleanup); + end + + function value = getResource(obj, scope, id) + value = obj.Resources.get(scope, id); + end + + function removeResource(obj, scope, id) + obj.Resources.remove(scope, id); + end + + function clearResourceScope(obj, scope) + obj.Resources.clearScope(scope); + end + + function failNextCommit(obj) + obj.Adapter.failNextCommit(); + end + + function count = commitCount(obj) + count = obj.Adapter.CommitCount; + end + + function events = diagnosticEvents(obj) + events = obj.Recorder.events(); + end + + function folder = diagnosticFolder(obj) + folder = obj.Recorder.artifactFolder(); + end + + function figure = figureHandle(obj) + if ~isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + error("labkit:app:runtime:InvariantFailure", ... + "Headless runtime has no MATLAB figure."); + end + figure = obj.Adapter.figureForRuntime(); + end + + function showFigure(obj) + if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + obj.Adapter.show(obj.formattedWindowTitle()); + end + end + + function result = saveProject(obj, state, filepath) + obj.assertProjectStore(); + result = obj.Documents.save(state, filepath); + obj.refreshWindowTitle(); + end + + function state = prepareProjectRestore(obj, filepath) + obj.assertOpen(); + obj.assertProjectStore(); + [state, obj.PendingDocumentMetadata] = ... + obj.Documents.restore(filepath, false); + end + + function state = prepareNewProject(obj) + obj.assertOpen(); + obj.assertProjectStore(); + [state, obj.PendingDocumentMetadata] = obj.Documents.createNew(); + end + + function saveRecovery(obj, state, filepath) + obj.assertProjectStore(); + obj.Documents.saveRecovery(state, filepath); + end + + function restoreProject(obj, filepath, asRecovery) + if nargin < 3 + asRecovery = false; + end + obj.assertOpen(); + obj.assertProjectStore(); + previousState = obj.State; + previousPresentation = obj.Presentation; + [candidate, metadata] = obj.Documents.restore(filepath, asRecovery); + try + labkit.app.internal.RuntimeContractBoundary.validateState( ... + obj.Application, candidate); + view = obj.present(candidate); + obj.Adapter.reconcile(previousPresentation, view); + obj.State = candidate; + obj.Presentation = view; + obj.Documents.acceptRestore(metadata); + obj.refreshWindowTitle(); + catch cause + obj.State = previousState; + obj.Presentation = previousPresentation; + failure = MException( ... + "labkit:app:runtime:ProjectRestoreFailed", ... + "Project restore failed transactionally."); + failure = addCause(failure, cause); + throwAsCaller(failure); + end + end + + function metadata = documentMetadata(obj) + obj.assertProjectStore(); + metadata = obj.Documents.Metadata; + end + + function written = writeResult(obj, folder, result) + metadata = []; + if ~isempty(obj.Documents) + metadata = obj.Documents.Metadata; + end + writer = labkit.app.internal.ResultWriter(obj.Application, metadata); + written = writer.write(folder, result); + end + + function record = sourceRecord(obj, id, role, path, required) + record = obj.Sources.create(id, role, path, required); + end + + function paths = sourcePaths(obj, sources, ids) + if isempty(ids) + paths = obj.Sources.sourcePaths(sources); + else + paths = obj.Sources.sourcePaths(sources, ids); + end + end + + function sources = upsertSource(obj, sources, record) + sources = obj.Sources.upsert(sources, record); + end + + function sources = reconcileSources(obj, current, incoming) + sources = obj.Sources.reconcile(current, incoming); + end + + function applyBinding(obj, target, value) + obj.applyBoundControl(target, value, false); + end + + function applyControlValue(obj, target, value) + plan = obj.Contract.PlatformPlan; + index = find(string({plan.Nodes.Id}) == string(target), 1); + if isempty(index) + error("labkit:app:contract:UnknownReference", ... + "Layout target is undeclared: %s.", target); + end + configuration = plan.Nodes(index).Configuration; + if isfield(configuration, "Bind") && ... + strlength(configuration.Bind) > 0 + obj.applyBoundControl(target, value, true); + else + binding = ... + labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... + obj.Contract, target, "valueChanged", false); + if isempty(binding) + error("labkit:app:contract:UnknownReference", ... + "Layout target has no value behavior: %s.", target); + end + obj.dispatch(binding, value); + end + end + + function invokeAction(obj, target) + obj.assertOpen(); + binding = ... + labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... + obj.Contract, target, "pressed"); + obj.dispatch(binding, []); + end + + function applyTableEdit(obj, target, edit) + obj.assertOpen(); + if ~isa(edit, "labkit.app.event.TableCellEdit") + error("labkit:app:contract:InvalidValue", ... + "Table edit payload must be a TableEdit value."); + end + binding = ... + labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... + obj.Contract, target, "cellEdited"); + obj.dispatch(binding, edit); + end + + function applyTableSelection(obj, target, cells) + obj.assertOpen(); + selection = labkit.app.event.TableCellSelection(cells); + binding = ... + labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... + obj.Contract, target, "cellSelectionChanged"); + obj.dispatch(binding, selection); + end + + function applyInteraction(obj, interactionId, signal, payload) + obj.assertOpen(); + binding = ... + labkit.app.internal.RuntimeContractBoundary.interactionSignal( ... + obj.Contract, interactionId, signal); + obj.dispatch(binding, payload); + end + + function applyFilePanelSelection(obj, target, indices) + obj.assertOpen(); + [config, current] = ... + labkit.app.internal.RuntimeContractBoundary.fileListState( ... + obj.Contract, obj.State, target); + obj.commitFilePanel(target, config, current, indices, false); + end + + function applyBoundControl(obj, target, value, dispatchChanged) + obj.assertOpen(); + if nargin < 4 + dispatchChanged = false; + end + plan = obj.Contract.PlatformPlan; + index = find(string({plan.Nodes.Id}) == string(target), 1); + if isempty(index) || ~isfield(plan.Nodes(index).Configuration, "Bind") + error("labkit:app:contract:UnknownReference", ... + "Layout target has no state binding: %s.", target); + end + path = plan.Nodes(index).Configuration.Bind; + if strlength(path) == 0 + error("labkit:app:contract:UnknownReference", ... + "Layout target has no state binding: %s.", target); + end + previousState = obj.State; + previousPresentation = obj.Presentation; + try + candidate = labkit.app.internal.RuntimeStatePath.write( ... + previousState, path, value); + if dispatchChanged + binding = ... + labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... + obj.Contract, target, "valueChanged", false); + if ~isempty(binding) + candidate = binding.UpdateState( ... + candidate, value, obj.Context); + end + end + labkit.app.internal.RuntimeContractBoundary.validateState( ... + obj.Application, candidate); + view = obj.present(candidate); + obj.Adapter.reconcile(previousPresentation, view); + obj.State = candidate; + obj.Presentation = view; + obj.markDocumentChanged(); + catch cause + obj.State = previousState; + obj.Presentation = previousPresentation; + failure = MException("labkit:app:runtime:ActionFailed", ... + "Bound update for %s failed transactionally.", target); + failure = addCause(failure, cause); + throwAsCaller(failure); + end + end + + function applyFileSelection(obj, target, paths, indices) + obj.assertOpen(); + [config, current] = ... + labkit.app.internal.RuntimeContractBoundary.fileListState( ... + obj.Contract, obj.State, target); + sources = obj.Sources.reconcileRolePaths( ... + current, paths, config.SourceRole, ... + config.SourceIdPrefix, config.Required, ... + config.AllowDuplicatePaths); + visibleSources = obj.Sources.recordsForRole( ... + sources, config.SourceRole); + if nargin < 4 + indices = 1:numel(visibleSources); + end + if ~(isnumeric(indices) && isrow(indices) && ... + all(isfinite(indices)) && all(indices == fix(indices)) && ... + all(indices >= 1) && ... + all(indices <= numel(visibleSources))) + error("labkit:app:contract:InvalidValue", ... + "fileList selection indices are invalid."); + end + obj.commitFilePanel(target, config, sources, indices, true); + end + + function removeFileSelection(obj, target, indices) + obj.assertOpen(); + [config, current] = ... + labkit.app.internal.RuntimeContractBoundary.fileListState( ... + obj.Contract, obj.State, target); + visible = obj.Sources.recordsForRole( ... + current, config.SourceRole); + if ~(isnumeric(indices) && isrow(indices) && ... + all(isfinite(indices)) && all(indices == fix(indices)) && ... + all(indices >= 1) && all(indices <= numel(visible))) + error("labkit:app:contract:InvalidValue", ... + "fileList removal indices are invalid."); + end + keep = true(1, numel(visible)); + keep(indices) = false; + sources = obj.Sources.replaceRole( ... + current, config.SourceRole, visible(keep)); + obj.commitFilePanel( ... + target, config, sources, zeros(1, 0), true); + end + + function close(obj) + if obj.Closed + return; + end + obj.Queue = {}; + obj.Closed = true; + if ~isempty(obj.Resources) + obj.Resources.clearAll(); + end + if ~isempty(obj.Adapter) && isvalid(obj.Adapter) + obj.Adapter.close(); + end + if ~isempty(obj.Recorder) + obj.Recorder.close(); + end + end + + function delete(obj) + obj.close(); + end + end + + methods (Access = private) + function commitFilePanel(obj, target, config, sources, indices, rebuildSession) + if nargin < 6 + rebuildSession = false; + end + visibleSources = obj.Sources.recordsForRole( ... + sources, config.SourceRole); + if ~(isnumeric(indices) && isrow(indices) && ... + all(isfinite(indices)) && all(indices == fix(indices)) && ... + all(indices >= 1) && ... + all(indices <= numel(visibleSources))) + error("labkit:app:contract:InvalidValue", ... + "fileList selection indices are invalid."); + end + candidate = labkit.app.internal.RuntimeStatePath.write( ... + obj.State, config.Bind, sources); + if rebuildSession && ~isempty(obj.Application.CreateSession) + candidate.session = obj.Application.CreateSession( ... + candidate.project, obj.Context); + end + if strlength(config.SelectionBind) > 0 + ids = strings(1, 0); + if ~isempty(indices) + ids = string({visibleSources(indices).id}); + end + selection = labkit.app.event.ListSelection( ... + Ids=ids, Indices=indices); + candidate = labkit.app.internal.RuntimeStatePath.write( ... + candidate, config.SelectionBind, selection); + else + selection = labkit.app.event.ListSelection(Indices=indices); + end + binding = ... + labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... + obj.Contract, target, "listSelectionChanged", false); + if ~isempty(binding) + candidate = binding.UpdateState( ... + candidate, selection, obj.Context); + end + previousState = obj.State; + previousPresentation = obj.Presentation; + try + labkit.app.internal.RuntimeContractBoundary.validateState( ... + obj.Application, candidate); + view = obj.present(candidate); + obj.Adapter.reconcile(previousPresentation, view); + obj.State = candidate; + obj.Presentation = view; + obj.markDocumentChanged(); + catch cause + obj.State = previousState; + obj.Presentation = previousPresentation; + failure = MException("labkit:app:runtime:ActionFailed", ... + "fileList update for %s failed transactionally.", target); + failure = addCause(failure, cause); + throwAsCaller(failure); + end + end + + function backend = completeBackend(obj, backend) + if nargin < 2 || isempty(backend) + backend = struct(); + end + builtins = struct( ... + "appendStatus", @(message) obj.appendStatus(message), ... + "reportError", @(operation, exception) ... + obj.reportError(operation, exception), ... + "diagnosticCheckpoint", @(id) ... + obj.Recorder.note( ... + "app", id, "checkpoint", "completed"), ... + "diagnosticCount", @(id, count) ... + obj.Recorder.count(id, count), ... + "setResource", @(scope, id, value, cleanup) ... + obj.setResource(scope, id, value, cleanup), ... + "getResource", @(scope, id) obj.getResource(scope, id), ... + "removeResource", @(scope, id) obj.removeResource(scope, id), ... + "clearResourceScope", @(scope) obj.clearResourceScope(scope)); + builtins.saveProject = @(state, filepath) ... + obj.saveProject(state, filepath); + builtins.restoreProject = @(filepath) ... + obj.prepareProjectRestore(filepath); + builtins.newProject = @() obj.prepareNewProject(); + builtins.saveRecovery = @(state, filepath) ... + obj.saveRecovery(state, filepath); + builtins.writeResult = @(folder, result) ... + obj.writeResult(folder, result); + builtins.sourcePaths = @(sources, ids) ... + obj.sourcePaths(sources, ids); + if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + builtins.alert = @(message, title) obj.Adapter.alert(message, title); + builtins.choose = @(prompt, choices, title, ... + defaultChoice, cancelChoice) ... + obj.Adapter.chooseOption(prompt, choices, title, ... + defaultChoice, cancelChoice); + builtins.chooseInputFile = @(filters, startPath) ... + obj.Adapter.chooseInputFile(filters, startPath); + builtins.chooseInputFolder = @(startPath) ... + obj.Adapter.chooseInputFolder(startPath); + builtins.chooseOutputFile = @(filters, startPath) ... + obj.Adapter.chooseOutputFile(filters, startPath); + builtins.chooseOutputFolder = @(startPath) ... + obj.Adapter.chooseOutputFolder(startPath); + end + names = string(fieldnames(builtins)); + for k = 1:numel(names) + if ~isfield(backend, names(k)) + backend.(names(k)) = builtins.(names(k)); + end + end + end + + function execute(obj, binding, payload) + previousState = obj.State; + previousPresentation = obj.Presentation; + obj.PendingDocumentMetadata = []; + operation = obj.Recorder.begin( ... + "callback", binding.Id, binding.Signal); + try + if ~binding.AcceptsPayload + candidate = binding.UpdateState(previousState, obj.Context); + else + candidate = binding.UpdateState( ... + previousState, payload, obj.Context); + end + labkit.app.internal.RuntimeContractBoundary.validateState( ... + obj.Application, candidate); + view = obj.present(candidate); + obj.Adapter.reconcile(previousPresentation, view); + obj.State = candidate; + obj.Presentation = view; + if isempty(obj.PendingDocumentMetadata) + obj.markDocumentChanged(); + else + obj.Documents.acceptRestore(obj.PendingDocumentMetadata); + obj.refreshWindowTitle(); + end + obj.Recorder.finish(operation, "completed", []); + catch cause + obj.State = previousState; + obj.Presentation = previousPresentation; + obj.PendingDocumentMetadata = []; + obj.Resources.clearScope("event"); + obj.Recorder.finish(operation, "rolledBack", cause); + failure = MException("labkit:app:runtime:ActionFailed", ... + "Callback %s failed transactionally.", binding.Id); + failure = addCause(failure, cause); + throwAsCaller(failure); + end + obj.PendingDocumentMetadata = []; + obj.Resources.clearScope("event"); + end + + function view = present(obj, state) + view = labkit.app.internal.RuntimePresentation.fromState( ... + obj.Contract.PlatformPlan, state, ... + @(records, role) obj.presentationSourcePaths(records, role), ... + obj.StatusLog); + if isempty(obj.Application.PresentWorkbench) + custom = labkit.app.view.Snapshot(); + else + custom = obj.Application.PresentWorkbench(state); + end + view = view.overlayForRuntime(custom); + obj.Application.validateViewSnapshot(view); + end + + function appendStatus(obj, message) + obj.StatusLog(end + 1) = message; + end + + function paths = presentationSourcePaths(obj, records, role) + records = obj.Sources.recordsForRole(records, role); + paths = obj.Sources.sourcePaths(records); + end + + function reportError(obj, operation, exception) + obj.Diagnostics{end + 1} = struct( ... + "Operation", operation, "Exception", exception); + diagnosticOperation = obj.Recorder.begin( ... + "reportedError", operation, "reported"); + obj.Recorder.finish( ... + diagnosticOperation, "reported", exception); + end + + function finishProcessing(obj) + obj.Processing = false; + if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + obj.Adapter.endBusy(); + end + end + + function updateStartup(obj, message) + if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + obj.Adapter.startupUpdate(message); + end + end + + function markDocumentChanged(obj) + if isempty(obj.Documents) + return + end + obj.Documents.markDirty(); + obj.refreshWindowTitle(); + end + + function refreshWindowTitle(obj) + if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + obj.Adapter.setWindowTitle(obj.formattedWindowTitle()); + end + end + + function title = formattedWindowTitle(obj) + title = obj.Application.Title + " v" + ... + obj.Application.AppVersion + " (" + ... + obj.Application.Updated + ")"; + if ~isempty(obj.Documents) && obj.Documents.Metadata.dirty + title = title + " *"; + end + end + + function assertOpen(obj) + if obj.StartupFailed + error("labkit:app:runtime:StartupFailed", ... + "Application runtime did not complete startup."); + end + if obj.Closed + error("labkit:app:runtime:InvariantFailure", ... + "Application runtime is closed."); + end + end + + function assertProjectStore(obj) + if isempty(obj.Documents) + error("labkit:app:runtime:InvariantFailure", ... + "Application has no project contract."); + end + end + end +end diff --git a/+labkit/+app/+internal/RuntimePresentation.m b/+labkit/+app/+internal/RuntimePresentation.m new file mode 100644 index 000000000..38ee24a9b --- /dev/null +++ b/+labkit/+app/+internal/RuntimePresentation.m @@ -0,0 +1,126 @@ +% Derive the complete default Snapshot from compiled layout and App state. +% Expected caller: RuntimeKernel before overlaying the App-owned Snapshot. +% Inputs are a compiled platform plan, scalar application state, portable +% source store, and status log. The method returns a new immutable Snapshot +% and does not create or mutate native MATLAB graphics. +classdef (Sealed, Hidden) RuntimePresentation + methods (Static) + function view = fromState( ... + plan, state, sourcePathsForRole, statusLog) + view = labkit.app.view.Snapshot(); + for k = 1:numel(plan.Nodes) + node = plan.Nodes(k); + if isempty(node.Capabilities) + continue; + end + config = node.Configuration; + switch node.Kind + case "button" + view = view.enabled(node.Id, config.Enabled); + case "field" + value = ... + labkit.app.internal.RuntimePresentation.neutralValue( ... + config.Value, config.Kind, config.Choices); + if strlength(config.Bind) > 0 + value = labkit.app.internal.RuntimeStatePath.read( ... + state, config.Bind); + end + view = view.value(node.Id, value); + if ~isempty(config.Choices) + view = view.choices(node.Id, config.Choices); + end + if ~isempty(config.Limits) + view = view.limits(node.Id, config.Limits); + end + view = view.enabled(node.Id, config.Enabled); + case "rangeField" + limits = config.Limits; + if isempty(limits) + limits = [0 1]; + end + value = config.Value; + if isempty(value) + value = limits; + end + if strlength(config.Bind) > 0 + value = labkit.app.internal.RuntimeStatePath.read( ... + state, config.Bind); + end + view = view.value(node.Id, value); + view = view.limits(node.Id, limits); + view = view.enabled(node.Id, config.Enabled); + case "slider" + value = config.Value; + if strlength(config.Bind) > 0 + value = labkit.app.internal.RuntimeStatePath.read( ... + state, config.Bind); + end + view = view.value(node.Id, value); + if ~isempty(config.Limits) + view = view.limits(node.Id, config.Limits); + end + view = view.enabled(node.Id, config.Enabled); + case "fileList" + paths = strings(0, 1); + if strlength(config.Bind) > 0 + sourceRecords = ... + labkit.app.internal.RuntimeStatePath.read( ... + state, config.Bind); + paths = sourcePathsForRole( ... + sourceRecords, config.SourceRole); + end + view = view.filePaths(node.Id, paths); + if strlength(config.SelectionBind) > 0 + selection = ... + labkit.app.internal.RuntimeStatePath.read( ... + state, config.SelectionBind); + view = view.listSelection(node.Id, selection); + end + case "plotArea" + view = view.visible(node.Id, true); + case "dataTable" + view = view.tableData(node.Id, cell(0, 0), ... + Columns=config.Columns, ... + RowNames=config.RowNames, ... + ColumnEditable=config.ColumnEditable); + case "logPanel" + view = view.text(node.Id, join(statusLog, newline)); + case "statusPanel" + status = config.Text; + if isempty(status) && ~isempty(statusLog) + status = statusLog(end); + end + view = view.text(node.Id, join(status, newline)); + case "workspacePage" + view = view.workspacePage(node.Id); + otherwise + error("labkit:app:runtime:InvariantFailure", ... + "No default presentation for Layout kind %s.", ... + node.Kind); + end + end + end + end + + methods (Static, Access = private) + function value = neutralValue(value, kind, choices) + if ~isempty(value) + return; + end + switch kind + case "numeric" + value = 0; + case "choice" + if isempty(choices) + value = ""; + else + value = choices(1); + end + case "logical" + value = false; + otherwise + value = ""; + end + end + end +end diff --git a/+labkit/+app/+internal/RuntimeStatePath.m b/+labkit/+app/+internal/RuntimeStatePath.m new file mode 100644 index 000000000..b02b756a6 --- /dev/null +++ b/+labkit/+app/+internal/RuntimeStatePath.m @@ -0,0 +1,44 @@ +% Read and update strict project/session field paths for RuntimeKernel. +% Expected caller: RuntimeKernel transaction and file-list operations. +% Inputs and outputs are scalar application-state structs. Updates rebuild +% nested structs without mutating the supplied value and raise stable +% contract errors when a declared path is unavailable. +classdef (Sealed, Hidden) RuntimeStatePath + methods (Static) + function value = read(state, path) + parts = split(path, "."); + value = state; + for k = 1:numel(parts) + name = char(parts(k)); + if ~isstruct(value) || ~isscalar(value) || ... + ~isfield(value, name) + error("labkit:app:contract:UnknownReference", ... + "Bound state path is unavailable: %s.", path); + end + value = value.(name); + end + end + + function state = write(state, path, value) + parts = split(path, "."); + state = labkit.app.internal.RuntimeStatePath.assign( ... + state, parts, value, path); + end + end + + methods (Static, Access = private) + function owner = assign(owner, parts, value, path) + name = char(parts(1)); + if ~isstruct(owner) || ~isscalar(owner) || ~isfield(owner, name) + error("labkit:app:contract:UnknownReference", ... + "Bound state path is unavailable: %s.", path); + end + if numel(parts) == 1 + owner.(name) = value; + return; + end + owner.(name) = labkit.app.internal.RuntimeStatePath.assign( ... + owner.(name), parts(2:end), value, path); + end + end +end diff --git a/+labkit/+app/+internal/SignalBinding.m b/+labkit/+app/+internal/SignalBinding.m new file mode 100644 index 000000000..eac0791a4 --- /dev/null +++ b/+labkit/+app/+internal/SignalBinding.m @@ -0,0 +1,75 @@ +classdef (Sealed, Hidden) SignalBinding + %SIGNALBINDING Private compiled link from one layout signal to a callback. + % + % Expected callers: LayoutNode constructors and RuntimeKernel. The value + % derives its stable diagnostic id from the owning target and signal, + % validates the one callback signature owned by that signal, and carries + % no App-authored registration name. Side effects are none. + + properties (SetAccess = immutable) + Id (1, 1) string + Signal (1, 1) string + AcceptsPayload (1, 1) logical + PayloadClass (1, 1) string + UpdateState + end + + methods + function obj = SignalBinding(target, signal, updateState) + target = scalarId(target, "target"); + signal = scalarId(signal, "signal"); + [inputCount, payloadClass] = signalContract(signal); + if ~isa(updateState, "function_handle") || ... + ~isscalar(updateState) + error("labkit:app:contract:InvalidValue", ... + "Layout %s callback must be a function handle.", signal); + end + if nargin(updateState) ~= inputCount || ... + nargout(updateState) ~= 1 + error("labkit:app:contract:CallbackRoleMismatch", ... + "Layout %s callback requires %d inputs and one output.", ... + signal, inputCount); + end + obj.Id = target + "__" + signal; + obj.Signal = signal; + obj.AcceptsPayload = inputCount == 3; + obj.PayloadClass = payloadClass; + obj.UpdateState = updateState; + end + end +end + +function [inputCount, payloadClass] = signalContract(signal) +payloadClass = ""; +switch signal + case {"pressed", "started"} + inputCount = 2; + case {"valueChanged", "pageChanged", "interactionChanged", ... + "backgroundPressed", "scrolled"} + inputCount = 3; + case "cellEdited" + inputCount = 3; + payloadClass = "labkit.app.event.TableCellEdit"; + case "cellSelectionChanged" + inputCount = 3; + payloadClass = "labkit.app.event.TableCellSelection"; + case "listSelectionChanged" + inputCount = 3; + payloadClass = "labkit.app.event.ListSelection"; + otherwise + error("labkit:app:runtime:InvariantFailure", ... + "Internal layout signal is unsupported: %s.", signal); +end +end + +function value = scalarId(value, label) +if ~(ischar(value) || (isstring(value) && isscalar(value))) + error("labkit:app:contract:InvalidValue", ... + "Layout signal %s must be scalar text.", label); +end +value = string(value); +if strlength(value) == 0 || ~isvarname(char(value)) + error("labkit:app:contract:InvalidValue", ... + "Layout signal %s must be a MATLAB identifier.", label); +end +end diff --git a/+labkit/+app/+internal/private/FigureInteractionHub.m b/+labkit/+app/+internal/private/FigureInteractionHub.m new file mode 100644 index 000000000..1ad0008e0 --- /dev/null +++ b/+labkit/+app/+internal/private/FigureInteractionHub.m @@ -0,0 +1,536 @@ +% Private App runtime helper. The MATLAB adapter supplies a figure, semantic +% axes targets, and one typed-dispatch bridge. This hub owns figure pointer, +% wheel, drag, target-listener, and editor-session lifetimes. +function hub = FigureInteractionHub( ... + fig, suppliedTargets, dispatchEvent, cleanupTarget) + state = struct(); + state.targets = normalizeTargets(suppliedTargets); + state.sessions = emptySessions(); + state.activeGroup = ""; + state.drag = emptyDrag(); + state.suppressed = false; + state.deleted = false; + state.prior = priorCallbacks(fig); + state.callbacks = struct( ... + "down", @onPointerDown, ... + "motion", @onPointerMotion, ... + "up", @onPointerUp, ... + "scroll", @onScroll); + installTargetListeners(); + installCallbacks(); + + hub = struct( ... + "adapter", @adapter, ... + "dispatch", @dispatchSemanticEvent, ... + "point", @targetPoint, ... + "setSuppressed", @setSuppressed, ... + "targetIds", @targetIds, ... + "activeGroup", @activeGroup, ... + "isDragging", @isDragging, ... + "routeWheel", @routeWheel, ... + "delete", @deleteHub); + + function point = targetPoint(targetId) + ax = targetAxes(requireTarget(targetId)); + current = double(ax.CurrentPoint); + point = current(1, 1:2); + end + + function runtime = adapter(targetId, groupId) + targetId = requireTarget(targetId); + if nargin < 2 || strlength(string(groupId)) == 0 + groupId = "interaction:" + targetId; + end + groupId = string(groupId); + runtime = struct( ... + "axes", @() targetAxes(targetId), ... + "figure", @() fig, ... + "createSession", @(options) createSession( ... + targetId, groupId, options)); + end + + function session = createSession(targetId, groupId, options) + if nargin < 3 || isempty(options) + options = struct(); + end + token = nextToken(); + entry = struct( ... + "token", token, ... + "target", targetId, ... + "group", groupId, ... + "name", string(optionValue(options, 'name', 'interaction')), ... + "onPointerDown", optionValue(options, 'onPointerDown', []), ... + "onScroll", optionValue(options, 'onScroll', []), ... + "installScrollWheel", logical(optionValue( ... + options, 'installScrollWheel', true)), ... + "background", gobjects(1, 0), ... + "graphics", gobjects(1, 0)); + state.sessions(end + 1) = entry; + session = struct( ... + "activate", @activate, ... + "activateIfAvailable", @activateIfAvailable, ... + "canActivate", @canActivate, ... + "deactivate", @deactivate, ... + "isActive", @isActive, ... + "setBackground", @(value) setHandles('background', value), ... + "setGraphics", @(value) setHandles('graphics', value), ... + "captureDrag", @captureDrag, ... + "releaseDrag", @releaseDrag, ... + "refresh", @refresh, ... + "delete", @deleteSession); + + function activate() + assertTargetValid(targetId); + state.activeGroup = groupId; + end + + function activated = activateIfAvailable() + activated = canActivate(); + if activated + activate(); + end + end + + function tf = canActivate() + tf = strlength(state.activeGroup) == 0 || ... + state.activeGroup == groupId; + end + + function deactivate() + releaseDrag(); + if state.activeGroup == groupId + state.activeGroup = ""; + end + end + + function tf = isActive() + tf = state.activeGroup == groupId && sessionExists(token); + end + + function setHandles(field, value) + index = sessionIndex(token); + if isempty(index) + return; + end + state.sessions(index).(field) = normalizeHandles(value); + end + + function captureDrag(motionFcn, releaseFcn) + if ~isActive() + return; + end + state.drag = struct("token", token, "motion", motionFcn, ... + "release", releaseFcn); + end + + function releaseDrag() + if isequal(state.drag.token, token) + state.drag = emptyDrag(); + end + end + + function refresh() + assertTargetValid(targetId); + end + + function deleteSession() + releaseDrag(); + index = sessionIndex(token); + if isempty(index) + return; + end + state.sessions(index) = []; + if state.activeGroup == groupId + state.activeGroup = ""; + end + end + end + + function onPointerDown(src, event) + hit = pointerHitObject(src, event); + target = targetUnderPointer(hit); + entry = activeSessionForTarget(target); + if isempty(entry) + invokeCallback(state.prior.down, src, event); + return; + end + invokeCallback(entry.onPointerDown, hit, event); + end + + function hit = pointerHitObject(fallback, event) + hit = []; + if isstruct(event) && isfield(event, 'HitObject') && ... + isValidHandle(event.HitObject) + hit = event.HitObject; + elseif isobject(event) && isprop(event, 'HitObject') && ... + isValidHandle(event.HitObject) + hit = event.HitObject; + end + if isempty(hit) + try + hit = hittest(fig); + catch + end + end + if isempty(hit) + hit = fallback; + end + end + + function onPointerMotion(src, event) + if isempty(state.drag.token) + invokeCallback(state.prior.motion, src, event); + return; + end + try + invokeCallback(state.drag.motion, src, event); + catch ME + state.drag = emptyDrag(); + rethrow(ME); + end + end + + function onPointerUp(src, event) + if isempty(state.drag.token) + invokeCallback(state.prior.up, src, event); + return; + end + release = state.drag.release; + state.drag = emptyDrag(); + invokeCallback(release, src, event); + end + + function onScroll(src, event) + target = targetUnderPointer(); + if strlength(target) == 0 + invokeCallback(state.prior.scroll, src, event); + return; + end + routeWheel(target, event, src); + end + + function routeWheel(targetId, event, src) + if nargin < 3 + src = fig; + end + if strlength(string(targetId)) == 0 + invokeCallback(state.prior.scroll, src, event); + return; + end + targetId = requireTarget(targetId); + entry = activeSessionForTarget(targetId); + if ~isempty(entry) && entry.installScrollWheel && ... + ~isempty(entry.onScroll) + invokeCallback(entry.onScroll, src, event); + return; + end + ax = targetAxes(targetId); + count = scrollCount(event); + if count == 0 + return; + end + point = wheelPoint(ax, event); + zoomAxesAtPoint(ax, point, count, ... + "ZoomAxes", scrollZoomAxes(ax)); + end + + function dispatchSemanticEvent(id, ~, value, ~) + if state.suppressed || isempty(dispatchEvent) + return; + end + dispatchEvent(string(id), value); + end + + function setSuppressed(value) + state.suppressed = logical(value); + end + + function ids = targetIds() + ids = [state.targets.id]; + end + + function value = activeGroup() + value = state.activeGroup; + end + + function tf = isDragging() + tf = ~isempty(state.drag.token); + end + + function deleteHub() + if state.deleted + return; + end + state.deleted = true; + deleteTargetListeners(); + state.sessions = emptySessions(); + state.drag = emptyDrag(); + restoreCallbacks(); + end + + function installTargetListeners() + for index = 1:numel(state.targets) + id = state.targets(index).id; + state.targets(index).listener = addlistener( ... + state.targets(index).axes, 'ObjectBeingDestroyed', ... + @(~, ~) onTargetDeleted(id)); + end + end + + function onTargetDeleted(id) + if state.deleted + return; + end + matches = string({state.sessions.target}) == id; + removedTokens = [state.sessions(matches).token]; + removedGroups = [state.sessions(matches).group]; + state.sessions(matches) = []; + if ~isempty(state.drag.token) && any(removedTokens == state.drag.token) + state.drag = emptyDrag(); + end + if ~isempty(removedGroups) && ... + any(string(removedGroups) == state.activeGroup) + state.activeGroup = ""; + end + cleanupTarget(id); + end + + function deleteTargetListeners() + for index = 1:numel(state.targets) + listener = state.targets(index).listener; + if ~isempty(listener) && isvalid(listener) + delete(listener); + end + end + end + + function installCallbacks() + fig.WindowButtonDownFcn = state.callbacks.down; + fig.WindowButtonMotionFcn = state.callbacks.motion; + fig.WindowButtonUpFcn = state.callbacks.up; + fig.WindowScrollWheelFcn = state.callbacks.scroll; + end + + function restoreCallbacks() + if ~isValidHandle(fig) + return; + end + restoreIfOwned('WindowButtonDownFcn', state.callbacks.down, state.prior.down); + restoreIfOwned('WindowButtonMotionFcn', state.callbacks.motion, state.prior.motion); + restoreIfOwned('WindowButtonUpFcn', state.callbacks.up, state.prior.up); + restoreIfOwned('WindowScrollWheelFcn', state.callbacks.scroll, state.prior.scroll); + end + + function restoreIfOwned(property, owned, prior) + if isequal(fig.(property), owned) + fig.(property) = prior; + end + end + + function target = targetUnderPointer(hit) + target = ""; + if ~isValidHandle(fig) + return; + end + if nargin < 1 || isempty(hit) + hit = []; + try + hit = hittest(fig); + catch + end + end + for k = 1:numel(state.targets) + if handleDescendsFrom(hit, state.targets(k).axes) + target = state.targets(k).id; + return; + end + end + try + point = fig.CurrentPoint; + catch + return; + end + for k = 1:numel(state.targets) + ax = state.targets(k).axes; + if ~isValidHandle(ax) + continue; + end + position = getpixelposition(ax, true); + if point(1) >= position(1) && point(1) <= position(1) + position(3) && ... + point(2) >= position(2) && point(2) <= position(2) + position(4) + target = state.targets(k).id; + return; + end + end + end + + function entry = activeSessionForTarget(target) + entry = []; + if strlength(target) == 0 || strlength(state.activeGroup) == 0 + return; + end + index = find(string({state.sessions.target}) == target & ... + string({state.sessions.group}) == state.activeGroup, 1, 'last'); + if ~isempty(index) + entry = state.sessions(index); + end + end + + function id = requireTarget(id) + id = string(id); + if ~isscalar(id) || ~any(string({state.targets.id}) == id) + error('labkit:app:runtime:UnknownInteractionTarget', ... + 'Unknown interaction target "%s".', id); + end + end + + function ax = targetAxes(id) + index = find(string({state.targets.id}) == string(id), 1, 'first'); + ax = state.targets(index).axes; + end + + function assertTargetValid(id) + if ~isValidHandle(targetAxes(id)) + error('labkit:app:runtime:InvalidInteractionTarget', ... + 'Interaction target "%s" no longer exists.', id); + end + end + + function index = sessionIndex(token) + index = find([state.sessions.token] == token, 1, 'first'); + end + + function tf = sessionExists(token) + tf = ~isempty(sessionIndex(token)); + end +end + +function targets = normalizeTargets(values) + if ~isstruct(values) || ... + ~isfield(values, "id") || ~isfield(values, "axes") + error("labkit:app:runtime:InvariantFailure", ... + "Interaction targets must provide semantic ids and axes."); + end + targets = struct("id", {}, "axes", {}, "listener", {}); + for k = 1:numel(values) + targets(end + 1) = struct( ... + "id", string(values(k).id), ... + "axes", values(k).axes, "listener", []); + end +end + +function callbacks = priorCallbacks(fig) + callbacks = struct( ... + "down", fig.WindowButtonDownFcn, ... + "motion", fig.WindowButtonMotionFcn, ... + "up", fig.WindowButtonUpFcn, ... + "scroll", fig.WindowScrollWheelFcn); + key = 'labkitPreviewScrollNavigation'; + if isappdata(fig, key) + navigation = getappdata(fig, key); + if isfield(navigation, 'fallbackScrollFcn') + callbacks.scroll = navigation.fallbackScrollFcn; + end + end +end + +function sessions = emptySessions() + sessions = struct("token", {}, "target", {}, "group", {}, ... + "name", {}, "onPointerDown", {}, "onScroll", {}, ... + "installScrollWheel", {}, "background", {}, "graphics", {}); +end + +function drag = emptyDrag() + drag = struct("token", [], "motion", [], "release", []); +end + +function token = nextToken() + persistent value + if isempty(value) + value = uint64(0); + end + value = value + 1; + token = value; +end + +function value = optionValue(options, name, defaultValue) + value = defaultValue; + if isstruct(options) && isfield(options, name) + value = options.(name); + end +end + +function handles = normalizeHandles(value) + handles = gobjects(1, 0); + if isempty(value) + return; + end + if iscell(value) + for k = 1:numel(value) + handles = [handles normalizeHandles(value{k})]; + end + return; + end + value = value(:).'; + handles = value(arrayfun(@isValidHandle, value)); +end + +function tf = handleDescendsFrom(handle, ancestorHandle) + tf = false; + while isValidHandle(handle) + if isequal(handle, ancestorHandle) + tf = true; + return; + end + if ~isprop(handle, 'Parent') + return; + end + handle = handle.Parent; + end +end + +function count = scrollCount(event) + count = 0; + if isstruct(event) && isfield(event, 'VerticalScrollCount') + count = event.VerticalScrollCount; + elseif isobject(event) && isprop(event, 'VerticalScrollCount') + count = event.VerticalScrollCount; + end + if ~isnumeric(count) || ~isscalar(count) || ~isfinite(count) + count = 0; + end +end + +function point = wheelPoint(ax, event) + if isstruct(event) && isfield(event, 'Point') && ... + isnumeric(event.Point) && numel(event.Point) >= 2 + point = double(event.Point(1, 1:2)); + return; + end + current = ax.CurrentPoint; + point = current(1, 1:2); +end + +function axesMode = scrollZoomAxes(ax) + axesMode = "xy"; + if isappdata(ax, 'labkitPreviewScrollZoomAxes') + axesMode = string(getappdata(ax, 'labkitPreviewScrollZoomAxes')); + end +end + +function invokeCallback(callback, src, event) + if isempty(callback) + return; + end + if isa(callback, 'function_handle') + callback(src, event); + elseif iscell(callback) + feval(callback{1}, src, event, callback{2:end}); + elseif ischar(callback) || isstring(callback) + feval(char(callback), src, event); + end +end + +function tf = isValidHandle(value) + tf = ~isempty(value) && isscalar(value) && isgraphics(value); +end diff --git a/+labkit/+app/+internal/private/InteractionController.m b/+labkit/+app/+internal/private/InteractionController.m new file mode 100644 index 000000000..df4fbc0a7 --- /dev/null +++ b/+labkit/+app/+internal/private/InteractionController.m @@ -0,0 +1,96 @@ +% Private App SDK native-adapter implementation for InteractionController; called only by the internal runtime. +function controller = InteractionController(figureHandle, targets, dispatch) +% Private MATLAB adapter child that owns managed interaction editors. +resources = containers.Map("KeyType", "char", "ValueType", "any"); +hub = FigureInteractionHub(figureHandle, targets, ... + @dispatchEvent, @(id) removeTargetResources(id)); +controller = struct("reconcile", @reconcile, "delete", @deleteController); + + function reconcile(declarations, operations) + specs = compileSpecs(declarations, operations); + actionIds = actionIdsFor(specs); + reconcileInteractions(hub, resources, specs, actionIds); + end + + function dispatchEvent(id, value) + parts = split(id, "__"); + dispatch(parts(1), parts(2), value); + end + + function removeTargetResources(target) + ids = string(keys(resources)); + for id = ids + controlled = resources(char(id)); + if any(controlled.spec.Targets == string(target)) + controlled.delete(); + remove(resources, char(id)); + end + end + end + + function deleteController() + ids = string(keys(resources)); + for id = ids + controlled = resources(char(id)); + controlled.delete(); + remove(resources, char(id)); + end + hub.delete(); + end +end + +function specs = compileSpecs(declarations, operations) +specs = struct(); +for k = 1:numel(operations) + operation = operations{k}; + id = operation.Target; + match = find(cellfun(@(value) value.Id == id, declarations), 1); + if isempty(match) || ~operation.Value.Enabled + continue + end + declaration = declarations{match}; + changed = declaration.signal("interactionChanged").Id; + background = signalId(declaration, "backgroundPressed"); + scrolled = signalId(declaration, "scrolled"); + specs.(char(id)) = struct( ... + "Kind", editorKind(declaration.Kind), ... + "Targets", {declaration.Targets}, ... + "Value", {operation.Value.Value}, ... + "Event", changed, ... + "BackgroundEvent", background, ... + "ScrollEvent", scrolled, ... + "ChangePolicy", "commit", ... + "ImageSize", operation.Value.ImageSize, ... + "Options", declaration.Options, ... + "Instruction", declaration.Instruction); +end +end + +function ids = actionIdsFor(specs) +ids = strings(1, 0); +names = string(fieldnames(specs)); +for name = names.' + spec = specs.(char(name)); + ids = [ids, string(spec.Event), ... + string(spec.BackgroundEvent), string(spec.ScrollEvent)]; +end +ids = unique(ids(strlength(ids) > 0), "stable"); +end + +function id = signalId(declaration, name) +binding = declaration.signal(name); +id = ""; +if ~isempty(binding) + id = binding.Id; +end +end + +function kind = editorKind(kind) +% The private editors retain concise implementation names. +switch kind + case "anchorPath" + kind = "anchors"; + case "scaleReference" + kind = "scaleBarReference"; +end +end diff --git a/+labkit/+ui/+runtime/private/addOrInsertAnchor.m b/+labkit/+app/+internal/private/addOrInsertAnchor.m similarity index 97% rename from +labkit/+ui/+runtime/private/addOrInsertAnchor.m rename to +labkit/+app/+internal/private/addOrInsertAnchor.m index 4540241e9..2d5e01c35 100644 --- a/+labkit/+ui/+runtime/private/addOrInsertAnchor.m +++ b/+labkit/+app/+internal/private/addOrInsertAnchor.m @@ -111,7 +111,7 @@ return; end - [curve, owners] = labkit.ui.interaction.anchorPath(points, imageSize, ... + [curve, owners] = labkit.app.interaction.interpolateAnchorPath(points, imageSize, ... "Style", string(curveStyle), "Closed", closed); if size(curve, 1) < 2 return; @@ -143,7 +143,7 @@ adjacentOwner = n - 1; end - [curve, owners] = labkit.ui.interaction.anchorPath(points, imageSize, ... + [curve, owners] = labkit.app.interaction.interpolateAnchorPath(points, imageSize, ... "Style", string(curveStyle), "Closed", false); if size(curve, 1) < 2 return; diff --git a/+labkit/+app/+internal/private/applyTextFit.m b/+labkit/+app/+internal/private/applyTextFit.m new file mode 100644 index 000000000..66f3246a3 --- /dev/null +++ b/+labkit/+app/+internal/private/applyTextFit.m @@ -0,0 +1,58 @@ +% Private App SDK native-adapter implementation for applyTextFit; called only by the internal runtime. +function applyTextFit(handle, varargin) +% Private native adapter helper. Keeps complete reader-facing text available +% when a component is narrower than its preferred size. +options = struct( ... + "CharsPerStep", 28, "MaxShrinkSteps", 3, "MinFontSize", 10); +for k = 1:2:numel(varargin) + name = string(varargin{k}); + if k + 1 <= numel(varargin) && isfield(options, name) + options.(name) = varargin{k + 1}; + end +end +text = componentText(handle); +if strlength(text) == 0 + return +end +if isprop(handle, "WordWrap") + handle.WordWrap = "on"; +end +if isprop(handle, "Tooltip") && strlength(string(handle.Tooltip)) == 0 + handle.Tooltip = char(text); +end +if ~isprop(handle, "FontSize") + return +end +key = "labkitAppTextFitBaseFontSize"; +if isappdata(handle, key) + baseFontSize = getappdata(handle, key); +else + baseFontSize = handle.FontSize; + setappdata(handle, key, baseFontSize); +end +longest = max(strlength(splitlines(text))); +steps = max(0, ceil(double(longest) / options.CharsPerStep) - 1); +steps = min(options.MaxShrinkSteps, steps); +handle.FontSize = max(options.MinFontSize, baseFontSize - steps); +end + +function text = componentText(handle) +text = ""; +if isprop(handle, "Text") + text = normalizedComponentText(handle.Text); +elseif isprop(handle, "Value") + text = normalizedComponentText(handle.Value); +end +end + +function text = normalizedComponentText(value) +if ischar(value) + if isrow(value) || isempty(value) + text = string(value); + else + text = join(string(value), newline); + end +else + text = join(string(value(:)), newline); +end +end diff --git a/+labkit/+ui/+runtime/private/createAnchorEditor.m b/+labkit/+app/+internal/private/createAnchorEditor.m similarity index 98% rename from +labkit/+ui/+runtime/private/createAnchorEditor.m rename to +labkit/+app/+internal/private/createAnchorEditor.m index a00d791f1..7a554b5e6 100644 --- a/+labkit/+ui/+runtime/private/createAnchorEditor.m +++ b/+labkit/+app/+internal/private/createAnchorEditor.m @@ -7,7 +7,7 @@ % editor.start(points); % % Inputs: -% runtime - Runtime V2 interaction-hub adapter. +% runtime - App runtime interaction-hub adapter. % imageSize - [height width] or image size used for zoom/limit clamping. % opts - optional struct. % @@ -51,7 +51,7 @@ isa(runtime.axes, 'function_handle') && ... isfield(runtime, 'createSession') && ... isa(runtime.createSession, 'function_handle'), ... - 'First input must be a Runtime V2 interaction adapter.'); + 'First input must be a App runtime interaction adapter.'); state.runtime = runtime; state.ax = runtime.axes(); @@ -210,7 +210,7 @@ function refresh() curve = zeros(0, 2); return; end - curve = labkit.ui.interaction.anchorPath(state.points, state.imageSize, ... + curve = labkit.app.interaction.interpolateAnchorPath(state.points, state.imageSize, ... "Style", state.style, "Closed", logical(state.closed)); end @@ -224,7 +224,7 @@ function deleteEditor() if ~isempty(state.anchorLine) && isvalid(state.anchorLine) delete(state.anchorLine); end - labkit.ui.interaction.enablePopout(state.ax); + labkit.app.plot.enablePopout(state.ax); end function onAxesClicked(~, ~) @@ -358,7 +358,7 @@ function ensureGraphics() created = true; end if created - labkit.ui.interaction.enablePopout(state.ax); + labkit.app.plot.enablePopout(state.ax); end end @@ -551,5 +551,5 @@ function zoomImageAxesAtPoint(ax, x, y, scrollCount, imageSize) end function key = anchorEditorAppdataKey() - key = 'labkit_ui_activeAnchorEditor'; + key = 'labkitAppActiveAnchorEditor'; end diff --git a/+labkit/+ui/+runtime/private/createPointSlotsEditor.m b/+labkit/+app/+internal/private/createPointSlotsEditor.m similarity index 95% rename from +labkit/+ui/+runtime/private/createPointSlotsEditor.m rename to +labkit/+app/+internal/private/createPointSlotsEditor.m index 23fd360c9..874ed02f3 100644 --- a/+labkit/+ui/+runtime/private/createPointSlotsEditor.m +++ b/+labkit/+app/+internal/private/createPointSlotsEditor.m @@ -1,5 +1,5 @@ -% Private Runtime V2 controlled interaction. Expected caller: -% reconcileV2Interactions. It owns fixed point-slot placement, single/set +% Private App runtime controlled interaction. Expected caller: +% reconcileInteractions. It owns fixed point-slot placement, single/set % dragging, graphics, figure callbacks, and cleanup outside app state. function editor = createPointSlotsEditor(runtime, imageSize, opts, onChanged) ax = runtime.axes(); @@ -198,12 +198,12 @@ function deleteEditor() function normalized = normalizeValue(input) if ~isstruct(input) || ~isscalar(input) || ~isfield(input, 'points') - error('labkit:ui:runtime:InvalidPointSlotsValue', ... + error('labkit:app:runtime:InvalidPointSlotsValue', ... 'pointSlots Value must contain a fixed Nx2 points array.'); end points = double(input.points); if size(points, 2) ~= 2 || isempty(points) - error('labkit:ui:runtime:InvalidPointSlotsValue', ... + error('labkit:app:runtime:InvalidPointSlotsValue', ... 'pointSlots points must be a nonempty Nx2 array.'); end selectedIndex = optionValue(input, 'selectedIndex', 1); @@ -221,7 +221,7 @@ function deleteEditor() imageSize = double(value(:).'); if numel(imageSize) < 2 || any(~isfinite(imageSize(1:2))) || ... any(imageSize(1:2) < 1) - error('labkit:ui:runtime:InvalidPointSlotsImageSize', ... + error('labkit:app:runtime:InvalidPointSlotsImageSize', ... 'pointSlots requires a finite positive image size.'); end imageSize = imageSize(1:2); diff --git a/+labkit/+ui/+runtime/private/createRectangleEditor.m b/+labkit/+app/+internal/private/createRectangleEditor.m similarity index 98% rename from +labkit/+ui/+runtime/private/createRectangleEditor.m rename to +labkit/+app/+internal/private/createRectangleEditor.m index 725e75322..649cc285e 100644 --- a/+labkit/+ui/+runtime/private/createRectangleEditor.m +++ b/+labkit/+app/+internal/private/createRectangleEditor.m @@ -6,7 +6,7 @@ % [20 20 80 60], struct('onMoved', @storePosition)); % % Inputs: -% runtime - Runtime V2 interaction-hub adapter. +% runtime - App runtime interaction-hub adapter. % imageSize - [height width] or full image size used to constrain the ROI. % position - [x y width height] rectangle in image coordinates. % opts - optional struct. @@ -39,7 +39,7 @@ isa(runtime.axes, 'function_handle') && ... isfield(runtime, 'createSession') && ... isa(runtime.createSession, 'function_handle'), ... - 'First input must be a Runtime V2 interaction adapter.'); + 'First input must be a App runtime interaction adapter.'); state = struct(); state.ax = runtime.axes(); diff --git a/+labkit/+app/+internal/private/installColumnResize.m b/+labkit/+app/+internal/private/installColumnResize.m new file mode 100644 index 000000000..fc4664b48 --- /dev/null +++ b/+labkit/+app/+internal/private/installColumnResize.m @@ -0,0 +1,89 @@ +% Private App SDK native-adapter implementation for installColumnResize; called only by the internal runtime. +function separator = installColumnResize( ... + figureHandle, grid, leftColumn, separatorColumn, varargin) +% Private workbench splitter. Expected caller: MatlabPlatformAdapter. +% Inputs are runtime-owned native handles and bounded width options. The +% separator temporarily owns figure pointer callbacks only while dragging, +% then restores the interaction hub callbacks exactly. + +policy = nativeLayoutPolicy(); +options = struct("InitialWidth", policy.ControlPaneWidth, ... + "MinimumWidth", policy.MinimumControlPaneWidth, ... + "WorkspaceReserve", policy.MinimumWorkspaceWidth, ... + "SeparatorWidth", policy.SplitterThickness); +if ~isempty(varargin) + supplied = varargin{1}; + names = string(fieldnames(supplied)); + for name = names.' + options.(name) = supplied.(name); + end +end +widths = grid.ColumnWidth; +widths{leftColumn} = options.InitialWidth; +widths{separatorColumn} = options.SeparatorWidth; +grid.ColumnWidth = widths; +separator = uipanel(grid, BorderType="none", ... + BackgroundColor=[0.72 0.72 0.72], ... + Tag="labkitAppColumnResize"); +separator.Layout.Row = 1; +separator.Layout.Column = separatorColumn; +if isprop(separator, "Tooltip") + separator.Tooltip = "Drag to resize the task panel"; +end +if isprop(separator, "HitTest") + separator.HitTest = "on"; +end +if isprop(separator, "PickableParts") + separator.PickableParts = "all"; +end +separator.ButtonDownFcn = @beginDrag; + +drag = struct("Active", false, "Motion", [], "Up", [], "Down", [], ... + "Key", [], "Pointer", "arrow"); + + function beginDrag(~, ~) + if drag.Active || ~isvalid(figureHandle) + return + end + drag.Active = true; + drag.Motion = figureHandle.WindowButtonMotionFcn; + drag.Up = figureHandle.WindowButtonUpFcn; + drag.Down = figureHandle.WindowButtonDownFcn; + drag.Key = figureHandle.WindowKeyPressFcn; + drag.Pointer = figureHandle.Pointer; + figureHandle.Pointer = "left"; + figureHandle.WindowButtonMotionFcn = @resize; + figureHandle.WindowButtonUpFcn = @(~, ~) finishDrag(); + figureHandle.WindowButtonDownFcn = @(~, ~) finishDrag(); + figureHandle.WindowKeyPressFcn = @cancelOnEscape; + end + + function resize(~, ~) + point = figureHandle.CurrentPoint; + width = max(options.MinimumWidth, point(1) - grid.Position(1)); + maximum = max(options.MinimumWidth, ... + figureHandle.Position(3) - options.WorkspaceReserve); + widths = grid.ColumnWidth; + widths{leftColumn} = min(width, maximum); + widths{separatorColumn} = options.SeparatorWidth; + grid.ColumnWidth = widths; + end + + function cancelOnEscape(~, event) + if string(event.Key) == "escape" + finishDrag(); + end + end + + function finishDrag() + if ~drag.Active || ~isvalid(figureHandle) + return + end + figureHandle.WindowButtonMotionFcn = drag.Motion; + figureHandle.WindowButtonUpFcn = drag.Up; + figureHandle.WindowButtonDownFcn = drag.Down; + figureHandle.WindowKeyPressFcn = drag.Key; + figureHandle.Pointer = drag.Pointer; + drag.Active = false; + end +end diff --git a/+labkit/+app/+internal/private/installRowResize.m b/+labkit/+app/+internal/private/installRowResize.m new file mode 100644 index 000000000..873f46e8f --- /dev/null +++ b/+labkit/+app/+internal/private/installRowResize.m @@ -0,0 +1,103 @@ +% Private App SDK native-adapter implementation for installRowResize; called only by the internal runtime. +function separator = installRowResize( ... + figureHandle, grid, contentRow, separatorRow, varargin) +% Private control-panel splitter. Expected caller: MatlabPlatformAdapter. +% Inputs are runtime-owned native handles and bounded height options. The +% separator temporarily owns figure pointer callbacks only while dragging, +% then restores interaction callbacks exactly. + +policy = nativeLayoutPolicy(); +options = struct("MinimumHeight", policy.MinimumResizableRowHeight, ... + "SeparatorHeight", policy.SplitterThickness); +if ~isempty(varargin) + supplied = varargin{1}; + names = string(fieldnames(supplied)); + for name = names.' + options.(name) = supplied.(name); + end +end +heights = grid.RowHeight; +heights{separatorRow} = options.SeparatorHeight; +grid.RowHeight = heights; +separator = uipanel(grid, BorderType="none", ... + BackgroundColor=[0.72 0.72 0.72], ... + Tag="labkitAppRowResize"); +separator.Layout.Row = separatorRow; +separator.Layout.Column = 1; +if isprop(separator, "Tooltip") + separator.Tooltip = "Drag to resize this control group"; +end +if isprop(separator, "HitTest") + separator.HitTest = "on"; +end +if isprop(separator, "PickableParts") + separator.PickableParts = "all"; +end +separator.ButtonDownFcn = @beginDrag; + +drag = struct("Active", false, "StartY", NaN, "StartHeight", NaN, ... + "Motion", [], "Up", [], "Down", [], "Key", [], ... + "Pointer", "arrow"); + + function beginDrag(~, ~) + if drag.Active || ~isvalid(figureHandle) + return + end + drag.Active = true; + drag.StartY = figureHandle.CurrentPoint(2); + drag.StartHeight = contentHeight(); + drag.Motion = figureHandle.WindowButtonMotionFcn; + drag.Up = figureHandle.WindowButtonUpFcn; + drag.Down = figureHandle.WindowButtonDownFcn; + drag.Key = figureHandle.WindowKeyPressFcn; + drag.Pointer = figureHandle.Pointer; + figureHandle.Pointer = "top"; + figureHandle.WindowButtonMotionFcn = @resize; + figureHandle.WindowButtonUpFcn = @(~, ~) finishDrag(); + figureHandle.WindowButtonDownFcn = @(~, ~) finishDrag(); + figureHandle.WindowKeyPressFcn = @cancelOnEscape; + end + + function resize(~, ~) + point = figureHandle.CurrentPoint; + height = drag.StartHeight - (point(2) - drag.StartY); + heights = grid.RowHeight; + heights{contentRow} = max(options.MinimumHeight, height); + heights{separatorRow} = options.SeparatorHeight; + grid.RowHeight = heights; + end + + function value = contentHeight() + value = options.MinimumHeight; + children = grid.Children; + for k = 1:numel(children) + layout = children(k).Layout; + if isprop(layout, "Row") && isequal(layout.Row, contentRow) + position = children(k).Position; + if numel(position) >= 4 && isfinite(position(4)) && ... + position(4) > 0 + value = position(4); + return + end + end + end + end + + function cancelOnEscape(~, event) + if string(event.Key) == "escape" + finishDrag(); + end + end + + function finishDrag() + if ~drag.Active || ~isvalid(figureHandle) + return + end + figureHandle.WindowButtonMotionFcn = drag.Motion; + figureHandle.WindowButtonUpFcn = drag.Up; + figureHandle.WindowButtonDownFcn = drag.Down; + figureHandle.WindowKeyPressFcn = drag.Key; + figureHandle.Pointer = drag.Pointer; + drag.Active = false; + end +end diff --git a/+labkit/+app/+internal/private/nativeLayoutPolicy.m b/+labkit/+app/+internal/private/nativeLayoutPolicy.m new file mode 100644 index 000000000..9b6dfe142 --- /dev/null +++ b/+labkit/+app/+internal/private/nativeLayoutPolicy.m @@ -0,0 +1,33 @@ +% Private App SDK native-adapter implementation for nativeLayoutPolicy; called only by the internal runtime. +function policy = nativeLayoutPolicy() +% Private native visual policy. Expected callers are App SDK MATLAB adapters. +% The returned immutable-by-convention struct centralizes cross-App sizing, +% spacing, resize bounds, and control density. Apps declare semantic layout; +% they do not own these concrete framework metrics. + +policy = struct( ... + "InitialFigurePosition", [100 100 1180 760], ... + "ControlPaneWidth", 420, ... + "FormLabelWidth", 145, ... + "MinimumControlPaneWidth", 260, ... + "MinimumWorkspaceWidth", 360, ... + "SplitterThickness", 6, ... + "SplitterSpacing", 2, ... + "MinimumResizableRowHeight", 80, ... + "OuterPadding", [8 8 8 8], ... + "ContentPadding", [8 8 8 8], ... + "ContentSpacing", 8, ... + "CompactFileHeight", 72, ... + "FileListHeight", 236, ... + "FileListNoStatusHeight", 174, ... + "TableHeight", 202, ... + "LogHeight", 240, ... + "StatusHeight", 146, ... + "UsageHeight", 124, ... + "ButtonHeight", 26, ... + "SliderHeight", 26, ... + "FieldHeight", 26, ... + "SectionChromeHeight", 44, ... + "UntitledSectionChromeHeight", 16, ... + "GroupChromeHeight", 0); +end diff --git a/+labkit/+app/+internal/private/reconcileInteractions.m b/+labkit/+app/+internal/private/reconcileInteractions.m new file mode 100644 index 000000000..31a221a1a --- /dev/null +++ b/+labkit/+app/+internal/private/reconcileInteractions.m @@ -0,0 +1,606 @@ +% Private App runtime helper. The MATLAB interaction controller supplies its +% hub, owned resource map, normalized specifications, and declared signal IDs. +function reconcileInteractions(hub, resources, interactions, actionIds) + if isempty(interactions) + interactions = struct(); + end + if ~isstruct(interactions) || ~isscalar(interactions) + error('labkit:app:runtime:InvalidPresentation', ... + 'Presentation interactions must be a scalar struct.'); + end + hub.setSuppressed(true); + cleanup = onCleanup(@() hub.setSuppressed(false)); + desiredIds = string(fieldnames(interactions)); + specs = cell(numel(desiredIds), 1); + targetIds = hub.targetIds(); + for k = 1:numel(desiredIds) + id = desiredIds(k); + specs{k} = normalizeSpec( ... + id, interactions.(char(id)), actionIds, targetIds); + end + currentIds = string(keys(resources)); + removedIds = setdiff(currentIds, desiredIds, 'stable'); + for k = 1:numel(removedIds) + removeResource(resources, removedIds(k)); + end + for k = 1:numel(desiredIds) + id = desiredIds(k); + spec = specs{k}; + current = []; + if isKey(resources, char(id)) + current = resources(char(id)); + end + if isempty(current) || ~sameIdentity(current.spec, spec) + removeResource(resources, id); + current = createControlledInteraction(hub, id, spec); + resources(char(id)) = current; + end + current.update(spec.Value); + end + clear cleanup; +end + +function removeResource(resources, id) +key = char(id); +if ~isKey(resources, key) + return +end +value = resources(key); +remove(resources, key); +value.delete(); +end + +function controlled = createControlledInteraction(hub, id, spec) + kind = lower(spec.Kind); + group = "interaction:" + id; + suppressed = false; + switch kind + case {"anchors", "scalebarreference", "scalebar"} + options = anchorOptions(spec, @emitValue); + editor = createAnchorEditor( ... + hub.adapter(spec.Targets(1), group), ... + spec.ImageSize, options); + editors = {editor}; + update = @updateSingleAnchors; + case "pairedanchors" + editors = cell(1, numel(spec.Targets)); + for k = 1:numel(spec.Targets) + options = anchorOptions(spec, @emitPairedValue); + editors{k} = createAnchorEditor( ... + hub.adapter(spec.Targets(k), group), ... + imageSizeAt(spec.ImageSize, k), options); + end + update = @updatePairedAnchors; + case "rectangle" + options = rectangleOptions(spec, @emitValue, @emitBackground); + editor = createRectangleEditor( ... + hub.adapter(spec.Targets(1), group), spec.ImageSize, ... + spec.Value, options); + editors = {editor}; + update = @updateRectangle; + case "regionselection" + editor = createRegionSelection( ... + hub.adapter(spec.Targets(1), group), spec, ... + @emitValue, @emitBackground); + editors = {editor}; + update = @updateRegionSelection; + case "interval" + editor = createIntervalEditor( ... + hub.adapter(spec.Targets(1), group), spec, ... + @emitValue, @emitScroll); + editors = {editor}; + update = @updateInterval; + case "pointslots" + editor = createPointSlotsEditor( ... + hub.adapter(spec.Targets(1), group), spec.ImageSize, ... + spec.Options, @emitValue); + editors = {editor}; + update = @updatePointSlots; + otherwise + error('labkit:app:runtime:UnknownInteractionKind', ... + 'Interaction "%s" has unsupported Kind "%s".', id, spec.Kind); + end + baseUpdate = update; + instruction = interactionInstruction(spec); + update = @updateWithInstruction; + controlled = struct("spec", spec, "update", update, ... + "delete", @deleteEditors, "editors", {editors}); + + function updateWithInstruction(value) + baseUpdate(value); + applyInstruction(); + end + + function updateSingleAnchors(value) + withSuppression(@() setAnchorValue(editors{1}, value)); + end + + function updatePairedAnchors(value) + values = pairedValues(value, spec.Targets); + withSuppression(@setValues); + + function setValues() + for index = 1:numel(editors) + setAnchorValue(editors{index}, values{index}); + end + end + end + + function updateRectangle(value) + withSuppression(@() editors{1}.setPosition(value)); + end + + function updateRegionSelection(~) + % Region selection is a transient gesture and has no durable overlay. + end + + function updateInterval(value) + withSuppression(@() editors{1}.setRange(value)); + end + + function updatePointSlots(value) + editorValue = value; + if ~isstruct(editorValue) + editorValue = struct( ... + "points", double(editorValue), ... + "selectedIndex", 1, ... + "locked", false); + end + withSuppression(@() editors{1}.setValue(editorValue)); + end + + function setAnchorValue(targetEditor, value) + targetEditor.setPoints(value); + targetEditor.setActive(true); + end + + function emitValue(value, varargin) + if ~suppressed + if spec.Kind == "pointSlots" && isstruct(value) && ... + isfield(value, "points") + value = value.points; + end + hub.dispatch(spec.Event, id, value, spec.ChangePolicy); + end + end + + function emitPairedValue(varargin) + if suppressed + return; + end + values = cell(1, numel(editors)); + for index = 1:numel(editors) + values{index} = editors{index}.getPoints(); + end + hub.dispatch(spec.Event, id, values, spec.ChangePolicy); + end + + function emitBackground(varargin) + if suppressed || strlength(spec.BackgroundEvent) == 0 + return; + end + hub.dispatch(spec.BackgroundEvent, id, ... + hub.point(spec.Targets(1)), "commit"); + end + + function emitScroll(value) + if ~suppressed && strlength(spec.ScrollEvent) > 0 + hub.dispatch(spec.ScrollEvent, id, value, "commit"); + end + end + + function withSuppression(callback) + suppressed = true; + cleanupSuppression = onCleanup(@() clearSuppression()); + callback(); + clear cleanupSuppression; + end + + function clearSuppression() + suppressed = false; + end + + function deleteEditors() + clearInstruction(); + for index = 1:numel(editors) + editors{index}.delete(); + end + end + + function applyInstruction() + if strlength(instruction) == 0 + return; + end + for index = 1:numel(spec.Targets) + adapter = hub.adapter(spec.Targets(index), group); + ax = adapter.axes(); + if isempty(ax) || ~isvalid(ax) + continue; + end + try + subtitle(ax, instruction, 'Interpreter', 'none'); + catch + end + end + end + + function clearInstruction() + if strlength(instruction) == 0 + return; + end + for index = 1:numel(spec.Targets) + adapter = hub.adapter(spec.Targets(index), group); + ax = adapter.axes(); + if isempty(ax) || ~isvalid(ax) + continue; + end + try + if join(string(ax.Subtitle.String), newline) == instruction + subtitle(ax, ""); + end + catch + end + end + end +end + +function editor = createRegionSelection(runtime, spec, onSelected, onPoint) + ax = runtime.axes(); + imageSize = normalizeRegionImageSize(spec.ImageSize); + color = optionValue(spec.Options, 'color', [1 1 1]); + lineWidth = optionValue(spec.Options, 'lineWidth', 1.2); + lineStyle = optionValue(spec.Options, 'lineStyle', '--'); + threshold = optionValue(spec.Options, 'pointThreshold', 2); + startPoint = [NaN NaN]; + overlay = gobjects(1, 0); + session = runtime.createSession(struct( ... + "name", "regionSelection", ... + "onPointerDown", @pointerDown, ... + "installScrollWheel", false)); + session.activate(); + editor = struct("delete", @deleteEditor); + + function pointerDown(~, ~) + startPoint = clampedPoint(); + if ~all(isfinite(startPoint)) + return; + end + deleteOverlay(); + session.captureDrag(@drag, @release); + end + + function drag(~, ~) + point = clampedPoint(); + if ~all(isfinite(point)) + return; + end + position = rectangleFromPoints(startPoint, point); + if isempty(overlay) || ~isgraphics(overlay) + overlay = rectangle(ax, "Position", position, ... + "EdgeColor", color, "LineWidth", lineWidth, ... + "LineStyle", lineStyle, "HitTest", "off", ... + "PickableParts", "none"); + else + overlay.Position = position; + end + session.setGraphics(overlay); + end + + function release(~, ~) + point = clampedPoint(); + position = rectangleFromPoints(startPoint, point); + deleteOverlay(); + if all(isfinite(position)) && max(position(3:4)) > threshold + onSelected(position); + elseif all(isfinite(point)) + onPoint(); + end + startPoint = [NaN NaN]; + end + + function point = clampedPoint() + current = double(ax.CurrentPoint); + point = current(1, 1:2); + if numel(imageSize) >= 2 && all(isfinite(imageSize)) + point = min([imageSize(2), imageSize(1)], max([1 1], point)); + end + end + + function deleteEditor() + session.delete(); + deleteOverlay(); + end + + function deleteOverlay() + if ~isempty(overlay) && all(isgraphics(overlay)) + delete(overlay); + end + overlay = gobjects(1, 0); + end +end + +function imageSize = normalizeRegionImageSize(value) + imageSize = double(value(:).'); + if numel(imageSize) < 2 || ~all(isfinite(imageSize(1:2))) || ... + any(imageSize(1:2) < 1) + imageSize = [NaN NaN]; + else + imageSize = imageSize(1:2); + end +end + +function position = rectangleFromPoints(a, b) + if numel(a) ~= 2 || numel(b) ~= 2 || ... + ~all(isfinite([a(:); b(:)])) + position = [NaN NaN NaN NaN]; + return; + end + origin = min(a, b); + position = [origin, abs(b - a)]; +end + +function spec = normalizeSpec(id, value, actionIds, targetIds) + if ~isstruct(value) || ~isscalar(value) + error('labkit:app:runtime:InvalidInteractionSpec', ... + 'Interaction "%s" must be a scalar struct.', id); + end + spec = struct(); + spec.Kind = string(requiredValue(value, 'Kind', id)); + spec.Targets = string(requiredValue(value, 'Targets', id)); + spec.Value = requiredValue(value, 'Value', id); + spec.Event = string(requiredValue(value, 'Event', id)); + spec.BackgroundEvent = string(optionValue( ... + value, 'BackgroundEvent', "")); + spec.ScrollEvent = string(optionValue(value, 'ScrollEvent', "")); + spec.ChangePolicy = string(optionValue( ... + value, 'ChangePolicy', 'commit')); + spec.ImageSize = optionValue(value, 'ImageSize', []); + spec.Options = optionValue(value, 'Options', struct()); + spec.Instruction = string(optionValue(value, 'Instruction', "")); + spec.Targets = spec.Targets(:).'; + if ~isscalar(spec.Kind) || strlength(spec.Kind) == 0 || ... + isempty(spec.Targets) || any(strlength(spec.Targets) == 0) + error('labkit:app:runtime:InvalidInteractionSpec', ... + 'Interaction "%s" requires Kind and semantic Targets.', id); + end + if ~isscalar(spec.Instruction) + error('labkit:app:runtime:InvalidInteractionSpec', ... + 'Interaction "%s" Instruction must be scalar text.', id); + end + if ~isscalar(spec.Event) || strlength(spec.Event) == 0 + error('labkit:app:runtime:InvalidInteractionSpec', ... + 'Interaction "%s" requires one semantic Event.', id); + end + if ~isscalar(spec.BackgroundEvent) || ~isscalar(spec.ScrollEvent) + error('labkit:app:runtime:InvalidInteractionSpec', ... + 'Interaction "%s" optional events must be scalar text.', id); + end + referencedEvents = [spec.Event, spec.BackgroundEvent, spec.ScrollEvent]; + referencedEvents = referencedEvents(strlength(referencedEvents) > 0); + if any(~ismember(referencedEvents, actionIds)) + unknown = referencedEvents(~ismember(referencedEvents, actionIds)); + error('labkit:app:runtime:UnknownInteractionAction', ... + 'Interaction "%s" references unknown action "%s".', ... + id, unknown(1)); + end + unknownTargets = setdiff(spec.Targets, targetIds, 'stable'); + if ~isempty(unknownTargets) + error('labkit:app:runtime:UnknownInteractionTarget', ... + 'Interaction "%s" references unknown target "%s".', ... + id, unknownTargets(1)); + end + if lower(spec.Kind) == "pairedanchors" && numel(spec.Targets) < 2 + error('labkit:app:runtime:InvalidInteractionSpec', ... + 'pairedAnchors interaction "%s" requires at least two Targets.', id); + elseif lower(spec.Kind) ~= "pairedanchors" && numel(spec.Targets) ~= 1 + error('labkit:app:runtime:InvalidInteractionSpec', ... + 'Interaction "%s" requires exactly one Target.', id); + end +end + +function tf = sameIdentity(left, right) + tf = lower(left.Kind) == lower(right.Kind) && ... + isequal(left.Targets, right.Targets) && ... + isequaln(left.ImageSize, right.ImageSize) && ... + isequaln(left.Options, right.Options) && ... + left.Event == right.Event && ... + left.BackgroundEvent == right.BackgroundEvent && ... + left.ScrollEvent == right.ScrollEvent && ... + left.Instruction == right.Instruction && ... + left.ChangePolicy == right.ChangePolicy; +end + +function value = interactionInstruction(spec) + value = spec.Instruction; + if strlength(value) > 0 + return; + end + kind = lower(spec.Kind); + mode = lower(string(optionValue(spec.Options, 'mode', 'curve'))); + if kind == "pairedanchors" + value = "Click each preview in matching order; drag points to refine; use Undo to remove a pair."; + elseif any(kind == ["scalebarreference", "scalebar"]) + value = "Double-click two reference endpoints; drag either endpoint to refine."; + elseif kind == "anchors" && mode == "points" + value = "Click blank image space to add points; drag points to refine; use Undo or Clear to remove."; + elseif kind == "anchors" + value = "Double-click blank image space to add; drag a point to move; double-click a point to delete."; + elseif kind == "pointslots" + value = "Click blank image space to place the selected marker; drag a marker to refine its position."; + end +end + +function editor = createIntervalEditor(runtime, spec, onChanged, onScroll) + ax = runtime.axes(); + range = normalizeInterval(spec.Value); + color = optionValue(spec.Options, 'color', [0.2 0.55 1]); + faceColor = optionValue(spec.Options, 'faceColor', color); + faceAlpha = optionValue(spec.Options, 'faceAlpha', 0.12); + overlay = gobjects(1, 0); + startX = NaN; + options = struct("name", "intervalEditor", ... + "onPointerDown", @pointerDown, ... + "onScroll", @scroll, "installScrollWheel", true); + session = runtime.createSession(options); + session.activate(); + editor = struct("setRange", @setRange, "delete", @deleteEditor); + refresh(); + + function setRange(value) + range = normalizeInterval(value); + refresh(); + end + + function pointerDown(~, ~) + point = axesPoint(); + if ~isfinite(point) + return; + end + startX = point; + range = [point point]; + refresh(); + session.captureDrag(@drag, @release); + end + + function drag(~, ~) + point = axesPoint(); + if isfinite(point) + range = sort([startX point]); + refresh(); + end + end + + function release(~, ~) + if all(isfinite(range)) && diff(range) > 0 + onChanged(range); + end + end + + function scroll(~, event) + point = axesPoint(); + count = scrollCount(event); + if count ~= 0 + onScroll(labkit.app.event.IntervalScroll( ... + Anchor=point, Count=count)); + end + end + + function refresh() + if ~isgraphics(ax) + return; + end + if isempty(overlay) || ~all(isgraphics(overlay)) + overlay = patch(ax, NaN, NaN, faceColor, ... + 'FaceAlpha', faceAlpha, 'EdgeColor', color, ... + 'LineStyle', ':', 'LineWidth', 1, 'HitTest', 'off', ... + 'PickableParts', 'none'); + end + if all(isfinite(range)) + limits = ylim(ax); + overlay.XData = [range(1) range(2) range(2) range(1)]; + overlay.YData = [limits(1) limits(1) limits(2) limits(2)]; + else + overlay.XData = NaN; + overlay.YData = NaN; + end + session.setGraphics(overlay); + session.refresh(); + end + + function value = axesPoint() + current = double(ax.CurrentPoint); + value = current(1, 1); + end + + function deleteEditor() + session.delete(); + if ~isempty(overlay) && all(isgraphics(overlay)) + delete(overlay); + end + overlay = gobjects(1, 0); + end +end + +function value = normalizeInterval(value) + value = double(value(:).'); + if numel(value) ~= 2 || ~all(isfinite(value)) + value = [NaN NaN]; + else + value = sort(value); + end +end + +function count = scrollCount(event) + count = 0; + if isstruct(event) && isfield(event, 'VerticalScrollCount') + count = double(event.VerticalScrollCount); + elseif isobject(event) && isprop(event, 'VerticalScrollCount') + count = double(event.VerticalScrollCount); + end + if ~isscalar(count) || ~isfinite(count) + count = 0; + end +end + +function options = anchorOptions(spec, callback) + options = spec.Options; + options.onChanged = callback; + kind = lower(spec.Kind); + if any(kind == ["scalebarreference", "scalebar"]) + options.closed = false; + options.style = "Straight lines"; + options.maxPoints = 2; + end +end + +function options = rectangleOptions(spec, callback, backgroundCallback) + options = spec.Options; + options.onMoved = callback; + if strlength(spec.BackgroundEvent) > 0 + options.onBackgroundDown = backgroundCallback; + end +end + +function values = pairedValues(value, targets) + if iscell(value) && numel(value) == numel(targets) + values = reshape(value, 1, []); + return; + end + if isstruct(value) && isscalar(value) + values = cell(1, numel(targets)); + for k = 1:numel(targets) + field = matlab.lang.makeValidName(char(targets(k))); + if ~isfield(value, field) + error('labkit:app:runtime:InvalidInteractionValue', ... + 'Paired interaction value is missing target "%s".', targets(k)); + end + values{k} = value.(field); + end + return; + end + error('labkit:app:runtime:InvalidInteractionValue', ... + 'Paired interaction Value must provide one point array per Target.'); +end + +function imageSize = imageSizeAt(value, index) + if iscell(value) + imageSize = value{index}; + else + imageSize = value; + end +end + +function value = requiredValue(spec, name, id) + if ~isfield(spec, name) + error('labkit:app:runtime:InvalidInteractionSpec', ... + 'Interaction "%s" requires %s.', id, name); + end + value = spec.(name); +end + +function value = optionValue(spec, name, defaultValue) + value = defaultValue; + if isfield(spec, name) + value = spec.(name); + end +end diff --git a/+labkit/+ui/+runtime/private/zoomAxesAtPoint.m b/+labkit/+app/+internal/private/zoomAxesAtPoint.m similarity index 96% rename from +labkit/+ui/+runtime/private/zoomAxesAtPoint.m rename to +labkit/+app/+internal/private/zoomAxesAtPoint.m index e70b7addb..2794f18a5 100644 --- a/+labkit/+ui/+runtime/private/zoomAxesAtPoint.m +++ b/+labkit/+app/+internal/private/zoomAxesAtPoint.m @@ -33,7 +33,7 @@ zoomBase = optionValue(opts, 'ZoomBase', 1.20); if ~isnumeric(zoomBase) || ~isscalar(zoomBase) || ... ~isfinite(zoomBase) || zoomBase <= 0 - error('labkit:ui:interaction:InvalidZoomBase', ... + error('labkit:app:interaction:InvalidZoomBase', ... 'ZoomBase must be a positive finite scalar.'); end @@ -105,7 +105,7 @@ return; end if mod(numel(args), 2) ~= 0 - error('labkit:ui:interaction:InvalidOptions', ... + error('labkit:app:interaction:InvalidOptions', ... 'zoomAxesAtPoint options must be name/value pairs.'); end for k = 1:2:numel(args) @@ -235,12 +235,12 @@ function bounds = normalizeBounds(bounds) if ~isnumeric(bounds) || numel(bounds) ~= 4 - error('labkit:ui:interaction:InvalidBounds', ... + error('labkit:app:interaction:InvalidBounds', ... 'Bounds must be [xmin xmax ymin ymax].'); end bounds = double(bounds(:).'); if any(~isfinite(bounds)) || bounds(2) <= bounds(1) || bounds(4) <= bounds(3) - error('labkit:ui:interaction:InvalidBounds', ... + error('labkit:app:interaction:InvalidBounds', ... 'Bounds must contain finite increasing x and y limits.'); end end @@ -250,7 +250,7 @@ minSpan = [0, 0]; end if ~isnumeric(minSpan) || ~(isscalar(minSpan) || numel(minSpan) == 2) - error('labkit:ui:interaction:InvalidMinSpan', ... + error('labkit:app:interaction:InvalidMinSpan', ... 'MinSpan must be a scalar or [minX minY].'); end minSpan = double(minSpan(:).'); @@ -294,7 +294,7 @@ zoomAxes = "xy"; end if ~isscalar(zoomAxes) || ~ismember(zoomAxes, ["xy", "x", "y"]) - error('labkit:ui:interaction:InvalidZoomAxes', ... + error('labkit:app:interaction:InvalidZoomAxes', ... 'ZoomAxes must be "xy", "x", or "y".'); end end diff --git a/+labkit/+app/+layout/button.m b/+labkit/+app/+layout/button.m new file mode 100644 index 000000000..997a844c6 --- /dev/null +++ b/+labkit/+app/+layout/button.m @@ -0,0 +1,35 @@ +function node = button(id, label, onPressed, varargin) +%BUTTON Add a push button with one explicit pressed callback. +% +% Usage: +% node = labkit.app.layout.button(id, label, onPressed, Name=Value) +% +% Description: +% Declares a semantic push button without creating a native component. +% +% Inputs: +% id - Unique MATLAB identifier for the layout target. +% label - Nonempty text displayed on the button. +% onPressed - Scalar function handle with the fixed callback +% state = onPressed(state,context). +% +% Options: +% BusyMessage - Status text while the action runs. Default: "". +% Enabled - Initial logical enabled state. Default: true. +% Tooltip - Nonempty hover text explaining the action's scientific or +% workflow effect. Default: label. +% +% Outputs: +% node - Immutable internal layout node accepted by layout containers. +% +% Errors: +% Throws labkit:app:contract:* for invalid IDs, options, or handlers. +% +% Typical Call: +% node = labkit.app.layout.button("run", "Run", @runAnalysis, ... +% Tooltip="Compute the current analysis from the selected inputs."); +% +% See also labkit.app.layout.workbench, labkit.app.CallbackContext +node = labkit.app.internal.LayoutNode.button( ... + id, label, onPressed, varargin{:}); +end diff --git a/+labkit/+app/+layout/dataTable.m b/+labkit/+app/+layout/dataTable.m new file mode 100644 index 000000000..3fd7b6e06 --- /dev/null +++ b/+labkit/+app/+layout/dataTable.m @@ -0,0 +1,38 @@ +function node = dataTable(id, varargin) +%DATATABLE Add a tabular data display with optional editing and selection. +% +% Usage: +% node = labkit.app.layout.dataTable(id, Name=Value) +% +% Description: +% Declares a semantic data table and typed cell callbacks. +% +% Inputs: +% id - Unique MATLAB identifier for the layout target. +% +% Options: +% Title - Reader-facing panel title or blank. A single-table section +% supplies its title when this is blank. Default: blank. +% Columns - Column-label text row. Default: strings(1,0). +% RowNames - Row-label text row. Default: strings(1,0). +% ColumnEditable - Logical scalar or row matching Columns. Default: false. +% OnCellEdited - Scalar callback +% state = callback(state,edit,context), where edit is a +% labkit.app.event.TableCellEdit. Default: []. +% OnCellSelectionChanged - Scalar callback +% state = callback(state,selection,context), where selection is a +% labkit.app.event.TableCellSelection. Default: []. +% +% Outputs: +% node - Immutable internal layout node accepted by layout containers. +% +% Errors: +% Throws labkit:app:contract:* for invalid options or callback signatures. +% +% Typical Call: +% node = labkit.app.layout.dataTable("results", Columns=["Name" "Value"]); +% +% See also labkit.app.event.TableCellEdit, +% labkit.app.event.TableCellSelection +node = labkit.app.internal.LayoutNode.dataTable(id, varargin{:}); +end diff --git a/+labkit/+app/+layout/field.m b/+labkit/+app/+layout/field.m new file mode 100644 index 000000000..c11d7b19d --- /dev/null +++ b/+labkit/+app/+layout/field.m @@ -0,0 +1,40 @@ +function node = field(id, varargin) +%FIELD Add a text, numeric, choice, or logical input field. +% +% Usage: +% node = labkit.app.layout.field(id, Name=Value) +% +% Description: +% Declares one bound or event-driven scalar input field. +% +% Inputs: +% id - Unique MATLAB identifier for the layout target. +% +% Options: +% Label - Display text. Default: id. +% Kind - "text", "numeric", "choice", "logical", or "readonly". +% Readonly fields display one labeled result value. Default: "text". +% Value - Initial value. Default: []. +% Choices - Text row for choice fields. Default: strings(1,0). +% Limits - Increasing finite numeric 1-by-2 row. Default: []. +% Step - Positive numeric scalar. Default: []. +% Bind - Project or session field path. Default: "". +% ValueDisplayFormat - MATLAB numeric display format. Default: "". +% ShowTicks - Logical slider-tick preference. Default: false. +% Enabled - Initial logical enabled state. Default: true. +% OnValueChanged - Scalar callback +% state = callback(state,value,context). Default: []. +% +% Outputs: +% node - Immutable internal layout node accepted by layout containers. +% +% Errors: +% Throws labkit:app:contract:* for invalid IDs, options, or handlers. +% +% Typical Call: +% node = labkit.app.layout.field("gain", Kind="numeric", ... +% Bind="project.parameters.gain"); +% +% See also labkit.app.layout.rangeField, labkit.app.layout.slider +node = labkit.app.internal.LayoutNode.field(id, varargin{:}); +end diff --git a/+labkit/+app/+layout/fileList.m b/+labkit/+app/+layout/fileList.m new file mode 100644 index 000000000..f850b5081 --- /dev/null +++ b/+labkit/+app/+layout/fileList.m @@ -0,0 +1,63 @@ +function node = fileList(id, varargin) +%FILELIST Add file selection and portable-source controls. +% +% Usage: +% node = labkit.app.layout.fileList(id, Name=Value) +% +% Description: +% Declares framework-owned file choosing, removal, selection, and portable +% source binding. +% +% Inputs: +% id - Unique MATLAB identifier for the layout target. +% +% Options: +% Label - Reader-facing collection label. Default: id. +% Mode - "files" or "folder". Default: "files". +% Filters - File-dialog filter text row. Default: strings(1,0). +% SelectionMode - "single" or "multiple". Default: "multiple". +% MaxFiles - Positive scalar or Inf. Default: Inf. +% FolderWarningThreshold - Positive scalar or Inf. Default: 500. +% ShowStatus - Logical status visibility. Default: true. +% StartPath - Initial folder text. Default: "". +% ChooseLabel - File button text. Default: "Choose". +% FolderLabel - Folder button text. Default: "Choose Folder". +% RecursiveFolderLabel - Recursive button text. Default: "Choose Folder Recursively". +% RemoveLabel - Remove button text. Default: "Remove". +% ClearLabel - Clear button text. Default: "Clear". +% ChooseTooltip - File button hover text. Default: ChooseLabel. +% FolderTooltip - Folder button hover text. Default: FolderLabel. +% RecursiveFolderTooltip - Recursive-folder button hover text. Default: +% RecursiveFolderLabel. +% RemoveTooltip - Remove button hover text. Default: RemoveLabel. +% ClearTooltip - Clear button hover text. Default: ClearLabel. +% EmptyText - Empty-list text. Default: "No files selected". +% AllowDuplicatePaths - Preserve separate portable source records that +% resolve to the same path. Use this when each list row is a distinct +% workflow task. Default: false. +% Bind - Project source-record field path. Default: "". +% SelectionBind - ListSelection field path. Default: "". +% OnSelectionChanged - Optional callback +% applicationState = callback(applicationState,selection,callbackContext) +% for business effects such as lazily decoding the selected source. +% selection is labkit.app.event.ListSelection. Ordinary selection state +% needs only SelectionBind. Default: empty. +% SourceRole - Portable source role. Default: id. +% SourceIdPrefix - Portable source ID prefix. Default: id. +% Required - Logical relinking requirement. Default: true. +% +% Outputs: +% node - Immutable internal layout node accepted by layout containers. +% +% Errors: +% Throws labkit:app:contract:* for invalid options, paths, or callbacks. +% +% Typical Call: +% node = labkit.app.layout.fileList("files", ... +% Bind="project.inputs.sources", ... +% ChooseTooltip="Choose calibrated source images for this analysis."); +% +% See also labkit.app.event.ListSelection, +% labkit.app.CallbackContext, labkit.app.view.Snapshot +node = labkit.app.internal.LayoutNode.fileList(id, varargin{:}); +end diff --git a/+labkit/+app/+layout/group.m b/+labkit/+app/+layout/group.m new file mode 100644 index 000000000..a80810b60 --- /dev/null +++ b/+labkit/+app/+layout/group.m @@ -0,0 +1,30 @@ +function node = group(id, children, varargin) +%GROUP Arrange related child elements without a titled boundary. +% +% Usage: +% node = labkit.app.layout.group(id, children, Name=Value) +% +% Description: +% Groups compatible control nodes into one semantic arrangement. A titled +% group draws a nested reader-facing boundary inside its owning section. +% +% Inputs: +% id - Unique MATLAB identifier for the layout target. +% children - Row cell array of layout nodes. +% +% Options: +% Layout - "auto", "vertical", or "horizontal". Default: "auto". +% Title - Reader-facing nested-group title or blank. Default: blank. +% +% Outputs: +% node - Immutable internal layout node accepted by containers. +% +% Errors: +% Throws labkit:app:contract:* for invalid IDs, children, or options. +% +% Typical Call: +% node = labkit.app.layout.group("inputs", {gainField}); +% +% See also labkit.app.layout.section, labkit.app.layout.workbench +node = labkit.app.internal.LayoutNode.group(id, children, varargin{:}); +end diff --git a/+labkit/+app/+layout/logPanel.m b/+labkit/+app/+layout/logPanel.m new file mode 100644 index 000000000..c5a73c318 --- /dev/null +++ b/+labkit/+app/+layout/logPanel.m @@ -0,0 +1,28 @@ +function node = logPanel(id, varargin) +%LOGPANEL Add a text display for App log messages. +% +% Usage: +% node = labkit.app.layout.logPanel(id, Name=Value) +% +% Description: +% Declares a runtime-populated multiline App log display. +% +% Inputs: +% id - Unique MATLAB identifier for the layout target. +% +% Options: +% Title - Visible panel title. Default: "Log". +% +% Outputs: +% node - Immutable internal layout node accepted by containers. +% +% Errors: +% Throws labkit:app:contract:InvalidValue for an invalid ID. +% +% Typical Call: +% node = labkit.app.layout.logPanel("appLog"); +% +% See also labkit.app.layout.statusPanel, +% labkit.app.CallbackContext +node = labkit.app.internal.LayoutNode.logPanel(id, varargin{:}); +end diff --git a/+labkit/+app/+layout/plotArea.m b/+labkit/+app/+layout/plotArea.m new file mode 100644 index 000000000..9a8977b92 --- /dev/null +++ b/+labkit/+app/+layout/plotArea.m @@ -0,0 +1,49 @@ +function node = plotArea(id, renderer, varargin) +%PLOTAREA Add one or more axes rendered by an App-owned renderer. +% +% Usage: +% node = labkit.app.layout.plotArea(id, renderer, Name=Value) +% +% Description: +% Declares axes and their one App-owned renderer. The renderer is declared +% here once; a view snapshot supplies only its current model. +% +% Inputs: +% id - Unique MATLAB identifier for the layout target. +% renderer - Scalar function handle renderer(axesById,model) with no +% output. axesById is always a scalar struct with one graphics axes +% field per declared AxisIds value, including single-axis plots. +% +% Options: +% Title - Reader-facing panel title or blank. Default: blank. +% Layout - Axes arrangement: "single", "pair", or "stack". Default: +% "single" for one axis and "stack" for multiple axes. +% AxisIds - Unique MATLAB identifier row. Default: "main". +% AxisTitles - One title per axis. Default: axis IDs. +% XLabels - One x-axis label per axis. Default: blank. +% YLabels - One y-axis label per axis. Default: blank. +% ColumnWidths - One positive pixel, "fit", or "1x" value per axis for +% pair layout. Default: equal flexible widths. +% RowHeights - One positive pixel, "fit", or "1x" value per axis for +% stack layout. Default: equal flexible heights. +% ScrollZoomAxes - One "xy", "x", or "y" value per axis. Default: "xy". +% ViewModes - App-owned mode labels. Default: strings(1,0). +% OnValueChanged - Scalar callback +% state = callback(state,value,context). Default: []. +% Interactions - Row cell array returned by named +% labkit.app.interaction.* declarations. Default: {}. +% +% Outputs: +% node - Immutable internal layout node accepted by workspace. +% +% Errors: +% Throws labkit:app:contract:* for invalid IDs, options, or handlers. +% +% Typical Call: +% node = labkit.app.layout.plotArea("preview", @drawTrace, ... +% AxisIds="trace"); +% +% See also labkit.app.view.Snapshot, labkit.app.layout.workspace, +% labkit.app.interaction.anchorPath +node = labkit.app.internal.LayoutNode.plotArea(id, renderer, varargin{:}); +end diff --git a/+labkit/+app/+layout/rangeField.m b/+labkit/+app/+layout/rangeField.m new file mode 100644 index 000000000..fd25e8652 --- /dev/null +++ b/+labkit/+app/+layout/rangeField.m @@ -0,0 +1,33 @@ +function node = rangeField(id, varargin) +%RANGEFIELD Add an input for a numeric lower and upper bound. +% +% Usage: +% node = labkit.app.layout.rangeField(id, Name=Value) +% +% Description: +% Declares one two-value numeric range input. +% +% Inputs: +% id - Unique MATLAB identifier for the layout target. +% +% Options: +% Label - Display text. Default: id. +% Value - Finite numeric 1-by-2 row. Default: []. +% Limits - Increasing finite numeric 1-by-2 row. Default: []. +% Enabled - Initial logical enabled state. Default: true. +% Bind - Project or session field path. Default: "". +% OnValueChanged - Scalar callback +% state = callback(state,value,context). Default: []. +% +% Outputs: +% node - Immutable internal layout node accepted by layout containers. +% +% Errors: +% Throws labkit:app:contract:* for invalid IDs, options, or callbacks. +% +% Typical Call: +% node = labkit.app.layout.rangeField("window", Limits=[0 10]); +% +% See also labkit.app.layout.field, labkit.app.layout.slider +node = labkit.app.internal.LayoutNode.rangeField(id, varargin{:}); +end diff --git a/+labkit/+app/+layout/section.m b/+labkit/+app/+layout/section.m new file mode 100644 index 000000000..c8aec476d --- /dev/null +++ b/+labkit/+app/+layout/section.m @@ -0,0 +1,30 @@ +function node = section(id, title, children, varargin) +%SECTION Arrange related child elements under a visible title. +% +% Usage: +% node = labkit.app.layout.section(id, title, children, Name=Value) +% +% Description: +% Groups compatible controls under a reader-facing title. +% +% Inputs: +% id - Unique MATLAB identifier for the layout target. +% title - Nonempty section title. +% children - Row cell array of layout nodes. +% +% Options: +% Collapsible - Logical collapse support. Default: false. +% Expanded - Initial logical expansion state. Default: true. +% +% Outputs: +% node - Immutable internal layout node accepted by containers. +% +% Errors: +% Throws labkit:app:contract:* for invalid IDs, children, or options. +% +% Typical Call: +% node = labkit.app.layout.section("inputs", "Inputs", {gainField}); +% +% See also labkit.app.layout.group, labkit.app.layout.tab +node = labkit.app.internal.LayoutNode.section(id, title, children, varargin{:}); +end diff --git a/+labkit/+app/+layout/slider.m b/+labkit/+app/+layout/slider.m new file mode 100644 index 000000000..08e12582a --- /dev/null +++ b/+labkit/+app/+layout/slider.m @@ -0,0 +1,37 @@ +function node = slider(id, varargin) +%SLIDER Add a bounded numeric slider. +% +% Usage: +% node = labkit.app.layout.slider(id, Name=Value) +% +% Description: +% Declares one bounded scalar slider. +% +% Inputs: +% id - Unique MATLAB identifier for the layout target. +% +% Options: +% Label - Display text. Default: id. +% Value - Finite numeric scalar. Default: lower Limits value. +% Limits - Increasing finite numeric 1-by-2 row. Default: [0 1]. +% Step - Positive numeric scalar. Default: []. +% ValueDisplayFormat - Optional spinner numeric format such as "%.6g". +% Default: "". +% ShowTicks - Logical tick visibility. Default: false. +% Bind - Project or session field path. Default: "". +% Enabled - Initial logical enabled state. Default: true. +% OnValueChanged - Scalar callback +% state = callback(state,value,context). Default: []. +% +% Outputs: +% node - Immutable internal layout node accepted by layout containers. +% +% Errors: +% Throws labkit:app:contract:* for invalid IDs, options, or callbacks. +% +% Typical Call: +% node = labkit.app.layout.slider("frame", Limits=[1 100], Step=1); +% +% See also labkit.app.layout.field, labkit.app.layout.rangeField +node = labkit.app.internal.LayoutNode.slider(id, varargin{:}); +end diff --git a/+labkit/+app/+layout/statusPanel.m b/+labkit/+app/+layout/statusPanel.m new file mode 100644 index 000000000..1944203e2 --- /dev/null +++ b/+labkit/+app/+layout/statusPanel.m @@ -0,0 +1,30 @@ +function node = statusPanel(id, varargin) +%STATUSPANEL Add a text display for current App status. +% +% Usage: +% node = labkit.app.layout.statusPanel(id, Name=Value) +% +% Description: +% Declares a current-status or static instruction display. +% +% Inputs: +% id - Unique MATLAB identifier for the layout target. +% +% Options: +% Title - Visible panel title. Default: "Status". +% Text - Static text lines. When omitted, the runtime's latest status is +% displayed. Default: strings(1,0). +% +% Outputs: +% node - Immutable internal layout node accepted by containers. +% +% Errors: +% Throws labkit:app:contract:InvalidValue for an invalid ID. +% +% Typical Call: +% node = labkit.app.layout.statusPanel("status"); +% +% See also labkit.app.layout.logPanel, +% labkit.app.CallbackContext +node = labkit.app.internal.LayoutNode.statusPanel(id, varargin{:}); +end diff --git a/+labkit/+app/+layout/tab.m b/+labkit/+app/+layout/tab.m new file mode 100644 index 000000000..5fd98d3b4 --- /dev/null +++ b/+labkit/+app/+layout/tab.m @@ -0,0 +1,26 @@ +function node = tab(id, title, children) +%TAB Add a named tab containing related child elements. +% +% Usage: +% node = labkit.app.layout.tab(id, title, children) +% +% Description: +% Declares one named control-side tab. +% +% Inputs: +% id - Unique MATLAB identifier for the layout target. +% title - Nonempty tab title. +% children - Row cell array of compatible layout nodes. +% +% Outputs: +% node - Immutable internal layout node accepted by workbench. +% +% Errors: +% Throws labkit:app:contract:* for invalid IDs, titles, or children. +% +% Typical Call: +% node = labkit.app.layout.tab("settings", "Settings", {gainField}); +% +% See also labkit.app.layout.section, labkit.app.layout.workbench +node = labkit.app.internal.LayoutNode.tab(id, title, children); +end diff --git a/+labkit/+app/+layout/workbench.m b/+labkit/+app/+layout/workbench.m new file mode 100644 index 000000000..db316e607 --- /dev/null +++ b/+labkit/+app/+layout/workbench.m @@ -0,0 +1,31 @@ +function node = workbench(children, varargin) +%WORKBENCH Assemble controls and a central workspace into the root layout. +% +% Usage: +% node = labkit.app.layout.workbench(children, Name=Value) +% +% Description: +% Produces the root semantic layout required by labkit.app.Definition. +% +% Inputs: +% children - Row cell array of controls, groups, sections, or tabs. +% +% Options: +% Workspace - Value returned by layout.workspace. Default: []. +% Usage - Static workflow instruction lines appended consistently to the +% first control tab. Default: strings(1,0). +% UsageTitle - Title for generated workflow instructions. Default: "Usage". +% +% Outputs: +% node - Immutable root layout node. +% +% Errors: +% Throws labkit:app:contract:* for invalid children or workspace values. +% +% Typical Call: +% node = labkit.app.layout.workbench({runButton}); +% +% See also labkit.app.Definition, +% labkit.app.layout.workspace +node = labkit.app.internal.LayoutNode.workbench(children, varargin{:}); +end diff --git a/+labkit/+app/+layout/workspace.m b/+labkit/+app/+layout/workspace.m new file mode 100644 index 000000000..48ef2fb93 --- /dev/null +++ b/+labkit/+app/+layout/workspace.m @@ -0,0 +1,36 @@ +function node = workspace(varargin) +%WORKSPACE Define the App's central working area and optional pages. +% +% Usage: +% node = labkit.app.layout.workspace() +% node = labkit.app.layout.workspace(content, Name=Value) +% +% Description: +% Declares a single-content workspace or a workspace extended with +% node.page(id,title,content) and node.initialPage(id). A named page accepts +% one workspace node or a nonempty row cell array of nodes arranged +% vertically; growable tables and plots share the available page height. +% +% Inputs: +% content - Optional plotArea, dataTable, group, or section node. +% For node.page, content may also be a nonempty row cell array of +% workspace nodes. +% +% Options: +% Title - Reader-facing workspace title. Default: "Workspace". +% OnPageChanged - Callback state = callback(state,pageId,context). +% Default: []. +% +% Outputs: +% node - Immutable workspace node accepted by layout.workbench. +% +% Errors: +% Throws labkit:app:contract:* for invalid content, pages, or callbacks. +% +% Typical Call: +% node = labkit.app.layout.workspace(plotArea); +% +% See also labkit.app.layout.workbench, +% labkit.app.layout.plotArea +node = labkit.app.internal.LayoutNode.workspace(varargin{:}); +end diff --git a/+labkit/+app/+plot/clampPointToAxes.m b/+labkit/+app/+plot/clampPointToAxes.m new file mode 100644 index 000000000..61466ab90 --- /dev/null +++ b/+labkit/+app/+plot/clampPointToAxes.m @@ -0,0 +1,47 @@ +function xy = clampPointToAxes(ax, xy, varargin) +%CLAMPPOINTTOAXES Keep data coordinates inside the visible axes box. +% +% Usage: +% xyOut = labkit.app.plot.clampPointToAxes(ax, xy) +% xyOut = labkit.app.plot.clampPointToAxes(ax, xy, Name=Value) +% +% Inputs: +% ax - Valid scalar MATLAB axes or uiaxes handle. Its XLim, YLim, XScale, +% YScale, XDir, and YDir properties define the visible box. +% xy - N-by-2 numeric matrix of [x y] data coordinates. +% +% Name-Value Arguments: +% Padding - Minimum distance from each axes edge, expressed as a fraction of +% the visible width or height. Values are limited to [0, 0.49]. Default: +% 0.04. +% +% Outputs: +% xy - N-by-2 data coordinates, shown as xyOut in the usage syntax. Each +% point is moved only as far as needed to satisfy Padding. +% +% Description: +% clampData is useful for labels and annotations that must remain readable +% near an axes boundary. Conversion through normalized axes coordinates keeps +% the result visually consistent on logarithmic or reversed axes. +% +% Errors: +% labkit:app:plot:InvalidAxes - ax is not a valid scalar axes handle. +% labkit:app:plot:InvalidPointPairs - xy is not an N-by-2 numeric array. +% labkit:app:plot:InvalidOptions or labkit:app:plot:InvalidOption - +% Name-value arguments are malformed or unsupported. +% +% Example: +% fig = figure("Visible", "off"); +% cleanup = onCleanup(@() close(fig)); +% ax = axes(fig, "XLim", [0 10], "YLim", [0 20]); +% xy = labkit.app.plot.clampPointToAxes(ax, [-2 25], "Padding", 0.1); +% assert(isequal(xy, [1 18])) +% +% See also labkit.app.plot.offsetPointByAxesFraction + + opts = parseAxesOptions(varargin, struct('Padding', 0.04)); + pad = max(0, min(0.49, double(opts.Padding))); + uv = dataToFraction(ax, xy); + uv = min(max(uv, pad), 1 - pad); + xy = fractionToData(ax, uv); +end diff --git a/+labkit/+app/+plot/clearAxes.m b/+labkit/+app/+plot/clearAxes.m new file mode 100644 index 000000000..04d50b89a --- /dev/null +++ b/+labkit/+app/+plot/clearAxes.m @@ -0,0 +1,69 @@ +function clearAxes(ax, varargin) +%CLEARAXES Prepare an axes for an app-owned redraw. +% +% Usage: +% labkit.app.plot.clearAxes(ax) +% labkit.app.plot.clearAxes(ax, Name=Value) +% +% Inputs: +% ax - Valid scalar MATLAB axes or uiaxes handle to clear. +% +% Name-Value Arguments: +% ResetScale - Logical value. true restores linear X/Y scales and automatic +% X/Y tick modes. false preserves those four properties. Default: false. +% ClearLegend - Logical value. true turns the legend off. Default: true. +% +% Outputs: +% None. +% +% Description: +% clear deletes plotted children, clears LabKit's cached image home view, +% releases hold, and returns XLim, YLim, ZLim, and CLim to automatic mode. +% Labels and other axes decorations follow MATLAB cla behavior. Call it once +% at the start of a complete redraw, not when adding an overlay that should +% preserve the current zoom. +% +% Errors: +% labkit:app:plot:InvalidAxes - ax is not a valid scalar axes handle. +% labkit:app:plot:InvalidOptions or labkit:app:plot:InvalidOption - +% Name-value arguments are malformed or unsupported. MATLAB graphics errors +% raised while clearing a valid axes propagate to the caller. +% +% Example: +% fig = figure("Visible", "off"); +% cleanup = onCleanup(@() close(fig)); +% ax = axes(fig); +% plot(ax, 1:3, [2 1 3]); +% labkit.app.plot.clearAxes(ax, "ResetScale", true); +% assert(isempty(ax.Children)) +% +% See also labkit.app.plot.fitAxesToGraphics, labkit.app.plot.showMessage + + opts = parseAxesOptions(varargin, struct( ... + 'ResetScale', false, ... + 'ClearLegend', true)); + validateAxesHandle(ax, 'clearPlot'); + + children = allchild(ax); + for k = 1:numel(children) + if isgraphics(children(k)) && isvalid(children(k)) + delete(children(k)); + end + end + cla(ax); + clearAxesViewState(ax); + if logical(opts.ClearLegend) + legend(ax, 'off'); + end + hold(ax, 'off'); + ax.XLimMode = 'auto'; + ax.YLimMode = 'auto'; + ax.ZLimMode = 'auto'; + ax.CLimMode = 'auto'; + if logical(opts.ResetScale) + ax.XScale = 'linear'; + ax.YScale = 'linear'; + ax.XTickMode = 'auto'; + ax.YTickMode = 'auto'; + end +end diff --git a/+labkit/+ui/+interaction/enablePopout.m b/+labkit/+app/+plot/enablePopout.m similarity index 95% rename from +labkit/+ui/+interaction/enablePopout.m rename to +labkit/+app/+plot/enablePopout.m index 9d29f643a..ac411bfc8 100644 --- a/+labkit/+ui/+interaction/enablePopout.m +++ b/+labkit/+app/+plot/enablePopout.m @@ -2,7 +2,7 @@ function enablePopout(ax) %ENABLEPOPOUT Add a standalone-figure action to an axes context menu. % % Usage: -% labkit.ui.interaction.enablePopout(ax) +% labkit.app.plot.enablePopout(ax) % % Inputs: % ax - MATLAB axes or uiaxes handle that receives an "Open axes in new @@ -25,9 +25,9 @@ function enablePopout(ax) % % Typical Call: % plot(ax, time, signal); -% labkit.ui.interaction.enablePopout(ax); +% labkit.app.plot.enablePopout(ax); % -% See also labkit.ui.layout.previewArea +% See also labkit.app.layout.plotArea if isempty(ax) || ~isvalid(ax) return; diff --git a/+labkit/+app/+plot/fitAxesToGraphics.m b/+labkit/+app/+plot/fitAxesToGraphics.m new file mode 100644 index 000000000..9a04ad40b --- /dev/null +++ b/+labkit/+app/+plot/fitAxesToGraphics.m @@ -0,0 +1,70 @@ +function limits = fitAxesToGraphics(ax, varargin) +%FITAXESTOGRAPHICS Fit axes limits to finite plotted X/Y data. +% +% Usage: +% limits = labkit.app.plot.fitAxesToGraphics(ax) +% limits = labkit.app.plot.fitAxesToGraphics(ax, graphicsHandles) +% limits = labkit.app.plot.fitAxesToGraphics(..., Name=Value) +% +% Inputs: +% ax - Valid scalar MATLAB axes or uiaxes handle whose limits are changed. +% graphicsHandles - Optional graphics handle array. Objects with numeric +% XData and YData contribute to the fitted range. When omitted, all +% current axes children are examined. +% +% Name-Value Arguments: +% Padding - Nonnegative fractional padding added on each side of the data +% range. Default: 0.02. For logarithmic axes, padding is computed in +% base-10 logarithmic space. +% +% Outputs: +% limits - Scalar struct with x and y fields. Each field contains the applied +% two-element limit, or [] when that dimension had no usable data and was +% returned to automatic limit mode. +% +% Description: +% fit ignores nonfinite XData and YData. Nonpositive values do not contribute +% to a logarithmic dimension. Supplying graphicsHandles lets an app exclude +% annotations such as reference lines from the fitted range. +% +% Errors: +% labkit:app:plot:InvalidAxes - ax is not a valid scalar axes handle. +% labkit:app:plot:InvalidOptions or labkit:app:plot:InvalidOption - +% Name-value arguments are malformed or unsupported. Invalid supplied +% graphics handles are ignored when they do not expose numeric XData/YData. +% +% Example: +% fig = figure("Visible", "off"); +% cleanup = onCleanup(@() close(fig)); +% ax = axes(fig); +% h = plot(ax, [1 2 3], [10 20 15]); +% xline(ax, 100); +% limits = labkit.app.plot.fitAxesToGraphics(ax, h, "Padding", 0); +% assert(isequal(limits.x, [1 3])) +% +% See also labkit.app.plot.clearAxes + + validateAxesHandle(ax, 'fit'); + [handles, opts] = parseFitInputs(ax, varargin); + [xLim, yLim] = finitePlotLimits(ax, handles, opts.Padding); + if isempty(xLim) + xlim(ax, 'auto'); + else + xlim(ax, xLim); + end + if isempty(yLim) + ylim(ax, 'auto'); + else + ylim(ax, yLim); + end + limits = struct('x', xLim, 'y', yLim); +end + +function [handles, opts] = parseFitInputs(ax, args) + handles = allchild(ax); + if ~isempty(args) && ~(ischar(args{1}) || isstring(args{1})) + handles = args{1}; + args = args(2:end); + end + opts = parseAxesOptions(args, struct('Padding', 0.02)); +end diff --git a/+labkit/+app/+plot/fitCanvasToSource.m b/+labkit/+app/+plot/fitCanvasToSource.m new file mode 100644 index 000000000..b4825d951 --- /dev/null +++ b/+labkit/+app/+plot/fitCanvasToSource.m @@ -0,0 +1,167 @@ +function [applied, frame] = fitCanvasToSource(ax, width, height, varargin) +%FITCANVASTOSOURCE Fit a preview axes into a fixed-aspect pixel frame. +% +% Usage: +% [applied, frame] = labkit.app.plot.fitCanvasToSource(ax, width, height) +% [applied, frame] = labkit.app.plot.fitCanvasToSource(..., Name=Value) +% +% Inputs: +% ax - UI axes whose parent is a uigridlayout created for a LabKit preview. +% width - Positive finite source-canvas width in pixels. +% height - Positive finite source-canvas height in pixels. +% +% Name-Value Arguments: +% margin - Preferred empty margin around the frame in pixels. Default: 24. +% maxScale - Largest allowed ratio between displayed and source dimensions. +% Default: 1, so the helper does not enlarge the source canvas. +% +% Outputs: +% applied - true when the grid and axes positions were updated; false when +% the axes, parent grid, dimensions, or available space were unsuitable. +% frame - Scalar struct with width, height, ratio, position, scale, and +% pixelPosition fields. It is an empty struct when applied is false. +% +% Description: +% fitCanvas centers the axes in the middle row and column of a three-by-three +% flexible grid and preserves the requested aspect ratio. The helper keeps +% one managed parent-position listener with the axes and reapplies the most +% recent canvas request after host resizing. It returns false instead of +% throwing when the host has not been laid out yet or is smaller than the +% minimum usable preview area. +% +% Failure Behavior: +% Invalid axes, host grids, source dimensions, or insufficient available +% space return applied=false and frame=struct(). Malformed or unsupported +% name-value arguments throw labkit:app:plot:InvalidOptions or +% labkit:app:plot:InvalidOption. +% +% Typical Call: +% [ok, frame] = labkit.app.plot.fitCanvasToSource(ax, 720, 540); +% +% See also labkit.app.layout.plotArea + + opts = parseOptions(varargin); + frame = struct(); + applied = false; + if isempty(ax) || ~isvalid(ax) + return; + end + parent = ax.Parent; + if isempty(parent) || ~isvalid(parent) || ... + ~contains(class(parent), 'GridLayout') + return; + end + + canvasWidth = finiteScalar(width, NaN); + canvasHeight = finiteScalar(height, NaN); + if ~isfinite(canvasWidth) || ~isfinite(canvasHeight) || ... + canvasWidth <= 0 || canvasHeight <= 0 + return; + end + rememberResizeRequest(ax, canvasWidth, canvasHeight, opts); + + try + drawnow; + parentPixels = getpixelposition(parent, true); + margin = finiteScalar(opts.margin, 24); + maxScale = finiteScalar(opts.maxScale, 1); + availableWidth = max(1, parentPixels(3) - 2 * margin); + availableHeight = max(1, parentPixels(4) - 2 * margin); + if availableWidth < 240 || availableHeight < 180 + return; + end + + scale = min(maxScale, min(availableWidth / canvasWidth, ... + availableHeight / canvasHeight)); + frameWidth = max(1, round(canvasWidth * scale)); + frameHeight = max(1, round(canvasHeight * scale)); + parent.RowHeight = {'1x', frameHeight, '1x'}; + parent.ColumnWidth = {'1x', frameWidth, '1x'}; + ax.Layout.Row = 2; + ax.Layout.Column = 2; + frame = struct( ... + 'width', canvasWidth, ... + 'height', canvasHeight, ... + 'ratio', canvasWidth / canvasHeight, ... + 'position', [NaN NaN frameWidth frameHeight], ... + 'scale', scale, ... + 'pixelPosition', [NaN NaN frameWidth frameHeight]); + applied = true; + catch + frame = struct(); + applied = false; + end +end + +function rememberResizeRequest(ax, width, height, opts) +key = "labkitAppCanvasResize"; +request = struct("width", width, "height", height, ... + "margin", finiteScalar(opts.margin, 24), ... + "maxScale", finiteScalar(opts.maxScale, 1), ... + "listener", []); +if isappdata(ax, key) + current = getappdata(ax, key); + if isstruct(current) && isfield(current, "listener") && ... + ~isempty(current.listener) && isvalid(current.listener) + request.listener = current.listener; + setappdata(ax, key, request); + return + end +end +try + request.listener = addlistener(ax.Parent, "Position", "PostSet", ... + @(~, ~) reflowRememberedCanvas(ax, key)); +catch + request.listener = []; +end +setappdata(ax, key, request); +end + +function reflowRememberedCanvas(ax, key) +guard = key + "InProgress"; +if isempty(ax) || ~isvalid(ax) || ~isappdata(ax, key) || ... + (isappdata(ax, guard) && getappdata(ax, guard)) + return +end +setappdata(ax, guard, true); +cleanup = onCleanup(@() clearResizeGuard(ax, guard)); +request = getappdata(ax, key); +fitCanvasToSource(ax, request.width, request.height, ... + "margin", request.margin, "maxScale", request.maxScale); +end + +function clearResizeGuard(ax, key) +if ~isempty(ax) && isvalid(ax) && isappdata(ax, key) + rmappdata(ax, key); +end +end + +function opts = parseOptions(args) + opts = struct('margin', 24, 'maxScale', 1); + if isempty(args) + return; + end + if mod(numel(args), 2) ~= 0 + error('labkit:app:plot:InvalidOptions', ... + 'fitCanvas options must be name/value pairs.'); + end + for k = 1:2:numel(args) + name = char(string(args{k})); + if ~isfield(opts, name) + error('labkit:app:plot:InvalidOption', ... + 'Unsupported fitCanvas option "%s".', name); + end + opts.(name) = args{k + 1}; + end +end + +function value = finiteScalar(candidate, fallback) + value = fallback; + if isempty(candidate) + return; + end + candidate = double(candidate); + if isscalar(candidate) && isfinite(candidate) + value = candidate; + end +end diff --git a/+labkit/+app/+plot/offsetPointByAxesFraction.m b/+labkit/+app/+plot/offsetPointByAxesFraction.m new file mode 100644 index 000000000..db2d58693 --- /dev/null +++ b/+labkit/+app/+plot/offsetPointByAxesFraction.m @@ -0,0 +1,43 @@ +function xy = offsetPointByAxesFraction(ax, xy, offsetFraction) +%OFFSETPOINTBYAXESFRACTION Move data coordinates by normalized axes fractions. +% +% Usage: +% xyOut = labkit.app.plot.offsetPointByAxesFraction(ax, xy, offsetFraction) +% +% Inputs: +% ax - Valid scalar MATLAB axes or uiaxes handle. Its current limits, scales, +% and directions define the coordinate conversion. +% xy - N-by-2 numeric matrix of [x y] data coordinates. +% offsetFraction - 1-by-2 or N-by-2 axes-fraction offsets. One row is reused +% for every point; otherwise the row count must match xy. For example, +% [0.03 -0.04] moves a label slightly right and down in visual axes +% space, including on log or reversed axes. +% +% Outputs: +% xy - N-by-2 offset coordinates in data units, shown as xyOut in the usage +% syntax. Values are not clamped to the visible axes box. +% +% Description: +% offsetData expresses annotation spacing in visual axes fractions instead of +% data units. This keeps the apparent offset stable as data ranges change and +% correctly handles logarithmic and reversed axes. +% +% Errors: +% labkit:app:plot:InvalidAxes - ax is not a valid scalar axes handle. +% labkit:app:plot:InvalidPointPairs - xy is not an N-by-2 numeric array. +% labkit:app:plot:InvalidPointOffsets - offsetFraction is neither one row nor +% one row per point. +% +% Example: +% fig = figure("Visible", "off"); +% cleanup = onCleanup(@() close(fig)); +% ax = axes(fig, "XLim", [0 10], "YLim", [0 20]); +% xy = labkit.app.plot.offsetPointByAxesFraction(ax, [5 10], [0.1 -0.1]); +% assert(isequal(xy, [6 8])) +% +% See also labkit.app.plot.clampPointToAxes + + uv = dataToFraction(ax, xy); + offsetFraction = normalizePointOffsets(offsetFraction, size(uv, 1)); + xy = fractionToData(ax, uv + offsetFraction); +end diff --git a/+labkit/+app/+plot/private/applyAxesStyleCommand.m b/+labkit/+app/+plot/private/applyAxesStyleCommand.m new file mode 100644 index 000000000..37fe3158a --- /dev/null +++ b/+labkit/+app/+plot/private/applyAxesStyleCommand.m @@ -0,0 +1,113 @@ +% Private App plot helper for copied popout-figure controls. +function applyAxesStyleCommand(ax, command) +if isempty(ax) || ~isvalid(ax) + return; +end +switch string(command) + case "fontIncrease" + adjustFont(ax, 1); + case "fontDecrease" + adjustFont(ax, -1); + case "lineIncrease" + adjustLineWidth(ax, 0.25); + case "lineDecrease" + adjustLineWidth(ax, -0.25); + case "axesIncrease" + adjustAxesLineWidth(ax, 0.25); + case "axesDecrease" + adjustAxesLineWidth(ax, -0.25); + case "gridIncrease" + adjustGrid(ax, 0.10); + case "gridDecrease" + adjustGrid(ax, -0.10); + otherwise + error('labkit:app:plot:InvalidPopoutStyleCommand', ... + 'Unsupported popout style command "%s".', string(command)); +end +drawnow limitrate; +end + +function adjustFont(ax, delta) +handles = fontTargets(ax); +for k = 1:numel(handles) + handle = handles{k}; + if isempty(handle) || ~isvalid(handle) || ~isprop(handle, 'FontSize') + continue; + end + try + handle.FontSize = min(36, max(6, ... + double(handle.FontSize) + delta)); + catch + end +end +end + +function handles = fontTargets(ax) +handles = {ax; ax.Title; ax.XLabel; ax.YLabel; ax.ZLabel}; +parentFig = ancestor(ax, 'figure'); +legends = findall(parentFig, 'Type', 'legend'); +colorbars = findall(parentFig, 'Type', 'colorbar'); +textObjects = findall(ax, 'Type', 'text'); +handles = [handles; num2cell(legends(:)); num2cell(colorbars(:)); ... + num2cell(textObjects(:))]; +end + +function adjustLineWidth(ax, delta) +handles = lineWidthTargets(ax); +for k = 1:numel(handles) + handle = handles(k); + if isempty(handle) || ~isvalid(handle) || ~isprop(handle, 'LineWidth') + continue; + end + try + handle.LineWidth = max( ... + 0.25, double(handle.LineWidth) + delta); + catch + end +end +end + +function adjustAxesLineWidth(ax, delta) +try + ax.LineWidth = max( ... + 0.25, min(5, double(ax.LineWidth) + delta)); +catch +end +end + +function adjustGrid(ax, delta) +try + ax.XGrid = 'on'; + ax.YGrid = 'on'; + if isprop(ax, 'ZGrid') + ax.ZGrid = 'on'; + end + ax.GridAlpha = max( ... + 0.05, min(1, double(ax.GridAlpha) + delta)); + if isprop(ax, 'MinorGridAlpha') + ax.MinorGridAlpha = max( ... + 0.05, min(1, double(ax.MinorGridAlpha) + delta)); + end +catch +end +end + +function handles = lineWidthTargets(ax) +handles = findall(ax, '-property', 'LineWidth'); +keep = false(size(handles)); +for k = 1:numel(handles) + keep(k) = isDataGraphic(handles(k)); +end +handles = handles(keep); +end + +function tf = isDataGraphic(handle) +tf = isa(handle, 'matlab.graphics.chart.primitive.Line') || ... + isa(handle, 'matlab.graphics.chart.primitive.Scatter') || ... + isa(handle, 'matlab.graphics.chart.primitive.Bar') || ... + isa(handle, 'matlab.graphics.chart.primitive.Stem') || ... + isa(handle, 'matlab.graphics.chart.primitive.ErrorBar') || ... + isa(handle, 'matlab.graphics.primitive.Patch') || ... + isa(handle, 'matlab.graphics.primitive.Surface') || ... + isa(handle, 'matlab.graphics.primitive.Image'); +end diff --git a/+labkit/+ui/+plot/private/clearAxesViewState.m b/+labkit/+app/+plot/private/clearAxesViewState.m similarity index 100% rename from +labkit/+ui/+plot/private/clearAxesViewState.m rename to +labkit/+app/+plot/private/clearAxesViewState.m diff --git a/+labkit/+app/+plot/private/createPopoutToolbar.m b/+labkit/+app/+plot/private/createPopoutToolbar.m new file mode 100644 index 000000000..f2918cfd9 --- /dev/null +++ b/+labkit/+app/+plot/private/createPopoutToolbar.m @@ -0,0 +1,104 @@ +% Private App plot helper that installs copied-figure editing controls. +function toolbar = createPopoutToolbar(fig, ax) +toolbar = uipanel(fig, 'Tag', 'labkitAxesPopoutToolbar', ... + 'BorderType', 'none', 'Units', 'normalized', ... + 'Position', [0.00 0.93 1.00 0.07], ... + 'BackgroundColor', [0.94 0.94 0.94]); +labels = ["Font +", "Font -", "Line +", "Line -", ... + "Axes +", "Axes -", "Grid +", "Grid -", "X labels /", ... + "Send to Studio"]; +tags = ["labkitAxesPopoutFontIncreaseTool", ... + "labkitAxesPopoutFontDecreaseTool", ... + "labkitAxesPopoutLineIncreaseTool", ... + "labkitAxesPopoutLineDecreaseTool", ... + "labkitAxesPopoutAxesIncreaseTool", ... + "labkitAxesPopoutAxesDecreaseTool", ... + "labkitAxesPopoutGridIncreaseTool", ... + "labkitAxesPopoutGridDecreaseTool", ... + "labkitAxesPopoutXLabelRotationTool", ... + "labkitAxesPopoutStudioTool"]; +callbacks = { + @(~,~) applyStyleAndLayout(fig, toolbar, ax, "fontIncrease") + @(~,~) applyStyleAndLayout(fig, toolbar, ax, "fontDecrease") + @(~,~) applyStyleAndLayout(fig, toolbar, ax, "lineIncrease") + @(~,~) applyStyleAndLayout(fig, toolbar, ax, "lineDecrease") + @(~,~) applyStyleAndLayout(fig, toolbar, ax, "axesIncrease") + @(~,~) applyStyleAndLayout(fig, toolbar, ax, "axesDecrease") + @(~,~) applyStyleAndLayout(fig, toolbar, ax, "gridIncrease") + @(~,~) applyStyleAndLayout(fig, toolbar, ax, "gridDecrease") + @(tool,~) toggleXLabelRotation(tool, fig, toolbar, ax) + @(~,~) sendToStudio(fig, ax)}; +for k = 1:numel(labels) + addTool(toolbar, labels(k), tags(k), ... + k, numel(labels), callbacks{k}); +end +rotationTool = findobj( ... + toolbar, 'Tag', 'labkitAxesPopoutXLabelRotationTool'); +updateXLabelRotationTool(rotationTool, ax); +layoutPopoutAxes(fig, toolbar, ax); +fig.SizeChangedFcn = @(~,~) layoutPopoutAxes(fig, toolbar, ax); +end + +function tool = addTool(parent, label, tag, index, count, callback) +width = 1 / count; +tool = uicontrol(parent, 'Style', 'pushbutton', ... + 'String', char(label), 'Tag', char(tag), ... + 'Units', 'normalized', ... + 'Position', [(index - 1) * width, 0, width, 1], ... + 'FontSize', 10, 'FontWeight', 'normal', ... + 'BackgroundColor', [0.96 0.96 0.96], ... + 'Callback', callback); +end + +function applyStyleAndLayout(fig, toolbar, ax, command) +applyAxesStyleCommand(ax, command); +layoutPopoutAxes(fig, toolbar, ax); +end + +function toggleXLabelRotation(tool, fig, toolbar, ax) +if abs(ax.XTickLabelRotation) < eps + ax.XTickLabelRotation = 45; +else + ax.XTickLabelRotation = 0; +end +updateXLabelRotationTool(tool, ax); +layoutPopoutAxes(fig, toolbar, ax); +end + +function updateXLabelRotationTool(tool, ax) +if abs(ax.XTickLabelRotation) < eps + tool.String = 'X labels /'; +else + tool.String = 'X labels -'; +end +tool.TooltipString = ... + 'Switch X-axis tick labels between angled and horizontal.'; +end + +function layoutPopoutAxes(fig, toolbar, ax) +if isempty(fig) || ~isvalid(fig) || isempty(toolbar) || ... + ~isvalid(toolbar) || isempty(ax) || ~isvalid(ax) + return; +end +toolbar.Units = 'normalized'; +toolbar.Position = [0.00 0.93 1.00 0.07]; +ax.Units = 'normalized'; +ax.OuterPosition = [0.02 0.02 0.96 0.89]; +ax.ActivePositionProperty = 'outerposition'; +drawnow limitrate; +end + +function sendToStudio(fig, ax) +launcher = []; +if isappdata(groot, 'labkitFigureStudioLauncher') + launcher = getappdata(groot, 'labkitFigureStudioLauncher'); +end +if isa(launcher, 'function_handle') + launcher(ax); + return; +end +setappdata(fig, 'labkitFigureStudioPendingAxes', ax); +warning('labkit:app:plot:FigureStudioUnavailable', ... + ['Figure Studio is not available yet. The copied axes were marked ' ... + 'for Studio handoff.']); +end diff --git a/+labkit/+ui/+plot/private/dataToFraction.m b/+labkit/+app/+plot/private/dataToFraction.m similarity index 100% rename from +labkit/+ui/+plot/private/dataToFraction.m rename to +labkit/+app/+plot/private/dataToFraction.m diff --git a/+labkit/+ui/+plot/private/dataToFraction1D.m b/+labkit/+app/+plot/private/dataToFraction1D.m similarity index 100% rename from +labkit/+ui/+plot/private/dataToFraction1D.m rename to +labkit/+app/+plot/private/dataToFraction1D.m diff --git a/+labkit/+ui/+plot/private/fileContextTitle.m b/+labkit/+app/+plot/private/fileContextTitle.m similarity index 100% rename from +labkit/+ui/+plot/private/fileContextTitle.m rename to +labkit/+app/+plot/private/fileContextTitle.m diff --git a/+labkit/+ui/+plot/private/finitePlotLimits.m b/+labkit/+app/+plot/private/finitePlotLimits.m similarity index 100% rename from +labkit/+ui/+plot/private/finitePlotLimits.m rename to +labkit/+app/+plot/private/finitePlotLimits.m diff --git a/+labkit/+ui/+plot/private/fractionToData.m b/+labkit/+app/+plot/private/fractionToData.m similarity index 100% rename from +labkit/+ui/+plot/private/fractionToData.m rename to +labkit/+app/+plot/private/fractionToData.m diff --git a/+labkit/+ui/+plot/private/fractionToData1D.m b/+labkit/+app/+plot/private/fractionToData1D.m similarity index 100% rename from +labkit/+ui/+plot/private/fractionToData1D.m rename to +labkit/+app/+plot/private/fractionToData1D.m diff --git a/+labkit/+ui/+plot/private/normalizePointOffsets.m b/+labkit/+app/+plot/private/normalizePointOffsets.m similarity index 90% rename from +labkit/+ui/+plot/private/normalizePointOffsets.m rename to +labkit/+app/+plot/private/normalizePointOffsets.m index 60eb9226a..dfa01b40c 100644 --- a/+labkit/+ui/+plot/private/normalizePointOffsets.m +++ b/+labkit/+app/+plot/private/normalizePointOffsets.m @@ -7,7 +7,7 @@ offsets = repmat(offsets, count, 1); end if size(offsets, 1) ~= count - error('labkit:ui:plot:InvalidPointOffsets', ... + error('labkit:app:plot:InvalidPointOffsets', ... 'offsetFraction must have one row or match the number of points.'); end end diff --git a/+labkit/+ui/+plot/private/parseAxesOptions.m b/+labkit/+app/+plot/private/parseAxesOptions.m similarity index 85% rename from +labkit/+ui/+plot/private/parseAxesOptions.m rename to +labkit/+app/+plot/private/parseAxesOptions.m index 773737be2..d84fc37c6 100644 --- a/+labkit/+ui/+plot/private/parseAxesOptions.m +++ b/+labkit/+app/+plot/private/parseAxesOptions.m @@ -7,13 +7,13 @@ return; end if mod(numel(args), 2) ~= 0 - error('labkit:ui:plot:InvalidOptions', ... + error('labkit:app:plot:InvalidOptions', ... 'Axes helper options must be name/value pairs.'); end for k = 1:2:numel(args) name = char(string(args{k})); if ~isfield(opts, name) - error('labkit:ui:plot:InvalidOption', ... + error('labkit:app:plot:InvalidOption', ... 'Unsupported axes helper option "%s".', name); end opts.(name) = args{k + 1}; diff --git a/+labkit/+ui/+interaction/private/popoutAxes.m b/+labkit/+app/+plot/private/popoutAxes.m similarity index 94% rename from +labkit/+ui/+interaction/private/popoutAxes.m rename to +labkit/+app/+plot/private/popoutAxes.m index ba6ad3f86..335c64b60 100644 --- a/+labkit/+ui/+interaction/private/popoutAxes.m +++ b/+labkit/+app/+plot/private/popoutAxes.m @@ -18,7 +18,7 @@ opts = parseOptions(varargin); if isempty(srcAx) || ~isvalid(srcAx) - error('labkit:ui:InvalidAxes', 'Source axes is not valid.'); + error('labkit:app:plot:InvalidAxes', 'Source axes is not valid.'); end titleText = string(opts.Title); @@ -54,7 +54,7 @@ function opts = parseOptions(args) if mod(numel(args), 2) ~= 0 - error('labkit:ui:InvalidPopoutOptions', ... + error('labkit:app:plot:InvalidPopoutOptions', ... 'popout options must be name-value pairs.'); end opts = struct('Toolbar', true, 'Title', ""); @@ -66,11 +66,11 @@ case "title" opts.Title = string(args{k + 1}); if ~isscalar(opts.Title) - error('labkit:ui:InvalidPopoutOptions', ... + error('labkit:app:plot:InvalidPopoutOptions', ... 'Title must be scalar text.'); end otherwise - error('labkit:ui:InvalidPopoutOptions', ... + error('labkit:app:plot:InvalidPopoutOptions', ... 'Unsupported popout option "%s".', char(name)); end end @@ -78,7 +78,7 @@ function value = logicalScalar(value, name) if ~(islogical(value) || isnumeric(value)) || ~isscalar(value) - error('labkit:ui:InvalidPopoutOptions', ... + error('labkit:app:plot:InvalidPopoutOptions', ... '%s must be a logical scalar.', name); end value = logical(value); diff --git a/+labkit/+ui/+plot/private/showImage.m b/+labkit/+app/+plot/private/showImage.m similarity index 99% rename from +labkit/+ui/+plot/private/showImage.m rename to +labkit/+app/+plot/private/showImage.m index 16d9b0aab..a2df7ccbb 100644 --- a/+labkit/+ui/+plot/private/showImage.m +++ b/+labkit/+app/+plot/private/showImage.m @@ -76,7 +76,7 @@ if optionValue(opts, 'enableNavigation', true) enableImageNavigation(ax); end - labkit.ui.interaction.enablePopout(ax); + labkit.app.plot.enablePopout(ax); end function hImage = reusableImage(ax, reuseImage) diff --git a/+labkit/+ui/+plot/private/validateAxesHandle.m b/+labkit/+app/+plot/private/validateAxesHandle.m similarity index 91% rename from +labkit/+ui/+plot/private/validateAxesHandle.m rename to +labkit/+app/+plot/private/validateAxesHandle.m index efad5e6a4..8fba05ce7 100644 --- a/+labkit/+ui/+plot/private/validateAxesHandle.m +++ b/+labkit/+app/+plot/private/validateAxesHandle.m @@ -5,7 +5,7 @@ function validateAxesHandle(ax, callerName) if ~(isscalar(ax) && isgraphics(ax) && ... (isa(ax, 'matlab.graphics.axis.Axes') || ... isa(ax, 'matlab.ui.control.UIAxes'))) - error('labkit:ui:plot:InvalidAxes', ... + error('labkit:app:plot:InvalidAxes', ... '%s requires a valid scalar axes handle.', char(string(callerName))); end end diff --git a/+labkit/+ui/+plot/private/validatePointPairs.m b/+labkit/+app/+plot/private/validatePointPairs.m similarity index 88% rename from +labkit/+ui/+plot/private/validatePointPairs.m rename to +labkit/+app/+plot/private/validatePointPairs.m index be0a4e194..270cb21e2 100644 --- a/+labkit/+ui/+plot/private/validatePointPairs.m +++ b/+labkit/+app/+plot/private/validatePointPairs.m @@ -3,7 +3,7 @@ % double matrix. function points = validatePointPairs(points, name) if ~(isnumeric(points) && ismatrix(points) && size(points, 2) == 2) - error('labkit:ui:plot:InvalidPointPairs', ... + error('labkit:app:plot:InvalidPointPairs', ... '%s must be an N-by-2 numeric matrix.', char(string(name))); end points = double(points); diff --git a/+labkit/+app/+plot/showMessage.m b/+labkit/+app/+plot/showMessage.m new file mode 100644 index 000000000..3627c9dc0 --- /dev/null +++ b/+labkit/+app/+plot/showMessage.m @@ -0,0 +1,58 @@ +function hText = showMessage(ax, message, varargin) +%SHOWMESSAGE Show a centered empty-state message in an axes. +% +% Usage: +% hText = labkit.app.plot.showMessage(ax, message) +% hText = labkit.app.plot.showMessage(ax, message, Name=Value) +% +% Inputs: +% ax - Valid scalar MATLAB axes or uiaxes handle. +% message - User-visible text scalar displayed at the axes center. +% +% Name-Value Arguments: +% Title - Axes title text. Default: "". +% Color - MATLAB color value for the message text. Default: +% [0.30 0.30 0.30]. +% +% Outputs: +% hText - MATLAB text object containing the message. +% +% Description: +% message clears the axes, resets it to linear unit limits [0 1], removes +% ticks, and draws non-pickable text. Use it for an empty preview, loading +% prompt, or unavailable result. Because it performs a complete clear, it is +% not an overlay operation and does not preserve the previous zoom. +% +% Errors: +% labkit:app:plot:InvalidAxes - ax is not a valid scalar axes handle. +% labkit:app:plot:InvalidOptions or labkit:app:plot:InvalidOption - +% Name-value arguments are malformed or unsupported. Invalid MATLAB text or +% color values propagate their originating graphics error. +% +% Example: +% fig = figure("Visible", "off"); +% cleanup = onCleanup(@() close(fig)); +% ax = axes(fig); +% h = labkit.app.plot.showMessage(ax, "No data", "Title", "Preview"); +% assert(string(h.String) == "No data") +% +% See also labkit.app.plot.clearAxes + + opts = parseAxesOptions(varargin, struct( ... + 'Title', "", ... + 'Color', [0.30 0.30 0.30])); + labkit.app.plot.clearAxes(ax, 'ResetScale', true); + xlim(ax, [0 1]); + ylim(ax, [0 1]); + ax.XTick = []; + ax.YTick = []; + title(ax, char(string(opts.Title)), 'Interpreter', 'none'); + hText = text(ax, 0.5, 0.5, string(message), ... + 'Units', 'normalized', ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'middle', ... + 'Color', opts.Color, ... + 'Interpreter', 'none', ... + 'HitTest', 'off', ... + 'PickableParts', 'none'); +end diff --git a/+labkit/+app/+project/Schema.m b/+labkit/+app/+project/Schema.m new file mode 100644 index 000000000..350b87763 --- /dev/null +++ b/+labkit/+app/+project/Schema.m @@ -0,0 +1,158 @@ +classdef (Sealed) Schema + %PROJECTCONTRACT Declare one durable App project contract. + % + % Usage: + % contract = labkit.app.project.Schema() + % contract = labkit.app.project.Schema(Name=Value) + % + % Description: + % Project Schema owns payload creation, validation, sequential + % migration, optional legacy import, resume, and source-relink callback + % signatures. App-specific fields and scientific meaning remain inside + % the payload and are not interpreted by this value. + % + % Default Contract: + % With no arguments, Version is 1, Create returns struct(), and + % Validate accepts any scalar struct. This is the standard path for a + % simple App with no migration or specialized project invariants. + % + % Required Name-Value Arguments (custom contract): + % Version - Positive integer payload version. + % Create - Fixed callback project = create(). + % Validate - Fixed callback accepted = validate(project). + % + % Optional Name-Value Arguments: + % Migrate - Fixed callback project = migrate(project,fromVersion). + % Required when Version is greater than 1. Default: empty. + % LegacyImports - Scalar struct mapping legacy MAT variable names to + % fixed callbacks accepting one value and returning project or + % [project,resume]. Default: struct(). + % CreateResume - Fixed callback resume = createResume(session,project). + % Default: empty. + % ApplyResume - Fixed callback + % session = applyResume(session,resume,project). Default: empty. + % RelinkSources - Fixed callback + % project = relink(project,unresolved,projectFile). Returning empty + % cancels the load. Default: empty. + % + % Outputs: + % contract - Immutable labkit.app.project.Schema value. + % + % Errors: + % labkit:app:contract:UnknownArgument - An option is missing, unknown, + % duplicated, or unpaired. + % labkit:app:contract:InvalidValue - Version or LegacyImports is invalid. + % labkit:app:contract:CallbackRoleMismatch - A callback does not have its + % documented fixed input and output count. + % + % Typical Call: + % contract = labkit.app.project.Schema(Version=1, ... + % Create=@createProject, Validate=@validateProject); + % + % See also labkit.app.Definition, labkit.app.result.Package + + properties (SetAccess = immutable) + Version (1, 1) double + Create + Validate + Migrate + LegacyImports (1, 1) struct + CreateResume + ApplyResume + RelinkSources + end + + methods + function obj = Schema(varargin) + names = ["Version", "Create", "Validate", "Migrate", ... + "LegacyImports", "CreateResume", "ApplyResume", ... + "RelinkSources"]; + if isempty(varargin) + varargin = {"Version", 1, "Create", @createProject, ... + "Validate", @validateProject}; + end + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.project.Schema", names, varargin{:}); + for name = ["Version", "Create", "Validate"] + if ~isfield(options, name) + error("labkit:app:contract:UnknownArgument", ... + "labkit.app.project.Schema requires argument %s.", ... + name); + end + end + + version = options.Version; + if ~(isnumeric(version) && isscalar(version) && ... + isfinite(version) && version >= 1 && version == fix(version)) + error("labkit:app:contract:InvalidValue", ... + "Project Schema Version must be a positive integer."); + end + obj.Version = double(version); + obj.Create = fixedCallback(options.Create, 0, 1, "Create"); + obj.Validate = fixedCallback( ... + options.Validate, 1, 1, "Validate"); + obj.Migrate = optionalCallback(options, "Migrate", 2, 1); + if obj.Version > 1 && isempty(obj.Migrate) + error("labkit:app:contract:InvalidValue", ... + "Project Schema Migrate is required above Version 1."); + end + obj.LegacyImports = legacyImports( ... + optionValue(options, "LegacyImports", struct())); + obj.CreateResume = optionalCallback( ... + options, "CreateResume", 2, 1); + obj.ApplyResume = optionalCallback( ... + options, "ApplyResume", 3, 1); + obj.RelinkSources = optionalCallback( ... + options, "RelinkSources", 3, 1); + end + end +end + +function callback = optionalCallback(options, name, inputs, outputs) + callback = []; + if isfield(options, name) && ~isempty(options.(name)) + callback = fixedCallback(options.(name), inputs, outputs, name); + end +end + +function callback = fixedCallback(callback, inputs, outputs, role) + if ~isa(callback, "function_handle") || ~isscalar(callback) || ... + nargin(callback) ~= inputs || nargout(callback) ~= outputs + error("labkit:app:contract:CallbackRoleMismatch", ... + "Project Schema %s requires %d inputs and %d outputs.", ... + role, inputs, outputs); + end +end + +function imports = legacyImports(imports) + if ~isstruct(imports) || ~isscalar(imports) + error("labkit:app:contract:InvalidValue", ... + "Project Schema LegacyImports must be a scalar struct."); + end + names = string(fieldnames(imports)); + for k = 1:numel(names) + callback = imports.(names(k)); + outputs = nargout(callback); + if ~isa(callback, "function_handle") || ~isscalar(callback) || ... + nargin(callback) ~= 1 || ~any(outputs == [1 2]) + error("labkit:app:contract:CallbackRoleMismatch", ... + "Project Schema legacy import %s requires one input and " + ... + "one or two outputs.", names(k)); + end + end +end + +function value = optionValue(options, name, defaultValue) + value = defaultValue; + if isfield(options, name) + value = options.(name); + end +end + +function project = createProject() + project = struct(); +end + +function accepted = validateProject(project) + accepted = isstruct(project) && isscalar(project); +end diff --git a/+labkit/+app/+project/emptySourceRecords.m b/+labkit/+app/+project/emptySourceRecords.m new file mode 100644 index 000000000..4114074d7 --- /dev/null +++ b/+labkit/+app/+project/emptySourceRecords.m @@ -0,0 +1,30 @@ +function records = emptySourceRecords() +%EMPTYSOURCERECORDS Create an empty portable source collection. +% +% Usage: +% records = labkit.app.project.emptySourceRecords() +% +% Description: +% Returns a zero-row struct collection with the same durable field shape +% as labkit.app.project.sourceRecord. App project factories use this value +% so the first file selection can assign a portable source without exposing +% or duplicating its field schema. +% +% Outputs: +% records - Empty column collection of portable source records. +% +% Failure Behavior: +% None - The function accepts no inputs and returns the canonical empty +% collection deterministically. +% +% Example: +% project.inputs.sources = labkit.app.project.emptySourceRecords(); +% assert(isempty(project.inputs.sources)) +% +% See also labkit.app.project.sourceRecord, +% labkit.app.project.Schema + +prototype = labkit.app.project.sourceRecord( ... + "placeholder", "placeholder", "placeholder", true); +records = repmat(prototype, 0, 1); +end diff --git a/+labkit/+app/+project/sourceRecord.m b/+labkit/+app/+project/sourceRecord.m new file mode 100644 index 000000000..cf5b570f3 --- /dev/null +++ b/+labkit/+app/+project/sourceRecord.m @@ -0,0 +1,82 @@ +function record = sourceRecord(id, role, filepath, required) +%SOURCERECORD Create a portable source value during project migration. +% +% Usage: +% record = labkit.app.project.sourceRecord(id,role,filepath) +% record = labkit.app.project.sourceRecord(id,role,filepath,required) +% +% Description: +% Creates the opaque durable source value accepted by App project schemas. +% Use this pure constructor only while creating or migrating payloads. +% Runtime callbacks resolve paths through CallbackContext. +% +% Inputs: +% id - Nonempty scalar text stable within the project. +% role - Nonempty scalar semantic source role. +% filepath - Nonempty scalar source path, or an existing scalar opaque +% reference struct during legacy migration. +% required - Optional logical scalar relinking requirement. Default: true. +% +% Outputs: +% record - Scalar portable source struct owned by the App framework. +% +% Errors: +% labkit:app:contract:InvalidValue - Text or required is malformed. +% +% Example: +% source = labkit.app.project.sourceRecord( ... +% "image1","cropSource","image.png"); +% assert(source.id == "image1") +% +% See also labkit.app.CallbackContext, labkit.app.project.Schema +if nargin < 4 + required = true; +end +id = nonemptyText(id, "id"); +role = nonemptyText(role, "role"); +if ~(islogical(required) && isscalar(required)) + invalid("required must be a logical scalar."); +end +if isstruct(filepath) + reference = portableReference(filepath); +else + filepath = nonemptyText(filepath, "filepath"); + [~, name, extension] = fileparts(filepath); + reference = struct("schemaVersion", 1, "relativePath", "", ... + "originalPath", filepath, "fileName", string(name) + string(extension)); +end +record = struct("id", id, "required", required, "role", role, ... + "reference", {reference}); +end + +function reference = portableReference(value) +required = ["schemaVersion", "relativePath", "originalPath", "fileName"]; +if ~isscalar(value) || ~all(isfield(value, cellstr(required))) + invalid("filepath reference must be a scalar portable source reference."); +end +reference = struct( ... + "schemaVersion", double(value.schemaVersion), ... + "relativePath", string(value.relativePath), ... + "originalPath", string(value.originalPath), ... + "fileName", string(value.fileName)); +if ~(isscalar(reference.schemaVersion) && ... + isfinite(reference.schemaVersion) && reference.schemaVersion == 1) || ... + ~all(arrayfun(@(name) isscalar(reference.(name)), ... + ["relativePath", "originalPath", "fileName"])) + invalid("filepath reference is malformed."); +end +end + +function value = nonemptyText(value, name) +if ~(ischar(value) || (isstring(value) && isscalar(value))) + invalid("%s must be scalar text.", name); +end +value = string(value); +if strlength(value) == 0 + invalid("%s must be nonempty.", name); +end +end + +function invalid(message, varargin) +error("labkit:app:contract:InvalidValue", message, varargin{:}); +end diff --git a/+labkit/+app/+result/File.m b/+labkit/+app/+result/File.m new file mode 100644 index 000000000..eddc00ef8 --- /dev/null +++ b/+labkit/+app/+result/File.m @@ -0,0 +1,127 @@ +classdef (Sealed) File + %RESULTOUTPUT Declare one validated result-package output. + % + % Usage: + % output = labkit.app.result.File(id, role, relativePath, Name=Value) + % + % Description: + % File records App-owned output identity and media semantics. + % Runtime result writing later resolves the relative path, file size, + % checksum, and aggregate manifest status. + % + % Inputs: + % id - Nonempty scalar text unique within one Result. + % role - Nonempty scalar text describing output purpose. + % relativePath - Nonempty package-relative path without traversal. + % + % Optional Name-Value Arguments: + % MediaType - Nonempty media-type scalar text. Default: + % "application/octet-stream". + % Status - "success", "failed", or "skipped". Default: "success". + % Message - Scalar reader-facing status text. Default: empty. + % Warnings - Row string or cellstr array. Default: empty. + % + % Outputs: + % output - Immutable labkit.app.result.File value. + % + % Errors: + % labkit:app:contract:UnknownArgument - An option is unknown, duplicated, + % or unpaired. + % labkit:app:contract:InvalidValue - Text, path, status, or warnings are + % malformed. + % + % Example: + % output = labkit.app.result.File( ... + % "summary", "primary", "summary.csv", MediaType="text/csv"); + % assert(output.Status == "success") + % + % See also labkit.app.result.Package, labkit.app.CallbackContext + + properties (SetAccess = immutable) + Id (1, 1) string + Role (1, 1) string + RelativePath (1, 1) string + MediaType (1, 1) string + Status (1, 1) string + Message (1, 1) string + Warnings (1, :) string + end + + methods + function obj = File(id, role, relativePath, varargin) + names = ["MediaType", "Status", "Message", "Warnings"]; + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.result.File", names, varargin{:}); + obj.Id = nonemptyText(id, "id"); + obj.Role = nonemptyText(role, "role"); + obj.RelativePath = relativePathValue(relativePath); + obj.MediaType = nonemptyText(optionValue(options, ... + "MediaType", "application/octet-stream"), "MediaType"); + obj.Status = statusValue( ... + optionValue(options, "Status", "success")); + obj.Message = scalarText( ... + optionValue(options, "Message", ""), "Message"); + obj.Warnings = textRow( ... + optionValue(options, "Warnings", strings(1, 0)), ... + "Warnings"); + end + end +end + +function value = relativePathValue(value) + value = replace(nonemptyText(value, "relativePath"), "\", "/"); + if startsWith(value, "/") || startsWith(value, "//") || ... + ~isempty(regexp(char(value), '^[A-Za-z]:', "once")) + error("labkit:app:contract:InvalidValue", ... + "File relativePath must be package-relative."); + end + parts = split(value, "/"); + if any(parts == ["", ".", ".."], "all") + error("labkit:app:contract:InvalidValue", ... + "File relativePath must not traverse package boundaries."); + end + value = join(parts, "/"); +end + +function value = statusValue(value) + value = nonemptyText(value, "Status"); + if ~any(value == ["success", "failed", "skipped"]) + error("labkit:app:contract:InvalidValue", ... + "File Status must be success, failed, or skipped."); + end +end + +function value = nonemptyText(value, name) + value = scalarText(value, name); + if strlength(value) == 0 + error("labkit:app:contract:InvalidValue", ... + "File %s must be nonempty.", name); + end +end + +function value = scalarText(value, name) + if ~(ischar(value) || (isstring(value) && isscalar(value))) + error("labkit:app:contract:InvalidValue", ... + "File %s must be scalar text.", name); + end + value = string(value); +end + +function values = textRow(values, name) + if ischar(values) + values = string(values); + elseif iscellstr(values) + values = string(values); + elseif ~isstring(values) + error("labkit:app:contract:InvalidValue", ... + "File %s must be text.", name); + end + values = reshape(values, 1, []); +end + +function value = optionValue(options, name, defaultValue) + value = defaultValue; + if isfield(options, name) + value = options.(name); + end +end diff --git a/+labkit/+app/+result/Package.m b/+labkit/+app/+result/Package.m new file mode 100644 index 000000000..965f2251f --- /dev/null +++ b/+labkit/+app/+result/Package.m @@ -0,0 +1,141 @@ +classdef (Sealed) Package + %RESULT Declare one App-owned result manifest request. + % + % Usage: + % result = labkit.app.result.Package(Name=Value) + % + % Description: + % Result contains validated output declarations and App-owned input, + % parameter, and summary data. Runtime result writing owns provenance, + % App and facade versions, document identity, file bytes, checksums, + % creation identity/time, aggregate status, and atomic replacement. + % + % Required Name-Value Arguments: + % Outputs - Nonempty row cell array of labkit.app.result.File values + % with unique IDs and relative paths and at least one success. + % Inputs - Scalar App-owned struct describing result inputs. + % Parameters - Scalar App-owned struct describing result parameters. + % Summary - Scalar App-owned struct describing result summary. + % + % Optional Name-Value Arguments: + % Warnings - Row string or cellstr array. Default: empty. + % ManifestName - Nonempty filename without folders. Default: + % "labkit_result.json". + % + % Outputs: + % result - Immutable labkit.app.result.Package value. + % + % Errors: + % labkit:app:contract:UnknownArgument - An option is missing, unknown, + % duplicated, or unpaired. + % labkit:app:contract:InvalidValue - A value or manifest name is invalid. + % labkit:app:contract:DuplicateId - Output IDs or paths repeat. + % + % Example: + % output = labkit.app.result.File( ... + % "summary", "primary", "summary.csv", MediaType="text/csv"); + % result = labkit.app.result.Package(Outputs={output}, Inputs=struct(), ... + % Parameters=struct(), Summary=struct("rows", 1)); + % assert(result.ManifestName == "labkit_result.json") + % + % See also labkit.app.result.File, labkit.app.CallbackContext + + properties (SetAccess = immutable) + Outputs (1, :) cell + Inputs (1, 1) struct + Parameters (1, 1) struct + Summary (1, 1) struct + Warnings (1, :) string + ManifestName (1, 1) string + end + + methods + function obj = Package(varargin) + names = ["Outputs", "Inputs", "Parameters", "Summary", ... + "Warnings", "ManifestName"]; + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.result.Package", names, varargin{:}); + for name = ["Outputs", "Inputs", "Parameters", "Summary"] + if ~isfield(options, name) + error("labkit:app:contract:UnknownArgument", ... + "labkit.app.result.Package requires argument %s.", name); + end + end + obj.Outputs = outputsValue(options.Outputs); + obj.Inputs = scalarStruct(options.Inputs, "Inputs"); + obj.Parameters = scalarStruct(options.Parameters, "Parameters"); + obj.Summary = scalarStruct(options.Summary, "Summary"); + obj.Warnings = textRow( ... + optionValue(options, "Warnings", strings(1, 0))); + obj.ManifestName = manifestName(optionValue( ... + options, "ManifestName", "labkit_result.json")); + end + end +end + +function values = outputsValue(values) + if ~iscell(values) || isempty(values) || ~isrow(values) || ... + ~all(cellfun(@(value) isa(value, ... + "labkit.app.result.File"), values)) + error("labkit:app:contract:InvalidValue", ... + "Result Outputs must be a nonempty row cell array of result File."); + end + ids = string(cellfun(@(value) value.Id, values, ... + "UniformOutput", false)); + paths = string(cellfun(@(value) value.RelativePath, values, ... + "UniformOutput", false)); + if numel(unique(ids)) ~= numel(ids) + error("labkit:app:contract:DuplicateId", ... + "Result output IDs must be unique."); + end + if numel(unique(lower(paths))) ~= numel(paths) + error("labkit:app:contract:DuplicateId", ... + "Result output relative paths must be unique."); + end + statuses = string(cellfun(@(value) value.Status, values, ... + "UniformOutput", false)); + if ~any(statuses == "success") + error("labkit:app:contract:InvalidValue", ... + "Result requires at least one successful output."); + end +end + +function value = scalarStruct(value, name) + if ~isstruct(value) || ~isscalar(value) + error("labkit:app:contract:InvalidValue", ... + "Result %s must be a scalar App-owned struct.", name); + end +end + +function values = textRow(values) + if ischar(values) + values = string(values); + elseif iscellstr(values) + values = string(values); + elseif ~isstring(values) + error("labkit:app:contract:InvalidValue", ... + "Result Warnings must be text."); + end + values = reshape(values, 1, []); +end + +function value = manifestName(value) + if ~(ischar(value) || (isstring(value) && isscalar(value))) + error("labkit:app:contract:InvalidValue", ... + "Result ManifestName must be scalar text."); + end + value = string(value); + [folder, name, extension] = fileparts(value); + if strlength(value) == 0 || strlength(string(folder)) > 0 || ... + strlength(string(name)) == 0 || string(extension) ~= ".json" + error("labkit:app:contract:InvalidValue", ... + "Result ManifestName must be a JSON filename without folders."); + end +end + +function value = optionValue(options, name, defaultValue) + value = defaultValue; + if isfield(options, name) + value = options.(name); + end +end diff --git a/+labkit/+app/+view/Snapshot.m b/+labkit/+app/+view/Snapshot.m new file mode 100644 index 000000000..d518e794a --- /dev/null +++ b/+labkit/+app/+view/Snapshot.m @@ -0,0 +1,378 @@ +classdef (Sealed) Snapshot + %SNAPSHOT Build one immutable complete visible-state snapshot. + % + % Usage: + % view = labkit.app.view.Snapshot() + % view = view.value(target, value) + % view = view.choices(target, choices) + % view = view.limits(target, limits) + % view = view.enabled(target, enabled) + % view = view.visible(target, visible) + % view = view.text(target, text) + % view = view.filePaths(target, paths) + % view = view.fileItemStatuses(target, statuses) + % view = view.listSelection(target, selection) + % view = view.tableCellSelection(target, selection) + % view = view.tableData(target, data, Name=Value) + % view = view.renderPlot(target, model) + % view = view.workspacePage(target, Name=Value) + % view = view.anchorPath(interaction, points, Name=Value) + % view = view.pairedAnchors(interaction, pointSets, Name=Value) + % view = view.pointSlots(interaction, value, Name=Value) + % view = view.rectangle(interaction, position, Name=Value) + % view = view.regionSelection(interaction, Name=Value) + % view = view.interval(interaction, range, Name=Value) + % view = view.scaleReference(interaction, endpoints, Name=Value) + % view = view.include(fragment) + % + % Description: + % View snapshot is a closed immutable operation vocabulary. One value + % describes the complete current visible state; Definition validates + % targets, capabilities, renderer references, and completeness before a + % runtime may reconcile it. Apps do not author patches or generic + % target/property setters. + % + % Inputs: + % target - Nonempty Layout target ID. + % value - Target value owned by the App state. + % choices - Text array or cellstr of legal choices. + % limits - Increasing finite two-element numeric row. + % enabled - Logical scalar availability. + % visible - Logical scalar visibility. + % text - Scalar text. + % paths - String or cell array of file paths. + % statuses - Empty or one reader-facing status per file-list row. + % selection - Selection value accepted by the target. + % data - App-owned table, numeric array, or cell array. + % model - App-owned model passed to the renderer declared by plotArea. + % + % Outputs: + % view - New immutable labkit.app.view.Snapshot snapshot. + % fragment - Another Snapshot whose non-duplicate operations should be + % included in this snapshot. + % + % Errors: + % labkit:app:contract:InvalidValue - An operation argument is malformed. + % labkit:app:contract:DuplicateId - The same operation is supplied twice + % for one target. + % + % Example: + % view = labkit.app.view.Snapshot(); + % view = view.choices("group", ["A", "B"]); + % view = view.value("group", "A"); + % assert(isa(view, "labkit.app.view.Snapshot")) + % + % See also labkit.app.Definition, labkit.app.layout.workbench + + properties (SetAccess = private, GetAccess = private) + Operations (1, :) cell + end + + methods + function obj = Snapshot() + obj.Operations = {}; + end + + function obj = value(obj, target, value) + obj = append(obj, "value", target, value); + end + + function obj = choices(obj, target, choices) + if ischar(choices) + choices = string(choices); + elseif iscellstr(choices) + choices = string(choices); + elseif ~isstring(choices) + error("labkit:app:contract:InvalidValue", ... + "View snapshot choices must be text."); + end + obj = append(obj, "choices", target, reshape(choices, 1, [])); + end + + function obj = limits(obj, target, limits) + if ~(isnumeric(limits) && isequal(size(limits), [1 2]) && ... + all(isfinite(limits)) && limits(1) <= limits(2)) + error("labkit:app:contract:InvalidValue", ... + "View snapshot limits must be an increasing finite 1-by-2 row."); + end + obj = append(obj, "limits", target, limits); + end + + function obj = enabled(obj, target, enabled) + obj = append(obj, "enabled", target, ... + logicalScalar(enabled, "enabled")); + end + + function obj = visible(obj, target, visible) + obj = append(obj, "visible", target, ... + logicalScalar(visible, "visible")); + end + + function obj = text(obj, target, text) + if ~(ischar(text) || (isstring(text) && isscalar(text))) + error("labkit:app:contract:InvalidValue", ... + "View snapshot text must be scalar text."); + end + obj = append(obj, "text", target, string(text)); + end + + function obj = filePaths(obj, target, paths) + if ischar(paths) + paths = string(paths); + elseif iscellstr(paths) + paths = string(paths); + elseif ~isstring(paths) + error("labkit:app:contract:InvalidValue", ... + "View snapshot files must be a string or cell array."); + end + obj = append(obj, "filePaths", ... + target, reshape(paths, 1, [])); + end + + function obj = fileItemStatuses(obj, target, statuses) + statuses = textRow(statuses, "file item statuses"); + obj = append(obj, "fileItemStatuses", target, statuses); + end + + function obj = listSelection(obj, target, selection) + if ~isa(selection, "labkit.app.event.ListSelection") + error("labkit:app:contract:InvalidValue", ... + "ViewSnapshot listSelection requires ListSelection."); + end + obj = append(obj, "listSelection", target, selection); + end + + function obj = tableCellSelection(obj, target, selection) + if ~isa(selection, "labkit.app.event.TableCellSelection") + error("labkit:app:contract:InvalidValue", ... + "ViewSnapshot tableCellSelection requires " + ... + "TableCellSelection."); + end + obj = append(obj, ... + "tableCellSelection", target, selection); + end + + function obj = tableData(obj, target, data, varargin) + if ~(istable(data) || isnumeric(data) || iscell(data)) + error("labkit:app:contract:InvalidValue", ... + "View snapshot table data has an unsupported type."); + end + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.view.Snapshot.tableData", ... + ["Columns", "RowNames", "ColumnEditable"], varargin{:}); + columns = textRow(optionValue( ... + options, "Columns", strings(1, 0)), "Columns"); + editable = logicalRow(optionValue( ... + options, "ColumnEditable", false), "ColumnEditable"); + assertEditableWidth(editable, columns); + value = struct( ... + "Data", {data}, "Columns", columns, ... + "RowNames", textRow(optionValue( ... + options, "RowNames", strings(1, 0)), "RowNames"), ... + "ColumnEditable", editable); + obj = append(obj, "tableData", target, value); + end + + function obj = renderPlot(obj, target, model) + obj = append(obj, "renderPlot", target, model); + end + + function obj = workspacePage(obj, target, varargin) + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.view.Snapshot.workspacePage", ... + ["Enabled", "Status"], varargin{:}); + enabled = true; + if isfield(options, "Enabled") + enabled = logicalScalar(options.Enabled, "Enabled"); + end + status = ""; + if isfield(options, "Status") + supplied = options.Status; + if ~(ischar(supplied) || ... + (isstring(supplied) && isscalar(supplied))) + error("labkit:app:contract:InvalidValue", ... + "View snapshot workspace Status must be scalar text."); + end + status = string(supplied); + end + obj = append(obj, "workspacePage", target, ... + struct("Enabled", enabled, "Status", status)); + end + + function obj = anchorPath(obj, interaction, points, varargin) + obj = appendInteraction( ... + obj, "anchorPath", interaction, points, varargin{:}); + end + + function obj = pairedAnchors(obj, interaction, pointSets, varargin) + obj = appendInteraction( ... + obj, "pairedAnchors", interaction, pointSets, varargin{:}); + end + + function obj = pointSlots(obj, interaction, value, varargin) + obj = appendInteraction( ... + obj, "pointSlots", interaction, value, varargin{:}); + end + + function obj = rectangle(obj, interaction, position, varargin) + obj = appendInteraction( ... + obj, "rectangle", interaction, position, varargin{:}); + end + + function obj = regionSelection(obj, interaction, varargin) + obj = appendInteraction( ... + obj, "regionSelection", interaction, [], varargin{:}); + end + + function obj = interval(obj, interaction, range, varargin) + obj = appendInteraction( ... + obj, "interval", interaction, range, varargin{:}); + end + + function obj = scaleReference(obj, interaction, endpoints, varargin) + obj = appendInteraction( ... + obj, "scaleReference", interaction, endpoints, varargin{:}); + end + + function obj = include(obj, fragment) + %INCLUDE Compose a feature-owned snapshot fragment. + if ~isa(fragment, "labkit.app.view.Snapshot") + error("labkit:app:contract:InvalidValue", ... + "View snapshot include requires another Snapshot."); + end + for k = 1:numel(fragment.Operations) + operation = fragment.Operations{k}; + obj = append(obj, operation.Kind, ... + operation.Target, operation.Value); + end + end + end + + methods (Access = private) + function obj = append(obj, kind, target, value) + target = scalarId(target, "target"); + for k = 1:numel(obj.Operations) + operation = obj.Operations{k}; + if operation.Kind == kind && operation.Target == target + error("labkit:app:contract:DuplicateId", ... + "View snapshot repeats %s for target %s.", ... + kind, target); + end + end + obj.Operations{end + 1} = struct( ... + "Kind", kind, ... + "Target", target, ... + "Value", value); + end + end + + methods (Access = { ... + ?labkit.app.internal.CompiledDefinition, ... + ?labkit.app.internal.MatlabPlatformAdapter}) + function operations = operationsForCompiler(obj) + operations = obj.Operations; + end + end + + methods (Access = ?labkit.app.internal.RuntimeKernel) + function result = overlayForRuntime(base, custom) + if ~isa(custom, "labkit.app.view.Snapshot") + error("labkit:app:contract:InvalidValue", ... + "View snapshot overlay requires a View snapshot value."); + end + operations = base.Operations; + for k = 1:numel(custom.Operations) + incoming = custom.Operations{k}; + replaced = false; + for n = 1:numel(operations) + current = operations{n}; + if current.Kind == incoming.Kind && ... + current.Target == incoming.Target + operations{n} = incoming; + replaced = true; + break; + end + end + if ~replaced + operations{end + 1} = incoming; + end + end + result = labkit.app.view.Snapshot(); + result.Operations = operations; + end + end +end + +function obj = appendInteraction(obj, kind, interaction, value, varargin) +options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.view.Snapshot." + kind, ... + ["ImageSize", "Enabled"], varargin{:}); +imageSize = optionValue(options, "ImageSize", []); +if ~isempty(imageSize) && ... + ~(isnumeric(imageSize) && isvector(imageSize) && ... + numel(imageSize) >= 2 && all(isfinite(imageSize(1:2))) && ... + all(imageSize(1:2) >= 1)) + error("labkit:app:contract:InvalidValue", ... + "View snapshot interaction ImageSize must contain finite " + ... + "positive height and width."); +end +enabled = optionValue(options, "Enabled", true); +enabled = logicalScalar(enabled, "interaction Enabled"); +obj = append(obj, kind, interaction, struct( ... + "Value", {value}, "ImageSize", double(imageSize(:).'), ... + "Enabled", enabled)); +end + +function value = scalarId(value, label) + if ~(ischar(value) || (isstring(value) && isscalar(value))) + error("labkit:app:contract:InvalidValue", ... + "View snapshot %s must be scalar text.", label); + end + value = string(value); + if strlength(value) == 0 || ~isvarname(char(value)) + error("labkit:app:contract:InvalidValue", ... + "View snapshot %s must be a MATLAB identifier.", label); + end +end + +function value = logicalScalar(value, label) + if ~(islogical(value) && isscalar(value)) + error("labkit:app:contract:InvalidValue", ... + "View snapshot %s must be a logical scalar.", label); + end +end + +function values = textRow(values, label) + if ischar(values) + values = string(values); + elseif iscellstr(values) + values = string(values); + elseif ~isstring(values) + error("labkit:app:contract:InvalidValue", ... + "View snapshot %s must be text.", label); + end + values = reshape(values, 1, []); +end + +function values = logicalRow(values, label) + if ~(islogical(values) && (isscalar(values) || isrow(values))) + error("labkit:app:contract:InvalidValue", ... + "View snapshot %s must be a logical scalar or row.", label); + end + values = reshape(values, 1, []); +end + +function assertEditableWidth(editable, columns) + if ~isscalar(editable) && ~isempty(columns) && ... + numel(editable) ~= numel(columns) + error("labkit:app:contract:InvalidValue", ... + "View snapshot ColumnEditable must be scalar or match Columns."); + end +end + +function value = optionValue(options, name, defaultValue) + value = defaultValue; + if isfield(options, name) + value = options.(name); + end +end diff --git a/+labkit/+app/CallbackContext.m b/+labkit/+app/CallbackContext.m new file mode 100644 index 000000000..ee398555d --- /dev/null +++ b/+labkit/+app/CallbackContext.m @@ -0,0 +1,365 @@ +classdef (Sealed) CallbackContext < handle + %CALLBACKCONTEXT Provide declared App-neutral runtime capabilities. + % + % Usage: + % labkit.app.CallbackContext.appendStatus(context, message) + % context.appendStatus(message) + % context.reportError(operation, exception) + % context.diagnosticCheckpoint(id) + % context.diagnosticCount(id, count) + % context.alert(message, title) + % result = context.chooseOption(prompt, choices, Name=Value) + % result = context.chooseInputFile(filters, startPath) + % result = context.chooseInputFolder(startPath) + % result = context.chooseOutputFile(filters, startPath) + % result = context.chooseOutputFolder(startPath) + % result = context.saveProjectDocument(state, filepath) + % state = context.restoreProjectDocument(filepath) + % state = context.newProjectDocument() + % context.saveRecoveryDocument(state, filepath) + % paths = context.resolveSourcePaths(sources) + % paths = context.resolveSourcePaths(sources, ids) + % context.setResource(scope, id, value, cleanup) + % value = context.getResource(scope, id) + % context.removeResource(scope, id) + % context.clearResourceScope(scope) + % result = context.writeResultPackage(folder, result) + % + % Description: + % CallbackContext is the sealed callback capability boundary. Each + % specifically named method invokes one private runtime operation. Apps + % receive this value only as a callback argument and never construct it. + % It exposes no figure, registry, component, launch request, debug object, + % or nested service bag. + % + % Inputs: + % state - Complete App-owned state value. + % message - Scalar reader-facing text. + % operation - Scalar diagnostic operation text. + % exception - Scalar MException. + % id - Stable semantic diagnostic or resource identifier. + % count - Nonnegative integer diagnostic count. + % title - Scalar reader-facing dialog title. + % prompt - Scalar reader-facing choice prompt. + % choices - Row string or cellstr array. + % Title - Reader-facing choice-dialog title. Default: + % "Choose an option". + % DefaultChoice - Choice selected by pressing Enter. Default: the + % first choice. + % CancelChoice - Choice returned when the dialog is dismissed. + % Default: the first choice. + % filters - Runtime-supported file-dialog filter value. + % startPath - Scalar starting file or folder path. + % filepath - Scalar project or recovery source or destination path. + % sources - Runtime-owned portable source collection. + % ids - Optional source identifiers to resolve. + % scope - "event", "interaction", "document", or "application". + % value - App-neutral resource value. + % cleanup - Empty or fixed callback cleanup(value). + % folder - Scalar result-package folder. + % result - labkit.app.result.Package value for writeResultPackage. + % + % Outputs: + % state - Complete restored App state prepared for the current runtime + % transaction. + % result - labkit.app.dialog.Choice for dialogs, project saves, and result + % writing. + % paths - Column string array of resolved source paths. + % value - Stored resource value. + % + % Errors: + % labkit:app:contract:InvalidValue - A public argument is malformed. + % labkit:app:runtime:InvariantFailure - A private backend operation is + % unavailable. + % + % Typical Call: + % function state = runAnalysis(state,event,callbackContext) + % arguments + % state (1,1) struct + % event + % callbackContext (1,1) labkit.app.CallbackContext + % end + % callbackContext.appendStatus("Analysis started."); + % end + % + % See also labkit.app.Definition, labkit.app.dialog.Choice, + % labkit.app.result.Package + + properties (Access = private) + Backend (1, 1) struct + end + + methods (Access = ?labkit.app.internal.CallbackContextFactory) + function obj = CallbackContext(backend) + if ~isstruct(backend) || ~isscalar(backend) + error("labkit:app:runtime:InvariantFailure", ... + "CallbackContext backend is invalid."); + end + names = string(fieldnames(backend)); + if ~all(structfun(@(value) ... + isa(value, "function_handle") && isscalar(value), backend)) + error("labkit:app:runtime:InvariantFailure", ... + "CallbackContext backend operations must be function handles."); + end + if numel(unique(names)) ~= numel(names) + error("labkit:app:runtime:InvariantFailure", ... + "CallbackContext backend operation names repeat."); + end + obj.Backend = backend; + end + end + + methods + function appendStatus(obj, message) + message = scalarText(message, "message"); + obj.invoke("appendStatus", "workflow", {message}, 0); + end + + function reportError(obj, operation, exception) + operation = scalarText(operation, "operation"); + if ~isa(exception, "MException") || ~isscalar(exception) + error("labkit:app:contract:InvalidValue", ... + "CallbackContext exception must be a scalar MException."); + end + obj.invoke("reportError", "diagnostics", ... + {operation, exception}, 0); + end + + function diagnosticCheckpoint(obj, id) + id = nonemptyText(id, "diagnostic id"); + obj.invoke("diagnosticCheckpoint", "diagnostics", {id}, 0); + end + + function diagnosticCount(obj, id, count) + id = nonemptyText(id, "diagnostic id"); + if ~(isnumeric(count) && isscalar(count) && ... + isfinite(count) && count >= 0 && count == fix(count)) + error("labkit:app:contract:InvalidValue", ... + "CallbackContext diagnostic count must be a " + ... + "nonnegative integer."); + end + obj.invoke("diagnosticCount", "diagnostics", ... + {id, double(count)}, 0); + end + + function alert(obj, message, title) + obj.invoke("alert", "dialogs", ... + {scalarText(message, "message"), ... + scalarText(title, "title")}, 0); + end + + function result = chooseOption(obj, prompt, choices, varargin) + choices = textRow(choices, "choices"); + if isempty(choices) || numel(unique(choices)) ~= numel(choices) + error("labkit:app:contract:InvalidValue", ... + "CallbackContext choices must be nonempty and unique."); + end + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.CallbackContext.chooseOption", ... + ["Title", "DefaultChoice", "CancelChoice"], varargin{:}); + title = scalarText(optionValue( ... + options, "Title", "Choose an option"), "title"); + defaultChoice = choiceValue(optionValue( ... + options, "DefaultChoice", choices(1)), ... + choices, "DefaultChoice"); + cancelChoice = choiceValue(optionValue( ... + options, "CancelChoice", choices(1)), ... + choices, "CancelChoice"); + result = obj.invoke("choose", "dialogs", ... + {scalarText(prompt, "prompt"), choices, title, ... + defaultChoice, cancelChoice}, 1); + requireChoice(result, "chooseOption"); + end + + function result = chooseInputFile(obj, filters, startPath) + result = obj.dialogPath("chooseInputFile", filters, startPath); + end + + function result = chooseInputFolder(obj, startPath) + result = obj.invoke("chooseInputFolder", "dialogs", ... + {scalarText(startPath, "startPath")}, 1); + requireChoice(result, "chooseInputFolder"); + end + + function result = chooseOutputFile(obj, filters, startPath) + result = obj.dialogPath("chooseOutputFile", filters, startPath); + end + + function result = chooseOutputFolder(obj, startPath) + result = obj.invoke("chooseOutputFolder", "dialogs", ... + {scalarText(startPath, "startPath")}, 1); + requireChoice(result, "chooseOutputFolder"); + end + + function result = saveProjectDocument(obj, state, filepath) + result = obj.invoke("saveProject", "project", ... + {state, scalarText(filepath, "filepath")}, 1); + requireChoice(result, "saveProjectDocument"); + end + + function state = restoreProjectDocument(obj, filepath) + state = obj.invoke("restoreProject", "project", ... + {scalarText(filepath, "filepath")}, 1); + requireApplicationState(state, "restoreProjectDocument"); + end + + function state = newProjectDocument(obj) + state = obj.invoke("newProject", "project", {}, 1); + requireApplicationState(state, "newProjectDocument"); + end + + function saveRecoveryDocument(obj, state, filepath) + obj.invoke("saveRecovery", "project", ... + {state, scalarText(filepath, "filepath")}, 0); + end + + function paths = resolveSourcePaths(obj, sources, ids) + if nargin < 3 + ids = strings(0, 1); + elseif ~(ischar(ids) || isstring(ids) || iscellstr(ids)) + error("labkit:app:contract:InvalidValue", ... + "CallbackContext source ids must be text."); + else + ids = string(ids(:)); + end + paths = obj.invoke("sourcePaths", "project", ... + {sources, ids}, 1); + if ~isstring(paths) || ~iscolumn(paths) + error("labkit:app:runtime:InvariantFailure", ... + "CallbackContext resolveSourcePaths backend must return " + ... + "a column string array."); + end + end + + function setResource(obj, scope, id, value, cleanup) + scope = resourceScope(scope); + id = nonemptyText(id, "resource id"); + if ~isempty(cleanup) && ... + (~isa(cleanup, "function_handle") || ... + nargin(cleanup) ~= 1 || nargout(cleanup) > 0) + error("labkit:app:contract:InvalidValue", ... + "CallbackContext cleanup must accept one value and " + ... + "return no outputs."); + end + obj.invoke("setResource", "resources", ... + {scope, id, value, cleanup}, 0); + end + + function value = getResource(obj, scope, id) + value = obj.invoke("getResource", "resources", ... + {resourceScope(scope), nonemptyText(id, "resource id")}, 1); + end + + function removeResource(obj, scope, id) + obj.invoke("removeResource", "resources", ... + {resourceScope(scope), nonemptyText(id, "resource id")}, 0); + end + + function clearResourceScope(obj, scope) + obj.invoke("clearResourceScope", "resources", ... + {resourceScope(scope)}, 0); + end + + function written = writeResultPackage(obj, folder, result) + folder = scalarText(folder, "result folder"); + if ~isa(result, "labkit.app.result.Package") + error("labkit:app:contract:InvalidValue", ... + "CallbackContext writeResultPackage requires a " + ... + "labkit.app.result.Package value."); + end + written = obj.invoke("writeResult", "results", ... + {folder, result}, 1); + requireChoice(written, "writeResultPackage"); + end + end + + methods (Access = private) + function result = dialogPath(obj, operation, filters, startPath) + result = obj.invoke(operation, "dialogs", ... + {filters, scalarText(startPath, "startPath")}, 1); + requireChoice(result, operation); + end + + function varargout = invoke(obj, operation, ~, inputs, outputs) + if ~isfield(obj.Backend, operation) + error("labkit:app:runtime:InvariantFailure", ... + "CallbackContext backend operation is unavailable: %s.", ... + operation); + end + callback = obj.Backend.(operation); + if outputs == 0 + callback(inputs{:}); + varargout = {}; + else + [varargout{1:outputs}] = callback(inputs{:}); + end + end + end +end + +function requireApplicationState(state, operation) + if ~isstruct(state) || ~isscalar(state) || ... + ~all(isfield(state, ["project", "session"])) + error("labkit:app:runtime:InvariantFailure", ... + "CallbackContext %s backend must return scalar " + ... + "project/session state.", operation); + end +end + +function value = scalarText(value, label) + if ~(ischar(value) || (isstring(value) && isscalar(value))) + error("labkit:app:contract:InvalidValue", ... + "CallbackContext %s must be scalar text.", label); + end + value = string(value); +end + +function value = nonemptyText(value, label) + value = scalarText(value, label); + if strlength(value) == 0 + error("labkit:app:contract:InvalidValue", ... + "CallbackContext %s must be nonempty.", label); + end +end + +function values = textRow(values, label) + if ischar(values) + values = string(values); + elseif iscellstr(values) + values = string(values); + elseif ~isstring(values) + error("labkit:app:contract:InvalidValue", ... + "CallbackContext %s must be text.", label); + end + values = reshape(values, 1, []); +end + +function value = resourceScope(value) + value = nonemptyText(value, "resource scope"); + if ~any(value == ["event", "interaction", "document", "application"]) + error("labkit:app:contract:InvalidValue", ... + "CallbackContext resource scope is unsupported: %s.", value); + end +end + +function requireChoice(value, operation) + if ~isa(value, "labkit.app.dialog.Choice") + error("labkit:app:runtime:InvariantFailure", ... + "CallbackContext %s backend must return Choice.", operation); + end +end + +function value = choiceValue(value, choices, name) +value = scalarText(value, name); +if ~any(choices == value) + error("labkit:app:contract:InvalidValue", ... + "CallbackContext %s must name one declared choice.", name); +end +end + +function value = optionValue(options, name, fallback) +value = fallback; +if isfield(options, name) + value = options.(name); +end +end diff --git a/+labkit/+app/Definition.m b/+labkit/+app/Definition.m new file mode 100644 index 000000000..6d5df1522 --- /dev/null +++ b/+labkit/+app/Definition.m @@ -0,0 +1,341 @@ +classdef (Sealed) Definition + %DEFINITION Compile and launch one immutable App SDK contract. + % + % Usage: + % app = labkit.app.Definition(Entrypoint=entrypoint, AppId=appId, ... + % Title=title, ... + % Family=family, AppVersion=version, Updated=date, ... + % Requirements=requirements, Workbench=workbench, Name=Value) + % fig = app.launch() + % fig = app.launch(InitialProject=project) + % fig = app.launch(Diagnostics=diagnosticOptions) + % requirements = app.launch("requirements") + % version = app.launch("version") + % + % Description: + % Definition validates product metadata, layout-owned callbacks and + % renderers, global IDs, callback roles, and references in one atomic + % constructor. The static + % target graph is cached once. validateViewSnapshot checks a complete + % view snapshot against that graph without rebuilding the layout. + % + % Required Name-Value Arguments: + % Entrypoint - Public MATLAB launch function name as a scalar identifier. + % AppId - Stable App identifier beginning with an ASCII letter and + % containing letters, digits, underscore, hyphen, or period. + % Title - Nonempty reader-facing scalar text. + % Family - Nonempty reader-facing scalar text. + % AppVersion - Semantic version in X.Y.Z form. + % Updated - Product date in YYYY-MM-DD form. + % Requirements - Empty value or labkit.contract.requirements result. + % Workbench - Root value returned by labkit.app.layout.workbench. + % + % Optional Name-Value Arguments: + % DisplayName - Nonempty scalar text. Default: Title. + % ProjectSchema - labkit.app.project.Schema or empty for a static App. + % Default: empty. + % CreateSession - Fixed callback session = callback(project,context). + % Portable project sources remain opaque; use + % context.resolveSourcePaths + % while rebuilding transient session data. Default: empty. + % PresentWorkbench - Fixed callback view = callback(state). Default: + % empty. + % OnStart - Fixed callback state = callback(state,context), invoked + % after the first view commit. Default: empty. + % BuildDebugSample - Fixed callback pack = callback(context). Default: + % empty. + % + % Outputs: + % app - Immutable compiled labkit.app.Definition value. + % + % Definition Methods: + % launch() - Build and show the native MATLAB App figure. + % launch(Diagnostics=options) - Use one + % labkit.app.diagnostic.Options value for standard or verbose + % sanitized runtime recording. + % launch("requirements") - Return declared facade requirements without + % creating a figure. + % launch("version") - Return product version metadata without creating + % a figure. + % validateViewSnapshot(view) - Validate target references, target + % capabilities and complete target coverage. + % Returns true or throws before any runtime UI mutation. + % + % Errors: + % labkit:app:contract:UnknownArgument - A required argument is missing or + % an argument is unknown, duplicated, or unpaired. + % labkit:app:contract:InvalidValue - Metadata, requirements, workbench, + % callbacks, or renderers are malformed. + % labkit:app:contract:DuplicateId - A layout ID is duplicated. + % labkit:app:contract:UnknownReference - A view target is undeclared. + % labkit:app:contract:UnsupportedOperation - A view operation is + % not legal for its target. + % labkit:app:contract:InvalidValue - A launch request or output count is + % unsupported. + % + % Typical Call: + % workbench = labkit.app.layout.workbench({ ... + % labkit.app.layout.button("run", "Run", @runAnalysis, ... + % Tooltip="Compute the current analysis.")}); + % app = labkit.app.Definition( ... + % Entrypoint="labkit_Example_app", AppId="example.app", ... + % Title="Example", Family="Examples", AppVersion="1.0.0", ... + % Updated="2026-07-19", Requirements=[], Workbench=workbench); + % + % See also labkit.app.layout.workbench, labkit.app.view.Snapshot, + % labkit.contract.requirements + + properties (SetAccess = immutable) + Entrypoint (1, 1) string + AppId (1, 1) string + Title (1, 1) string + DisplayName (1, 1) string + Family (1, 1) string + AppVersion (1, 1) string + Updated (1, 1) string + Requirements + ProjectSchema + CreateSession + PresentWorkbench + OnStart + BuildDebugSample + end + + properties (SetAccess = immutable, GetAccess = { ... + ?labkit.app.internal.RuntimeFactory, ... + ?labkit.app.internal.DefinitionInspector}) + Compiled + end + + methods + function obj = Definition(varargin) + names = [ ... + "Entrypoint", "AppId", "Title", "DisplayName", "Family", ... + "AppVersion", "Updated", "Requirements", "Workbench", ... + "ProjectSchema", "CreateSession", "PresentWorkbench", ... + "OnStart", "BuildDebugSample"]; + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.Definition", names, varargin{:}); + required = [ ... + "Entrypoint", "AppId", "Title", "Family", "AppVersion", ... + "Updated", "Requirements", "Workbench"]; + for name = required + if ~isfield(options, name) + error("labkit:app:contract:UnknownArgument", ... + "labkit.app.Definition requires argument %s.", name); + end + end + + obj.Entrypoint = matlabId(options.Entrypoint, "Entrypoint"); + obj.AppId = appId(options.AppId); + obj.Title = nonemptyText(options.Title, "Title"); + displayName = obj.Title; + if isfield(options, "DisplayName") + displayName = nonemptyText( ... + options.DisplayName, "DisplayName"); + end + obj.DisplayName = displayName; + obj.Family = nonemptyText(options.Family, "Family"); + obj.AppVersion = semanticVersion(options.AppVersion); + obj.Updated = isoDate(options.Updated); + obj.Requirements = validateRequirements(options.Requirements); + obj.ProjectSchema = validateProjectSchema( ... + optionValue(options, "ProjectSchema", [])); + obj.CreateSession = optionalFixedCallback( ... + options, "CreateSession", 2, 1); + obj.PresentWorkbench = optionalFixedCallback( ... + options, "PresentWorkbench", 1, 1); + startCallback = optionalFixedCallback( ... + options, "OnStart", 2, 1); + obj.OnStart = startCallback; + obj.BuildDebugSample = optionalFixedCallback( ... + options, "BuildDebugSample", 1, 1); + obj.Compiled = labkit.app.internal.CompiledDefinition( ... + options.Workbench, startCallback); + end + + function accepted = validateViewSnapshot(obj, view) + accepted = obj.Compiled.validateViewSnapshot(view); + end + + function varargout = launch(obj, varargin) + %LAUNCH Answer metadata requests or show the native MATLAB App. + initialProject = []; + diagnostics = labkit.app.diagnostic.Options(); + if ~isempty(varargin) && ... + ~(numel(varargin) == 1 && ... + (ischar(varargin{1}) || ... + (isstring(varargin{1}) && isscalar(varargin{1})))) + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.Definition.launch", ... + ["InitialProject", "Diagnostics"], ... + varargin{:}); + if isfield(options, "InitialProject") + if ~isstruct(options.InitialProject) || ... + ~isscalar(options.InitialProject) + error("labkit:app:contract:InvalidValue", ... + "Definition launch InitialProject must be a " + ... + "scalar project struct."); + end + initialProject = options.InitialProject; + end + if isfield(options, "Diagnostics") + if ~isa(options.Diagnostics, ... + "labkit.app.diagnostic.Options") || ... + ~isscalar(options.Diagnostics) + error("labkit:app:contract:InvalidValue", ... + "Definition launch Diagnostics must be one " + ... + "labkit.app.diagnostic.Options value."); + end + diagnostics = options.Diagnostics; + end + varargin = {}; + end + if ~isempty(varargin) + if numel(varargin) ~= 1 || ... + ~(ischar(varargin{1}) || ... + (isstring(varargin{1}) && isscalar(varargin{1}))) + error("labkit:app:contract:InvalidValue", ... + "Definition launch accepts one optional request."); + end + request = lower(string(varargin{1})); + if nargout > 1 + error("labkit:app:contract:InvalidValue", ... + "Definition metadata requests return one output."); + end + switch request + case "requirements" + varargout = {obj.Requirements}; + case "version" + varargout = {struct( ... + "name", obj.Entrypoint, ... + "displayName", obj.DisplayName, ... + "family", obj.Family, ... + "version", obj.AppVersion, ... + "updated", obj.Updated)}; + otherwise + error("labkit:app:contract:InvalidValue", ... + "Definition launch request is unsupported: %s.", ... + request); + end + return; + end + if nargout > 1 + error("labkit:app:contract:InvalidValue", ... + "Definition launch returns at most one figure."); + end + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + obj, ... + initialProject, struct(), diagnostics); + runtime.showFigure(); + figure = runtime.figureHandle(); + if nargout == 1 + varargout = {figure}; + else + varargout = {}; + end + end + end + +end + +function value = optionValue(options, name, defaultValue) + value = defaultValue; + if isfield(options, name) + value = options.(name); + end +end + +function value = matlabId(value, label) + value = nonemptyText(value, label); + if ~isvarname(char(value)) + error("labkit:app:contract:InvalidValue", ... + "Definition %s must be a MATLAB identifier.", label); + end +end + +function value = appId(value) + value = nonemptyText(value, "Id"); + if isempty(regexp(char(value), ... + '^[A-Za-z][A-Za-z0-9_.-]*$', "once")) + error("labkit:app:contract:InvalidValue", ... + "Definition AppId has invalid syntax."); + end +end + +function value = nonemptyText(value, label) + if ~(ischar(value) || (isstring(value) && isscalar(value))) || ... + strlength(string(value)) == 0 + error("labkit:app:contract:InvalidValue", ... + "Definition %s must be nonempty scalar text.", label); + end + value = string(value); +end + +function value = semanticVersion(value) + value = nonemptyText(value, "AppVersion"); + if isempty(regexp(char(value), ... + '^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)$', ... + "once")) + error("labkit:app:contract:InvalidValue", ... + "Definition AppVersion must use X.Y.Z syntax."); + end +end + +function value = isoDate(value) + value = nonemptyText(value, "Updated"); + if isempty(regexp(char(value), ... + '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', "once")) + error("labkit:app:contract:InvalidValue", ... + "Definition Updated must use YYYY-MM-DD syntax."); + end + try + parsed = datetime(value, "InputFormat", "yyyy-MM-dd"); + catch + error("labkit:app:contract:InvalidValue", ... + "Definition Updated is not a valid date."); + end + if string(parsed, "yyyy-MM-dd") ~= value + error("labkit:app:contract:InvalidValue", ... + "Definition Updated is not a canonical date."); + end +end + +function value = validateRequirements(value) + if isempty(value) + return; + end + if ~isstruct(value) || ~isscalar(value) || ... + ~isfield(value, "type") || ... + string(value.type) ~= "labkit.requirements" || ... + ~isfield(value, "facades") + error("labkit:app:contract:InvalidValue", ... + "Requirements must come from labkit.contract.requirements."); + end +end + +function value = validateProjectSchema(value) + if ~isempty(value) && ~isa(value, "labkit.app.project.Schema") + error("labkit:app:contract:InvalidValue", ... + "Definition ProjectSchema must be a project.Schema or empty."); + end +end + +function callback = optionalFixedCallback(options, name, inputs, outputs) + callback = []; + if ~isfield(options, name) || isempty(options.(name)) + return; + end + callback = options.(name); + if ~isa(callback, "function_handle") || ~isscalar(callback) || ... + nargin(callback) ~= inputs || nargout(callback) ~= outputs + error("labkit:app:contract:CallbackRoleMismatch", ... + "Definition %s requires %d inputs and %d outputs.", ... + name, inputs, outputs); + end +end + + +function state = runAnalysis(state, ~) + state.finished = true; +end diff --git a/+labkit/+app/version.m b/+labkit/+app/version.m new file mode 100644 index 000000000..fdeff059f --- /dev/null +++ b/+labkit/+app/version.m @@ -0,0 +1,35 @@ +function info = version() +%VERSION Return the LabKit App SDK facade contract version. +% +% Usage: +% info = labkit.app.version() +% +% Description: +% Reports the semantic version and compatibility range of the public +% LabKit App SDK. The SDK owns App definitions, semantic layout, +% transactions, projects, portable sources, resources, results, dialogs, +% and native MATLAB presentation. It is independent of App product and +% saved-project schema versions. +% +% Inputs: +% None. +% +% Outputs: +% info - Scalar structure returned by labkit.contract.versionInfo with name, +% current, compatible, status, and notes fields. +% +% Errors: +% labkit:contract:InvalidVersionInfo - Embedded facade metadata is invalid. +% +% Example: +% info = labkit.app.version(); +% assert(startsWith(info.current, "1.")) +% +% See also labkit.contract.versionInfo, +% labkit.contract.requirements, +% labkit.app.Definition + + info = labkit.contract.versionInfo( ... + "app", "1.1.0", ">=1 <2", "stable", ... + "Explicit LabKit App SDK contract for tracked production Apps."); +end diff --git a/+labkit/+contract/assertRequirements.m b/+labkit/+contract/assertRequirements.m index d7320edc2..d109e3a22 100644 --- a/+labkit/+contract/assertRequirements.m +++ b/+labkit/+contract/assertRequirements.m @@ -25,7 +25,7 @@ function assertRequirements(appName, req, versions) % and range validation errors from checkRequirements are passed through. % % Example: -% req = labkit.contract.requirements("ui", ">=7 <8"); +% req = labkit.contract.requirements("app", ">=1 <2"); % labkit.contract.assertRequirements("labkit_Example_app", req) % % See also labkit.contract.checkRequirements, diff --git a/+labkit/+contract/checkRequirements.m b/+labkit/+contract/checkRequirements.m index f1bfe1ada..a5aff40a6 100644 --- a/+labkit/+contract/checkRequirements.m +++ b/+labkit/+contract/checkRequirements.m @@ -8,7 +8,7 @@ % Description: % Checks each requested facade in two ways: its current version must satisfy % the caller's range, and that range must overlap at least one compatibility -% range advertised by the facade. When versions is omitted, the current ui, +% range advertised by the facade. When versions is omitted, the current app, % dta, rhs, biosignal, image, and thermal version functions are queried. % % Inputs: @@ -96,7 +96,7 @@ function versions = currentFacadeVersions() versions = [ - labkit.ui.version() + labkit.app.version() labkit.dta.version() labkit.rhs.version() labkit.biosignal.version() diff --git a/+labkit/+contract/requirements.m b/+labkit/+contract/requirements.m index 389188be0..56d7eb218 100644 --- a/+labkit/+contract/requirements.m +++ b/+labkit/+contract/requirements.m @@ -2,11 +2,11 @@ %REQUIREMENTS Describe the LabKit API versions required by a caller. % % Usage: -% req = labkit.contract.requirements("ui", ">=2.0 <3", ...) +% req = labkit.contract.requirements("app", ">=1.0 <2", ...) % % Description: % Builds a normalized requirement structure from facade/range pairs. A -% facade name may be written as "ui" or "labkit.ui". Names are converted to +% facade name may be written as "app" or "labkit.app". Names are converted to % lowercase and the optional "labkit." prefix is removed. No compatibility % check is performed until checkRequirements or assertRequirements is called. % @@ -29,7 +29,7 @@ % % Example: % req = labkit.contract.requirements( ... -% "ui", ">=7 <8", ... +% "app", ">=1 <2", ... % "image", ">=4.0 <5"); % report = labkit.contract.checkRequirements(req); % diff --git a/+labkit/+ui/+debug/context.m b/+labkit/+ui/+debug/context.m deleted file mode 100644 index a678379a5..000000000 --- a/+labkit/+ui/+debug/context.m +++ /dev/null @@ -1,638 +0,0 @@ -function debugContext = context(appName, opts) -%CONTEXT Create an app-neutral callback tracing and diagnostic context. -% -% Usage: -% debug = labkit.ui.debug.context(appName) -% debug = labkit.ui.debug.context(appName, opts) -% -% Inputs: -% appName - Text scalar identifying the app in every structured trace line -% and diagnostic report. -% opts - Optional scalar struct described below. Default: struct(). -% -% Options: -% enabled - Logical master switch. A disabled context ignores captured -% messages and does not create artifact folders. Default: true. -% traceEnabled - Logical switch for trace and callback instrumentation. -% Default: enabled. -% logFile - Text file path. Existing content is replaced when the context is -% created, then every captured line is appended. Default: "". -% logCallback - Function handle called as logCallback(line) for every -% captured append or trace line. Default: []. -% traceCallback - Function handle called as traceCallback(line) only for -% structured trace lines. Default: []. -% stallTimeoutSeconds - Nonnegative callback duration in seconds before an -% instrumented operation writes a stalled-operation report. Default: 30. -% crashReportFile - Diagnostic report path. By default, uses the logFile -% folder and base name with a _crash_report.txt suffix. -% activeOperationFile - Path written at callback start and removed after -% successful or failed completion. A MATLAB process failure can therefore -% leave the last active callback on disk. The default is derived from -% logFile with an _active_operation.txt suffix. -% artifactFolder - Root folder for debug sample packs. Default: the logFile -% folder. -% sampleFolder - Folder for copied or synthetic input samples. Default: -% fullfile(artifactFolder,"samples"). -% outputFolder - Folder for debug outputs. Default: -% fullfile(artifactFolder,"outputs"). -% manifestFile - JSON manifest path. Default: -% fullfile(artifactFolder,"manifest.json"). -% callbackProperties - Cell array or string array of graphics callback -% property names used only by instrumentFigure(fig,opts). Each name is -% ignored on handles that do not expose that property. Default: the -% standard button, value, selection, cell, close, size, keyboard, and -% tree callback properties listed by the runtime. -% -% Outputs: -% debugContext - Scalar struct, shown as debug in the usage syntax, containing -% configuration fields and the methods below. -% -% Context Fields: -% appName - Normalized app name as a string scalar. -% enabled - Effective master switch. -% traceEnabled - Effective trace switch. -% logFile - Effective log path. -% crashReportFile - Effective caught-error and stall-report path. -% activeOperationFile - Effective active-operation marker path. -% artifactFolder - Effective artifact root. -% sampleFolder - Effective sample folder. -% outputFolder - Effective output folder. -% manifestFile - Effective manifest path. -% -% Context Methods: -% append(message) - Add one timestamped human-readable log line. -% trace(event) - Add a structured app-level trace with reason "internal". -% trace(component,event,reason) - Add a trace containing stable app, -% component, event, and reason fields. -% reportException(event,exception) - Trace and report an app-level exception. -% reportException(component,event,exception) - Trace and report a component -% exception. Non-MException values are recorded as caught error text. -% setTraceCallback(callback) - Replace the trace-only callback. -% attachTextLog(textArea) - Send future trace lines to a LabKit log text area. -% wrapCallback(name,callback) - Return a callback that records BEGIN, END, -% elapsed operation state, stalls, and errors while preserving outputs. -% instrumentFigure(fig) - Wrap supported callbacks already installed on a -% figure and its descendants; return the number wrapped. -% instrumentFigure(fig,opts) - Use opts.callbackProperties instead of the -% standard callback-property list. -% recordArtifacts(manifest) - Write manifest plus effective debug paths to -% manifestFile when that path is configured. -% getLog() - Return captured lines as a column cell array of character rows. -% -% Description: -% context provides opt-in diagnostics without coupling an app to a particular -% log panel. Instrumented callbacks are rethrown after reporting, so debugging -% does not change error control flow. File-chooser trace events suppress stall -% reports while a known modal dialog is active. With no file paths configured, -% messages remain in memory and no files or folders are created. -% -% Errors: -% labkit:ui:DebugLogFileOpenFailed - A configured log or report file cannot -% be opened for writing. -% Instrumented callback exceptions are recorded and then rethrown unchanged. -% Invalid option types, callback properties, graphics handles, or artifact -% paths may propagate the originating MATLAB conversion, graphics, or -% file-system error. -% -% Example: -% debug = labkit.ui.debug.context("example_app"); -% debug.trace("loader", "file parsed", "user"); -% lines = debug.getLog(); -% assert(contains(lines{end}, "component=loader")) -% -% See also labkit.ui.runtime.launch - - if nargin < 2 || isempty(opts) - opts = struct(); - end - - appName = string(appName); - enabled = optionValue(opts, 'enabled', true); - traceEnabled = optionValue(opts, 'traceEnabled', enabled); - logFile = string(optionValue(opts, 'logFile', "")); - logCallback = optionValue(opts, 'logCallback', []); - traceCallback = optionValue(opts, 'traceCallback', []); - crashReportFile = string(optionValue(opts, 'crashReportFile', ... - defaultReportFile(logFile, "crash_report"))); - activeOperationFile = string(optionValue(opts, 'activeOperationFile', ... - defaultReportFile(logFile, "active_operation"))); - artifacts = debugArtifactPaths(opts, logFile, enabled); - artifactFolder = artifacts.artifactFolder; - sampleFolder = artifacts.sampleFolder; - outputFolder = artifacts.outputFolder; - manifestFile = artifacts.manifestFile; - stallTimeoutSeconds = normalizePositiveScalar( ... - optionValue(opts, 'stallTimeoutSeconds', 30), 30); - lines = {}; - modalDialogDepth = 0; - - if enabled && strlength(logFile) > 0 - initializeLogFile(logFile, appName); - end - debugContext = struct(); - debugContext.appName = appName; - debugContext.enabled = logical(enabled); - debugContext.traceEnabled = logical(traceEnabled); - debugContext.logFile = logFile; - debugContext.crashReportFile = crashReportFile; - debugContext.activeOperationFile = activeOperationFile; - debugContext.artifactFolder = artifactFolder; - debugContext.sampleFolder = sampleFolder; - debugContext.outputFolder = outputFolder; - debugContext.manifestFile = manifestFile; - debugContext.append = @append; - debugContext.trace = @trace; - debugContext.reportException = @reportException; - debugContext.setTraceCallback = @setTraceCallback; - debugContext.attachTextLog = @attachTextLog; - debugContext.wrapCallback = @wrapCallback; - debugContext.instrumentFigure = @instrumentFigure; - debugContext.recordArtifacts = @recordArtifacts; - debugContext.getLog = @getLog; - - function append(message) - if ~enabled - return; - end - appendLineValue(char(message)); - end - - function trace(component, event, reason) - if ~enabled || ~traceEnabled - return; - end - if nargin < 2 - event = component; - component = "app"; - end - if nargin < 3 || isempty(reason) - reason = "internal"; - end - updateModalDialogState(component, event); - line = appendLineValue(sprintf('[debug] app=%s component=%s event=%s reason=%s', ... - traceValue(appName), traceValue(component), ... - traceValue(event), traceValue(reason))); - if ~isempty(traceCallback) - traceCallback(line); - end - end - - function setTraceCallback(callback) - traceCallback = callback; - end - - function reportException(component, event, exception) - if nargin < 3 - exception = event; - event = component; - component = "app"; - end - if ~isa(exception, 'MException') - trace(component, sprintf('ERROR %s: %s', ... - char(string(event)), char(string(exception))), 'caught'); - return; - end - - label = sprintf('%s %s', char(string(component)), char(string(event))); - trace(component, sprintf('ERROR %s: %s %s', ... - char(string(event)), exception.identifier, exception.message), 'caught'); - op = completedOperation(label); - writeOperationReport("caught_error", op, exception); - end - - function recordArtifacts(manifest) - if ~enabled || strlength(manifestFile) == 0 - return; - end - metadata = struct( ... - "appName", appName, ... - "logFile", logFile, ... - "artifactFolder", artifactFolder, ... - "sampleFolder", sampleFolder, ... - "outputFolder", outputFolder); - writeDebugManifest(manifestFile, manifest, metadata); - trace("debugArtifacts", "manifest written", "internal"); - end - - function attachTextLog(textArea) - setTraceCallback(@appendTraceToTextLog); - - function appendTraceToTextLog(line) - if isempty(textArea) || ~isvalid(textArea) - return; - end - appendTextLog(textArea, line); - end - end - - function wrappedCallback = wrapCallback(name, callback) - if isempty(callback) - wrappedCallback = []; - return; - end - - callbackName = char(string(name)); - originalCallback = callback; - wrappedCallback = @wrapped; - - function varargout = wrapped(varargin) - op = startOperation(callbackName); - trace(sprintf('BEGIN %s', callbackName)); - try - if nargout == 0 - originalCallback(varargin{:}); - trace(sprintf('END %s', callbackName)); - finishOperation(op, "completed", []); - else - [varargout{1:nargout}] = originalCallback(varargin{:}); - trace(sprintf('END %s', callbackName)); - finishOperation(op, "completed", []); - end - catch ME - trace(sprintf('ERROR %s: %s %s', ... - callbackName, ME.identifier, ME.message)); - finishOperation(op, "error", ME); - rethrow(ME); - end - end - end - - function count = instrumentFigure(fig, opts) - if nargin < 2 - opts = struct(); - end - count = 0; - if ~enabled || ~traceEnabled || isempty(fig) || ~isvalid(fig) - return; - end - - callbackProps = optionValue(opts, 'callbackProperties', defaultCallbackProperties()); - callbackProps = cellstr(string(callbackProps)); - handles = findall(fig); - for iHandle = 1:numel(handles) - handle = handles(iHandle); - for iProp = 1:numel(callbackProps) - propName = callbackProps{iProp}; - if ~isprop(handle, propName) - continue; - end - currentCallback = handle.(propName); - if isempty(currentCallback) || isAlreadyInstrumented(handle, propName) - continue; - end - wrapped = callbackWrapperForHandle(handle, propName, currentCallback, ... - @trace, @startOperation, @finishOperation); - if isempty(wrapped) - continue; - end - handle.(propName) = wrapped; - markInstrumented(handle, propName); - count = count + 1; - end - end - trace(sprintf('instrumented figure %d callback(s) on %s', ... - count, debugHandleLabel(fig))); - end - - function line = appendLineValue(message) - line = sprintf('[%s] %s', datestr(now, 'HH:MM:SS'), message); - lines{end+1, 1} = line; - if strlength(logFile) > 0 - appendLine(logFile, line); - end - if ~isempty(logCallback) - logCallback(line); - end - end - - function out = getLog() - out = lines; - end - - function op = startOperation(label) - op = emptyOperation(); - if ~enabled || ~traceEnabled - return; - end - op = struct( ... - 'id', operationId(), ... - 'label', char(string(label)), ... - 'startedAt', datestr(now, 31), ... - 'startTic', tic, ... - 'timer', []); - writeOperationReport("active", op, []); - op.timer = startStallTimer(op); - end - - function op = completedOperation(label) - op = struct( ... - 'id', operationId(), ... - 'label', char(string(label)), ... - 'startedAt', datestr(now, 31), ... - 'startTic', tic, ... - 'timer', []); - end - - function finishOperation(op, status, exception) - if ~isstruct(op) || ~isfield(op, 'id') || strlength(string(op.id)) == 0 - return; - end - stopStallTimer(op); - if status == "error" - writeOperationReport("error", op, exception); - end - removeActiveOperationFile(); - end - - function timerObj = startStallTimer(op) - timerObj = []; - if stallTimeoutSeconds <= 0 || strlength(crashReportFile) == 0 - return; - end - try - timerObj = timer( ... - 'ExecutionMode', 'singleShot', ... - 'StartDelay', stallTimeoutSeconds, ... - 'Name', sprintf('labkit_stall_%s', op.id), ... - 'TimerFcn', @(~,~) writeStallReportIfNotModal(op)); - start(timerObj); - catch - timerObj = []; - end - end - - function writeStallReportIfNotModal(op) - if modalDialogDepth > 0 - appendLineValue(sprintf('[debug] app=%s component=debug event=stall timer skipped during modal dialog operation=%s reason=internal', ... - traceValue(appName), traceValue(op.label))); - return; - end - writeOperationReport("stalled", op, []); - end - - function stopStallTimer(op) - if ~isfield(op, 'timer') || isempty(op.timer) - return; - end - try - stop(op.timer); - delete(op.timer); - catch - end - end - - function writeOperationReport(status, op, exception) - filepath = crashReportFile; - if status == "active" - filepath = activeOperationFile; - end - if strlength(filepath) == 0 - return; - end - - fid = fopen(filepath, 'w'); - if fid < 0 - return; - end - cleaner = onCleanup(@() fclose(fid)); - fprintf(fid, 'LabKit app diagnostic report\n'); - fprintf(fid, 'created=%s\n', datestr(now, 31)); - fprintf(fid, 'app=%s\n', char(appName)); - fprintf(fid, 'status=%s\n', char(status)); - fprintf(fid, 'operation=%s\n', sanitizeReportLine(op.label)); - fprintf(fid, 'operation_id=%s\n', sanitizeReportLine(op.id)); - fprintf(fid, 'operation_started=%s\n', sanitizeReportLine(op.startedAt)); - fprintf(fid, 'elapsed_seconds=%.3f\n', elapsedSeconds(op)); - fprintf(fid, 'stall_timeout_seconds=%.3f\n', stallTimeoutSeconds); - fprintf(fid, 'matlab=%s\n', version); - fprintf(fid, 'platform=%s\n', computer); - if ~isempty(exception) && isa(exception, 'MException') - fprintf(fid, 'error_id=%s\n', sanitizeReportLine(exception.identifier)); - fprintf(fid, 'error_message=%s\n', sanitizeReportLine(exception.message)); - writeStack(fid, exception); - end - fprintf(fid, '\nrecent_operations:\n'); - writeRecentOperations(fid, recentOperationLines(12)); - fprintf(fid, '\nrecent_log:\n'); - recent = recentLines(40); - for k = 1:numel(recent) - fprintf(fid, '%s\n', sanitizeReportLine(recent{k})); - end - end - - function removeActiveOperationFile() - if strlength(activeOperationFile) == 0 - return; - end - try - if exist(activeOperationFile, 'file') == 2 - delete(activeOperationFile); - end - catch - end - end - - function recent = recentLines(maxCount) - first = max(1, numel(lines) - maxCount + 1); - recent = lines(first:end); - end - - function recent = recentOperationLines(maxCount) - recent = {}; - for k = 1:numel(lines) - line = string(lines{k}); - if isReproTraceLine(line) - recent{end + 1, 1} = char(line); - end - end - first = max(1, numel(recent) - maxCount + 1); - recent = recent(first:end); - end - - function updateModalDialogState(component, event) - if string(component) ~= "filePanel" - return; - end - eventText = string(event); - if contains(eventText, "file chooser start") || ... - contains(eventText, "folder chooser start") || ... - contains(eventText, "dialog provider start") || ... - contains(eventText, "large folder prompt") - modalDialogDepth = modalDialogDepth + 1; - elseif contains(eventText, "file chooser end") || ... - contains(eventText, "folder chooser end") || ... - contains(eventText, "dialog provider end") || ... - contains(eventText, "paths selected") - modalDialogDepth = max(0, modalDialogDepth - 1); - end - 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; - if shouldFollowLatest(textArea) - scrollLogToBottom(textArea); - end -end - -function tf = shouldFollowLatest(textArea) - tf = true; - try - if isappdata(textArea, logFollowKey()) - tf = logical(getappdata(textArea, logFollowKey())); - end - catch - tf = true; - end -end - -function scrollLogToBottom(textArea) - try - scroll(textArea, 'bottom'); - catch - end -end - -function key = logFollowKey() - key = 'labkitLogFollowLatest'; -end - -function tf = isAlreadyInstrumented(handle, propName) - tf = false; - key = instrumentationAppdataKey(propName); - try - tf = isappdata(handle, key); - catch - tf = false; - end -end - -function markInstrumented(handle, propName) - try - setappdata(handle, instrumentationAppdataKey(propName), true); - catch - end -end - -function props = defaultCallbackProperties() - props = {'ButtonPushedFcn', 'ValueChangedFcn', 'SelectionChangedFcn', ... - 'CellEditCallback', 'CellSelectionCallback', ... - 'CloseRequestFcn', 'SizeChangedFcn', ... - 'KeyPressFcn', 'KeyReleaseFcn', ... - 'WindowKeyPressFcn', 'WindowKeyReleaseFcn', ... - 'CheckedNodesChangedFcn', 'NodeExpandedFcn', 'NodeCollapsedFcn'}; -end - -function key = instrumentationAppdataKey(propName) - key = ['labkit_ui_debug_instrumented_' char(propName)]; -end - -function initializeLogFile(logFile, appName) - fid = fopen(logFile, 'w'); - if fid < 0 - error('labkit:ui:DebugLogFileOpenFailed', ... - 'Could not open debug log file: %s.', logFile); - end - cleaner = onCleanup(@() fclose(fid)); - fprintf(fid, '%s debug log\n', char(appName)); - fprintf(fid, 'created=%s\n', datestr(now, 31)); - fprintf(fid, 'matlab=%s\n', version); - fprintf(fid, 'platform=%s\n', computer); -end - -function appendLine(logFile, line) - fid = fopen(logFile, 'a'); - if fid < 0 - error('labkit:ui:DebugLogFileOpenFailed', ... - 'Could not open debug log file: %s.', logFile); - end - cleaner = onCleanup(@() fclose(fid)); - fprintf(fid, '%s\n', char(line)); -end - -function filepath = defaultReportFile(logFile, suffix) - logFile = string(logFile); - if strlength(logFile) == 0 - filepath = ""; - return; - end - [folder, name] = fileparts(logFile); - filepath = fullfile(folder, sprintf('%s_%s.txt', char(name), char(suffix))); -end - -function value = normalizePositiveScalar(value, defaultValue) - try - value = double(value); - if ~isscalar(value) || ~isfinite(value) || value < 0 - value = defaultValue; - end - catch - value = defaultValue; - end -end - -function op = emptyOperation() - op = struct('id', '', 'label', '', 'startedAt', '', ... - 'startTic', [], 'timer', []); -end - -function id = operationId() - [~, seed] = fileparts(tempname); - id = char(matlab.lang.makeValidName(seed)); -end - -function seconds = elapsedSeconds(op) - seconds = NaN; - try - seconds = toc(op.startTic); - catch - end -end - -function text = sanitizeReportLine(value) - text = char(string(value)); - text = strrep(text, newline, ' '); - text = strrep(text, sprintf('\r'), ' '); -end - -function writeStack(fid, exception) - stack = exception.stack; - for k = 1:numel(stack) - fprintf(fid, 'stack_%d=%s:%d %s\n', k, ... - sanitizeReportLine(stack(k).file), stack(k).line, ... - sanitizeReportLine(stack(k).name)); - end -end - -function writeRecentOperations(fid, recent) - if isempty(recent) - fprintf(fid, '(none captured)\n'); - return; - end - for k = 1:numel(recent) - fprintf(fid, '%d. %s\n', k, sanitizeReportLine(recent{k})); - end -end - -function tf = isReproTraceLine(line) - tf = contains(line, '[debug]') && ... - (contains(line, 'BEGIN ') || contains(line, 'ERROR ') || ... - contains(line, 'component=filePanel') || ... - contains(line, ' callback start') || contains(line, ' callback end')); -end - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isstruct(opts) && isfield(opts, name) - value = opts.(name); - end -end - -function text = traceValue(value) - text = char(string(value)); - text = strrep(text, newline, ' '); - text = strrep(text, sprintf('\r'), ' '); -end diff --git a/+labkit/+ui/+debug/private/callbackWrapperForHandle.m b/+labkit/+ui/+debug/private/callbackWrapperForHandle.m deleted file mode 100644 index 5e8ee9df0..000000000 --- a/+labkit/+ui/+debug/private/callbackWrapperForHandle.m +++ /dev/null @@ -1,92 +0,0 @@ -% Private UI debug helper. Expected caller: labkit.ui.debug.context -% instrumentation. Inputs are a GUI handle callback property and debug lifecycle -% hooks. Output is a wrapped callback preserving the original callback calling -% convention while emitting BEGIN, END, and ERROR trace lines. -function wrapped = callbackWrapperForHandle(handle, propName, callback, traceFcn, startOperationFcn, finishOperationFcn) - - if isa(callback, 'function_handle') - wrapped = @wrappedFunctionHandle; - elseif iscell(callback) && ~isempty(callback) && isa(callback{1}, 'function_handle') - wrapped = @wrappedCellCallback; - else - wrapped = []; - end - - function varargout = wrappedFunctionHandle(varargin) - label = callbackTraceLabel(handle, propName, callback); - op = startOperationFcn(label); - traceFcn(sprintf('BEGIN %s', label)); - try - if nargout == 0 - callback(varargin{:}); - traceFcn(sprintf('END %s', label)); - finishOperationFcn(op, "completed", []); - else - [varargout{1:nargout}] = callback(varargin{:}); - traceFcn(sprintf('END %s', label)); - finishOperationFcn(op, "completed", []); - end - catch ME - traceFcn(sprintf('ERROR %s: %s %s', label, ME.identifier, ME.message)); - finishOperationFcn(op, "error", ME); - rethrow(ME); - end - end - - function varargout = wrappedCellCallback(varargin) - callbackFcn = callback{1}; - callbackArgs = callback(2:end); - label = callbackTraceLabel(handle, propName, callbackFcn); - op = startOperationFcn(label); - traceFcn(sprintf('BEGIN %s', label)); - try - if nargout == 0 - callbackFcn(varargin{:}, callbackArgs{:}); - traceFcn(sprintf('END %s', label)); - finishOperationFcn(op, "completed", []); - else - [varargout{1:nargout}] = callbackFcn(varargin{:}, callbackArgs{:}); - traceFcn(sprintf('END %s', label)); - finishOperationFcn(op, "completed", []); - end - catch ME - traceFcn(sprintf('ERROR %s: %s %s', label, ME.identifier, ME.message)); - finishOperationFcn(op, "error", ME); - rethrow(ME); - end - end -end - -function label = callbackTraceLabel(handle, propName, callback) - label = sprintf('%s %s', char(string(propName)), debugHandleLabel(handle)); - 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 txt = callbackNameText(callback) - txt = ""; - if ~isa(callback, 'function_handle') - return; - end - try - txt = string(func2str(callback)); - catch - txt = ""; - end -end diff --git a/+labkit/+ui/+debug/private/debugArtifactPaths.m b/+labkit/+ui/+debug/private/debugArtifactPaths.m deleted file mode 100644 index d2692fa77..000000000 --- a/+labkit/+ui/+debug/private/debugArtifactPaths.m +++ /dev/null @@ -1,69 +0,0 @@ -% Expected caller: labkit.ui.debug.context. Inputs are debug opts and -% log path. Output is an app-neutral debug artifact path struct. Side effects: -% creates artifact, sample, and output folders when enabled. -function paths = debugArtifactPaths(opts, logFile, enabled) - artifactFolder = string(optionValue(opts, 'artifactFolder', ... - defaultArtifactFolder(logFile))); - sampleFolder = string(optionValue(opts, 'sampleFolder', ... - defaultSubfolder(artifactFolder, "samples"))); - outputFolder = string(optionValue(opts, 'outputFolder', ... - defaultSubfolder(artifactFolder, "outputs"))); - manifestFile = string(optionValue(opts, 'manifestFile', ... - defaultManifestFile(artifactFolder))); - - if enabled - ensureDirectory(artifactFolder); - ensureDirectory(sampleFolder); - ensureDirectory(outputFolder); - end - - paths = struct( ... - "artifactFolder", artifactFolder, ... - "sampleFolder", sampleFolder, ... - "outputFolder", outputFolder, ... - "manifestFile", manifestFile); -end - -function folder = defaultArtifactFolder(logFile) - logFile = string(logFile); - if strlength(logFile) == 0 - folder = ""; - return; - end - folder = string(fileparts(char(logFile))); -end - -function folder = defaultSubfolder(parentFolder, name) - parentFolder = string(parentFolder); - if strlength(parentFolder) == 0 - folder = ""; - return; - end - folder = string(fullfile(char(parentFolder), char(string(name)))); -end - -function filepath = defaultManifestFile(artifactFolder) - artifactFolder = string(artifactFolder); - if strlength(artifactFolder) == 0 - filepath = ""; - return; - end - filepath = string(fullfile(char(artifactFolder), "manifest.json")); -end - -function ensureDirectory(folder) - folder = string(folder); - if strlength(folder) == 0 - return; - end - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end - -function value = optionValue(opts, fieldName, defaultValue) - value = defaultValue; - if isstruct(opts) && isfield(opts, fieldName) - value = opts.(fieldName); - end -end diff --git a/+labkit/+ui/+debug/private/debugHandleLabel.m b/+labkit/+ui/+debug/private/debugHandleLabel.m deleted file mode 100644 index a586658ab..000000000 --- a/+labkit/+ui/+debug/private/debugHandleLabel.m +++ /dev/null @@ -1,21 +0,0 @@ -% Private UI debug helper. Expected caller: labkit.ui.debug.context and private -% debug callback wrappers. Input is a MATLAB graphics or UI handle. Output is a -% compact label that prefers user-visible properties when available and -% otherwise falls back to the MATLAB class name. -function label = debugHandleLabel(handle) - - label = class(handle); - for propName = {'Text', 'Title', 'Name', 'Tag'} - prop = propName{1}; - if isprop(handle, prop) - try - value = handle.(prop); - if ~(isempty(value) || (isstring(value) && strlength(value) == 0)) - label = sprintf('%s "%s"', class(handle), char(string(value))); - return; - end - catch - end - end - end -end diff --git a/+labkit/+ui/+debug/private/writeDebugManifest.m b/+labkit/+ui/+debug/private/writeDebugManifest.m deleted file mode 100644 index 1f26bc3e0..000000000 --- a/+labkit/+ui/+debug/private/writeDebugManifest.m +++ /dev/null @@ -1,34 +0,0 @@ -% Expected caller: labkit.ui.debug.context. Inputs are a manifest path, -% sample-pack manifest, and debug metadata. Side effect: writes manifest JSON. -function writeDebugManifest(filepath, manifest, metadata) - if nargin < 2 || isempty(manifest) - manifest = struct(); - end - if ~isstruct(manifest) - manifest = struct("value", string(manifest)); - end - - payload = manifest; - payload.debug = metadata; - text = jsonencode(payload, "PrettyPrint", true); - - folder = string(fileparts(char(filepath))); - ensureDirectory(folder); - fid = fopen(char(filepath), "w", "n", "UTF-8"); - if fid < 0 - error('labkit:ui:DebugManifestWriteFailed', ... - 'Could not write debug manifest file: %s.', filepath); - end - cleaner = onCleanup(@() fclose(fid)); - fprintf(fid, "%s\n", char(string(text))); -end - -function ensureDirectory(folder) - folder = string(folder); - if strlength(folder) == 0 - return; - end - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/+labkit/+ui/+interaction/anchorPath.m b/+labkit/+ui/+interaction/anchorPath.m deleted file mode 100644 index 4edb726f5..000000000 --- a/+labkit/+ui/+interaction/anchorPath.m +++ /dev/null @@ -1,65 +0,0 @@ -function [curve, owners] = anchorPath(points, imageSize, options) -%ANCHORPATH Build a visible path through image anchor points. -% -% Usage: -% curve = labkit.ui.interaction.anchorPath(points, imageSize) -% curve = labkit.ui.interaction.anchorPath(points, imageSize, Name=Value) -% [curve, owners] = labkit.ui.interaction.anchorPath(...) -% -% Inputs: -% points - N-by-2 numeric matrix of [x y] anchor coordinates in image-pixel -% coordinates. -% imageSize - Numeric vector whose first two elements are the positive finite -% image height and width. -% options - Name-value argument container for Style and Closed. Supply these -% values by name; do not pass an options struct. -% -% Name-Value Arguments: -% Style - "Curve" for a Catmull-Rom path or "Straight lines" for line -% segments joining the anchors. Default: "Curve". -% Closed - Logical scalar. true joins the last anchor to the first and -% requires at least three anchors. false requires at least two. Default: -% false. -% -% Outputs: -% curve - M-by-2 path samples. Coordinates are limited to pixel-edge bounds -% [0.5, width+0.5] and [0.5, height+0.5]. The result is empty until the -% selected open or closed path has enough anchors. -% owners - (M-1)-by-1 anchor-segment indices used to associate each visible -% curve segment with its starting anchor. It is empty with curve. -% -% Description: -% anchorPath contains the deterministic geometry shared by managed anchor -% editors and app previews. Curved paths pass through the supplied anchors; -% two-point open curves reduce to a straight segment. The function creates no -% graphics and can be used independently of a LabKit app. -% -% Errors: -% MATLAB argument-validation or assertion errors are raised when points is -% not N-by-2, imageSize lacks positive finite height and width, Style is -% unsupported, or a named argument has an incompatible type or shape. -% -% Example: -% points = [10 30; 30 10; 50 30]; -% curve = labkit.ui.interaction.anchorPath( ... -% points, [40 60], "Style", "Straight lines"); -% assert(isequal(curve, points)) -% -% See also labkit.ui.runtime.define - - arguments - points double - imageSize double - options.Style (1, 1) string = "Curve" - options.Closed (1, 1) logical = false - end - assert(isempty(points) || size(points, 2) == 2, ... - 'Anchor points must be an N-by-2 numeric array.'); - assert(numel(imageSize) >= 2 && all(isfinite(imageSize(1:2))) && ... - all(imageSize(1:2) > 0), ... - 'Image size must contain positive finite height and width values.'); - assert(any(options.Style == ["Curve", "Straight lines"]), ... - 'Style must be "Curve" or "Straight lines".'); - [curve, owners] = anchorCurvePoints(points, imageSize, ... - options.Style, options.Closed); -end diff --git a/+labkit/+ui/+interaction/private/applyAxesStyleCommand.m b/+labkit/+ui/+interaction/private/applyAxesStyleCommand.m deleted file mode 100644 index 8a7b69517..000000000 --- a/+labkit/+ui/+interaction/private/applyAxesStyleCommand.m +++ /dev/null @@ -1,111 +0,0 @@ -% Private UI interaction helper. Expected caller: copied popout-figure controls. -% Input is the copied axes and a scalar command string. Side effects are -% limited to style properties on the copied axes, labels, legends, colorbars, -% and plotted graphics objects. -function applyAxesStyleCommand(ax, command) - if isempty(ax) || ~isvalid(ax) - return; - end - switch string(command) - case "fontIncrease" - adjustFont(ax, 1); - case "fontDecrease" - adjustFont(ax, -1); - case "lineIncrease" - adjustLineWidth(ax, 0.25); - case "lineDecrease" - adjustLineWidth(ax, -0.25); - case "axesIncrease" - adjustAxesLineWidth(ax, 0.25); - case "axesDecrease" - adjustAxesLineWidth(ax, -0.25); - case "gridIncrease" - adjustGrid(ax, 0.10); - case "gridDecrease" - adjustGrid(ax, -0.10); - otherwise - error('labkit:ui:InvalidPopoutStyleCommand', ... - 'Unsupported popout style command "%s".', string(command)); - end - drawnow limitrate; -end - -function adjustFont(ax, delta) - handles = fontTargets(ax); - for k = 1:numel(handles) - handle = handles{k}; - if isempty(handle) || ~isvalid(handle) || ~isprop(handle, 'FontSize') - continue; - end - try - handle.FontSize = min(36, max(6, double(handle.FontSize) + delta)); - catch - end - end -end - -function handles = fontTargets(ax) - handles = {ax; ax.Title; ax.XLabel; ax.YLabel; ax.ZLabel}; - parentFig = ancestor(ax, 'figure'); - legends = findall(parentFig, 'Type', 'legend'); - colorbars = findall(parentFig, 'Type', 'colorbar'); - textObjects = findall(ax, 'Type', 'text'); - handles = [handles; num2cell(legends(:)); num2cell(colorbars(:)); ... - num2cell(textObjects(:))]; -end - -function adjustLineWidth(ax, delta) - handles = lineWidthTargets(ax); - for k = 1:numel(handles) - handle = handles(k); - if isempty(handle) || ~isvalid(handle) || ~isprop(handle, 'LineWidth') - continue; - end - try - handle.LineWidth = max(0.25, double(handle.LineWidth) + delta); - catch - end - end -end - -function adjustAxesLineWidth(ax, delta) - try - ax.LineWidth = max(0.25, min(5, double(ax.LineWidth) + delta)); - catch - end -end - -function adjustGrid(ax, delta) - try - ax.XGrid = 'on'; - ax.YGrid = 'on'; - if isprop(ax, 'ZGrid') - ax.ZGrid = 'on'; - end - ax.GridAlpha = max(0.05, min(1, double(ax.GridAlpha) + delta)); - if isprop(ax, 'MinorGridAlpha') - ax.MinorGridAlpha = max(0.05, min(1, double(ax.MinorGridAlpha) + delta)); - end - catch - end -end - -function handles = lineWidthTargets(ax) - handles = findall(ax, '-property', 'LineWidth'); - keep = false(size(handles)); - for k = 1:numel(handles) - keep(k) = isDataGraphic(handles(k)); - end - handles = handles(keep); -end - -function tf = isDataGraphic(handle) - tf = isa(handle, 'matlab.graphics.chart.primitive.Line') || ... - isa(handle, 'matlab.graphics.chart.primitive.Scatter') || ... - isa(handle, 'matlab.graphics.chart.primitive.Bar') || ... - isa(handle, 'matlab.graphics.chart.primitive.Stem') || ... - isa(handle, 'matlab.graphics.chart.primitive.ErrorBar') || ... - isa(handle, 'matlab.graphics.primitive.Patch') || ... - isa(handle, 'matlab.graphics.primitive.Surface') || ... - isa(handle, 'matlab.graphics.primitive.Image'); -end diff --git a/+labkit/+ui/+interaction/private/createPopoutToolbar.m b/+labkit/+ui/+interaction/private/createPopoutToolbar.m deleted file mode 100644 index c20396caf..000000000 --- a/+labkit/+ui/+interaction/private/createPopoutToolbar.m +++ /dev/null @@ -1,107 +0,0 @@ -% Private UI interaction helper. Expected caller: labkit.ui.interaction.popout. -% Inputs are a standalone MATLAB figure and its copied axes. Side effects: -% installs visible text buttons whose callbacks mutate only the copied figure -% or mark it for a future Studio handoff; source app axes and app state are not -% touched. -function toolbar = createPopoutToolbar(fig, ax) - toolbar = uipanel(fig, 'Tag', 'labkitAxesPopoutToolbar', ... - 'BorderType', 'none', 'Units', 'normalized', ... - 'Position', [0.00 0.93 1.00 0.07], ... - 'BackgroundColor', [0.94 0.94 0.94]); - labels = ["Font +", "Font -", "Line +", "Line -", ... - "Axes +", "Axes -", "Grid +", "Grid -", "X labels /", ... - "Send to Studio"]; - tags = ["labkitAxesPopoutFontIncreaseTool", ... - "labkitAxesPopoutFontDecreaseTool", ... - "labkitAxesPopoutLineIncreaseTool", ... - "labkitAxesPopoutLineDecreaseTool", ... - "labkitAxesPopoutAxesIncreaseTool", ... - "labkitAxesPopoutAxesDecreaseTool", ... - "labkitAxesPopoutGridIncreaseTool", ... - "labkitAxesPopoutGridDecreaseTool", ... - "labkitAxesPopoutXLabelRotationTool", ... - "labkitAxesPopoutStudioTool"]; - callbacks = { - @(~,~) applyStyleAndLayout(fig, toolbar, ax, "fontIncrease") - @(~,~) applyStyleAndLayout(fig, toolbar, ax, "fontDecrease") - @(~,~) applyStyleAndLayout(fig, toolbar, ax, "lineIncrease") - @(~,~) applyStyleAndLayout(fig, toolbar, ax, "lineDecrease") - @(~,~) applyStyleAndLayout(fig, toolbar, ax, "axesIncrease") - @(~,~) applyStyleAndLayout(fig, toolbar, ax, "axesDecrease") - @(~,~) applyStyleAndLayout(fig, toolbar, ax, "gridIncrease") - @(~,~) applyStyleAndLayout(fig, toolbar, ax, "gridDecrease") - @(tool,~) toggleXLabelRotation(tool, fig, toolbar, ax) - @(~,~) sendToStudio(fig, ax)}; - for k = 1:numel(labels) - addTool(toolbar, labels(k), tags(k), k, numel(labels), callbacks{k}); - end - rotationTool = findobj(toolbar, 'Tag', 'labkitAxesPopoutXLabelRotationTool'); - updateXLabelRotationTool(rotationTool, ax); - layoutPopoutAxes(fig, toolbar, ax); - fig.SizeChangedFcn = @(~,~) layoutPopoutAxes(fig, toolbar, ax); -end - -function tool = addTool(parent, label, tag, index, count, callback) - width = 1 / count; - tool = uicontrol(parent, 'Style', 'pushbutton', ... - 'String', char(label), ... - 'Tag', char(tag), ... - 'Units', 'normalized', ... - 'Position', [(index - 1) * width, 0, width, 1], ... - 'FontSize', 10, ... - 'FontWeight', 'normal', ... - 'BackgroundColor', [0.96 0.96 0.96], ... - 'Callback', callback); -end - -function applyStyleAndLayout(fig, toolbar, ax, command) - applyAxesStyleCommand(ax, command); - layoutPopoutAxes(fig, toolbar, ax); -end - -function toggleXLabelRotation(tool, fig, toolbar, ax) - if abs(ax.XTickLabelRotation) < eps - ax.XTickLabelRotation = 45; - else - ax.XTickLabelRotation = 0; - end - updateXLabelRotationTool(tool, ax); - layoutPopoutAxes(fig, toolbar, ax); -end - -function updateXLabelRotationTool(tool, ax) - if abs(ax.XTickLabelRotation) < eps - tool.String = 'X labels /'; - else - tool.String = 'X labels -'; - end - tool.TooltipString = ... - 'Switch X-axis tick labels between angled and horizontal.'; -end - -function layoutPopoutAxes(fig, toolbar, ax) - if isempty(fig) || ~isvalid(fig) || isempty(toolbar) || ... - ~isvalid(toolbar) || isempty(ax) || ~isvalid(ax) - return; - end - toolbar.Units = 'normalized'; - toolbar.Position = [0.00 0.93 1.00 0.07]; - ax.Units = 'normalized'; - ax.OuterPosition = [0.02 0.02 0.96 0.89]; - ax.ActivePositionProperty = 'outerposition'; - drawnow limitrate; -end - -function sendToStudio(fig, ax) - launcher = []; - if isappdata(groot, 'labkitFigureStudioLauncher') - launcher = getappdata(groot, 'labkitFigureStudioLauncher'); - end - if isa(launcher, 'function_handle') - launcher(ax); - return; - end - setappdata(fig, 'labkitFigureStudioPendingAxes', ax); - warning('labkit:ui:FigureStudioUnavailable', ... - 'Figure Studio is not available yet. The copied axes were marked for Studio handoff.'); -end diff --git a/+labkit/+ui/+interaction/scaleBarCalibration.m b/+labkit/+ui/+interaction/scaleBarCalibration.m deleted file mode 100644 index a41456a91..000000000 --- a/+labkit/+ui/+interaction/scaleBarCalibration.m +++ /dev/null @@ -1,125 +0,0 @@ -function cal = scaleBarCalibration(referencePixels, referenceLength, unitName, opts) -%SCALEBARCALIBRATION Convert a known image distance into pixels per unit. -% -% Usage: -% cal = labkit.ui.interaction.scaleBarCalibration(referencePixels, ... -% referenceLength, unitName) -% cal = labkit.ui.interaction.scaleBarCalibration(..., opts) -% -% Inputs: -% referencePixels - Measured reference distance in image pixels. Empty, -% nonnumeric, nonfinite, or nonpositive values are treated as missing. -% referenceLength - Physical reference distance expressed in unitName. -% Missing, nonfinite, or negative values become 0. -% unitName - Unit label. With default options, legal values are "m", "cm", -% "mm", "um", and "nm". An unsupported value uses defaultUnit. -% opts - Optional scalar struct described below. Default: struct(). -% -% Options: -% units - Allowed unit labels. Default: {'m','cm','mm','um','nm'}. -% defaultUnit - Fallback unit. Default: the first entry in units. -% referenceLine - N-by-2 numeric reference points stored with the result. -% When it contains exactly two rows and referencePixels is missing, their -% Euclidean distance supplies referencePixels. Default: zeros(0,2). -% -% Outputs: -% cal - Scalar struct with the fields described below. -% -% Calibration Fields: -% referencePixels - Positive measured pixel distance, or NaN when missing. -% referenceLength - Nonnegative physical reference distance. -% unit - Normalized unit label as a character vector. -% pixelsPerUnit - referencePixels/referenceLength, or 0 when calibration is -% incomplete. -% isCalibrated - true when pixelsPerUnit is positive. -% referenceLine - Normalized N-by-2 numeric reference coordinates. -% -% Description: -% This function builds a serializable calibration value; it does not read an -% image or draw a scale bar. Repeating the call with identical inputs returns -% identical numeric fields. Divide a pixel distance by pixelsPerUnit to obtain -% a distance in cal.unit. -% -% Failure Behavior: -% Missing or invalid measurement values are normalized into an uncalibrated -% result instead of throwing. opts must be a scalar structure whose units -% can be converted to text and whose referenceLine can be converted to an -% N-by-2 numeric array; incompatible MATLAB values propagate conversion -% errors. -% -% Example: -% cal = labkit.ui.interaction.scaleBarCalibration(80, 20, "mm"); -% physicalLength = 40 / cal.pixelsPerUnit; -% assert(cal.isCalibrated && physicalLength == 10) -% -% See also labkit.ui.interaction.scaleBarGeometry - - if nargin < 1 || isempty(referencePixels) - referencePixels = NaN; - end - if nargin < 2 || isempty(referenceLength) - referenceLength = 0; - end - if nargin < 3 || isempty(unitName) - unitName = ""; - end - if nargin < 4 - opts = struct(); - end - - units = cellstr(string(optionValue(opts, 'units', defaultScaleBarUnits()))); - defaultUnit = char(string(optionValue(opts, 'defaultUnit', units{1}))); - referenceLine = normalizeReferenceLine(optionValue(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); - unitName = char(normalizeScaleBarUnit(unitName, units, defaultUnit)); - pixelsPerUnit = 0; - if isfinite(referencePixels) && referencePixels > 0 && referenceLength > 0 - pixelsPerUnit = referencePixels / referenceLength; - end - - cal = struct( ... - 'referencePixels', referencePixels, ... - 'referenceLength', referenceLength, ... - 'unit', unitName, ... - 'pixelsPerUnit', pixelsPerUnit, ... - 'isCalibrated', pixelsPerUnit > 0, ... - 'referenceLine', referenceLine); -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 value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isstruct(opts) && isfield(opts, name) - value = opts.(name); - end -end diff --git a/+labkit/+ui/+layout/action.m b/+labkit/+ui/+layout/action.m deleted file mode 100644 index df97a5060..000000000 --- a/+labkit/+ui/+layout/action.m +++ /dev/null @@ -1,52 +0,0 @@ -function layout = action(id, labelText, onInvoke, varargin) -%ACTION Create an app-command layout node. -% -% Usage: -% layout = labkit.ui.layout.action(id, labelText, onInvoke) -% layout = labkit.ui.layout.action(id, labelText, onInvoke, Name=Value) -% -% Inputs: -% id - Text scalar used to identify the action. It must be a valid MATLAB -% variable name and unique within the workbench. -% labelText - Text displayed on the push button. -% onInvoke - Function handle called as onInvoke(control,event) after the -% button is pressed. Use [] when a runtime binding will supply behavior. -% The default is []. -% -% Name-Value Arguments: -% enabled - Logical value controlling whether the button can be pressed. -% Default: true. -% busyMessage - Text appended to the app title while onInvoke runs. Default: -% labelText. -% -% Outputs: -% layout - Scalar action node with kind, id, props, children, and slots -% fields. The node creates no graphics until the workbench is launched. -% -% Description: -% action represents one app command. A section may contain an action directly, -% or several actions may be placed in an action group. The runtime ignores a -% press while the figure is already busy and restores the normal title after -% the callback completes or throws. -% -% Errors: -% labkit:ui:layout:InvalidId - id is not a valid MATLAB field name. -% labkit:ui:layout:InvalidOptions or InvalidOptionName - Name-value -% arguments are unpaired or an option name is not valid scalar text. -% Callback value compatibility is checked when the runtime builds the node. -% -% Example: -% runAction = labkit.ui.layout.action( ... -% "runAnalysis", "Run analysis", @(~,~) disp("Running")); -% assert(runAction.kind == "action") -% -% See also labkit.ui.layout.group, labkit.ui.layout.section - - if nargin < 3 - onInvoke = []; - end - props = optionStruct(varargin); - props.label = char(string(labelText)); - props.onInvoke = onInvoke; - layout = makeLayoutNode('action', id, props, {}, struct()); -end diff --git a/+labkit/+ui/+layout/field.m b/+labkit/+ui/+layout/field.m deleted file mode 100644 index ce3ae59b1..000000000 --- a/+labkit/+ui/+layout/field.m +++ /dev/null @@ -1,81 +0,0 @@ -function layout = field(id, labelText, varargin) -%FIELD Create a labeled scalar field layout node. -% -% Usage: -% layout = labkit.ui.layout.field(id, labelText) -% layout = labkit.ui.layout.field(id, labelText, Name=Value) -% -% Inputs: -% id - Text scalar used to identify the field. It must be a valid MATLAB -% variable name and unique within the workbench. -% labelText - Text displayed beside the field. A checkbox displays it as the -% checkbox label. -% -% Name-Value Arguments: -% kind - Control type: "text", "number", "spinner", "dropdown", "slider", -% "checkbox", or "readonly". Default: "text". -% value - Initial control value. When omitted, the selected MATLAB control's -% default value is used. -% items - Dropdown choices as text, a string array, or cellstr. Used only by -% kind="dropdown". -% limits - Two-element numeric limits for number, spinner, or slider fields. -% step - Spinner step size. -% valueDisplayFormat - MATLAB numeric display format such as "%.3f". -% showTicks - Logical value controlling slider tick marks. MATLAB's slider -% ticks are used when omitted; false hides both major and minor ticks. -% enabled - Logical value controlling whether the field accepts input. -% Default: true. -% onChange - Function handle called as onChange(control,event). event.value -% contains the current field value. -% debounceMs - Delay before onChange runs, in milliseconds. A later change -% restarts the delay. Default: 500. Use 0 for immediate dispatch. -% Bind - Runtime V2 state path such as "project.parameters.gain" or -% "session.selection.channel". The path must exist in initial state. -% Event - Action ID dispatched after a bound value is written. Event requires -% Bind; omit Event when the binding alone is sufficient. -% -% Outputs: -% layout - Scalar field node with kind, id, props, children, and slots fields. -% -% Description: -% field covers common single-value controls. In a Runtime V2 app, Bind makes -% state the source of truth: the runtime initializes the control from that -% path, writes user changes back, and then dispatches Event when supplied. -% Without Bind, onChange is the low-level callback used by runtime.create. -% -% Errors: -% labkit:ui:layout:InvalidFieldKind - kind is not one of the documented -% control types. -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. Control-specific values are validated -% when the runtime builds the node. -% -% Example: -% gain = labkit.ui.layout.field("gain", "Gain", ... -% "kind", "spinner", "value", 2, "limits", [0 10], "step", 0.5); -% assert(gain.props.value == 2) -% -% See also labkit.ui.layout.panner, labkit.ui.layout.rangeField - - props = optionStruct(varargin); - props.label = char(string(labelText)); - props.kind = char(string(optionValue(props, 'kind', 'text'))); - validateFieldKind(props.kind); - layout = makeLayoutNode('field', id, props, {}, struct()); -end - -function validateFieldKind(kind) - allowed = {'text', 'number', 'spinner', 'dropdown', 'slider', ... - 'checkbox', 'readonly'}; - if ~any(strcmpi(kind, allowed)) - error('labkit:ui:layout:InvalidFieldKind', ... - 'Unsupported declarative 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/+layout/filePanel.m b/+labkit/+ui/+layout/filePanel.m deleted file mode 100644 index 84b8e1bf1..000000000 --- a/+labkit/+ui/+layout/filePanel.m +++ /dev/null @@ -1,135 +0,0 @@ -function layout = filePanel(id, labelText, varargin) -%FILEPANEL Create a file input panel layout node. -% -% Usage: -% layout = labkit.ui.layout.filePanel(id, labelText) -% layout = labkit.ui.layout.filePanel(id, labelText, Name=Value) -% -% Inputs: -% id - Text scalar used to identify the panel. It must be a valid MATLAB -% variable name and unique within the workbench. -% labelText - Text displayed in the panel title. -% -% Name-Value Arguments: -% mode - "multi" accumulates files and provides file, folder, recursive -% folder, remove, and clear actions. "single" shows one chooser and -% replaces the previous file. Default: "multi". -% filters - uigetfile-style filter cell array used by file selection and -% folder scans. Default: {'*.*','All files'}. -% selectionMode - List selection mode in a multi panel: "single" or -% "multiple". Default: "single". -% maxFiles - Positive maximum number of retained entries. Inf has no limit. -% Single mode always retains one file. Default: Inf. -% folderWarningThreshold - Nonnegative match count above which a folder scan -% asks for confirmation. Default: 500. -% showStatus - Logical value controlling the count/status box in multi mode. -% Default: true. -% startPath - Preferred initial file-dialog folder. A missing or unsafe path -% falls back to the last LabKit input folder or the user's home folder. -% chooseLabel - File button text. Default: "Add..." in multi mode and -% "Choose..." in single mode. -% folderLabel - Direct-folder button text. Default: "Add folder". -% recursiveFolderLabel - Recursive-folder button text. Default: -% "Add folder tree". -% removeLabel - Remove button text. Default: "Remove selected". -% clearLabel - Clear button text. Default: "Clear". -% emptyText - Text shown when no files are loaded. Default: -% "No files loaded". -% onChoose - Callback called as onChoose(control,event) after files are added. -% onRemove - Callback called after the selected entries are removed. -% onClear - Callback called after every entry is removed. -% onSelectionChange - Callback called when list selection changes. -% -% Callback Event Fields: -% action - "choose", "remove", "clear", or "select". -% files - Complete file-entry array after the action. -% selectedFiles - Currently selected entries after the action. -% value - Same value as selectedFiles. -% addedFiles - Entries added by a choose action. -% removedFiles - Entries removed by a remove or clear action. -% -% File Entry Fields: -% id - Stable entry ID generated by the panel unless supplied by the app. -% index - One-based position in the current file list. -% path - Full selected file path. -% name - File name and extension. -% displayName - User-facing label; defaults to name. -% status - Optional app-authored status text shown with the file label. -% -% Outputs: -% layout - Scalar filePanel node with kind, id, props, children, and slots -% fields. -% -% Description: -% Multi-file selection uses one native file dialog and therefore returns -% files from one folder. Folder actions cover the other common cases: direct -% children only or all matching files recursively. Cancelling any chooser -% leaves the current list unchanged. Files beyond maxFiles are discarded in -% selection order. -% -% Errors: -% labkit:ui:layout:InvalidFilePanelMode or -% InvalidFilePanelSelectionMode - A mode label is unsupported. -% labkit:ui:layout:InvalidFilePanelMaxFiles or -% InvalidFilePanelWarningThreshold - A numeric limit is invalid. -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. -% -% Typical Call: -% files = labkit.ui.layout.filePanel("images", "Images", ... -% "filters", {'*.png;*.tif','Images'}, "selectionMode", "multiple"); -% -% See also labkit.ui.layout.section - - props = optionStruct(varargin); - props.label = char(string(labelText)); - props.mode = char(string(optionValue(props, 'mode', 'multi'))); - validateMode(props.mode); - props.selectionMode = char(string(optionValue(props, ... - 'selectionMode', 'single'))); - validateSelectionMode(props.selectionMode); - props.maxFiles = optionValue(props, 'maxFiles', Inf); - validateMaxFiles(props.maxFiles); - props.folderWarningThreshold = optionValue(props, ... - 'folderWarningThreshold', 500); - validateWarningThreshold(props.folderWarningThreshold); - props.showStatus = logical(optionValue(props, 'showStatus', true)); - layout = makeLayoutNode('filePanel', id, props, {}, struct()); -end - -function validateMode(mode) - allowed = {'multi', 'single'}; - if ~any(strcmp(mode, allowed)) - error('labkit:ui:layout:InvalidFilePanelMode', ... - 'Unsupported filePanel mode "%s".', mode); - end -end - -function validateSelectionMode(mode) - allowed = {'single', 'multiple'}; - if ~any(strcmp(mode, allowed)) - error('labkit:ui:layout:InvalidFilePanelSelectionMode', ... - 'Unsupported filePanel selectionMode "%s".', mode); - end -end - -function validateMaxFiles(value) - if ~(isnumeric(value) && isscalar(value) && value >= 1) - error('labkit:ui:layout:InvalidFilePanelMaxFiles', ... - 'filePanel maxFiles must be a positive scalar.'); - end -end - -function validateWarningThreshold(value) - if ~(isnumeric(value) && isscalar(value) && isfinite(value) && value >= 0) - error('labkit:ui:layout:InvalidFilePanelWarningThreshold', ... - 'filePanel folderWarningThreshold must be a nonnegative 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/+layout/group.m b/+labkit/+ui/+layout/group.m deleted file mode 100644 index 84ff4f211..000000000 --- a/+labkit/+ui/+layout/group.m +++ /dev/null @@ -1,59 +0,0 @@ -function layout = group(id, titleText, children, varargin) -%GROUP Create a grouped UI layout. -% -% Usage: -% layout = labkit.ui.layout.group(id, titleText, children) -% layout = labkit.ui.layout.group(id, titleText, children, "layout", mode) -% -% Inputs: -% id - Text scalar used to identify the group. It must be a valid MATLAB -% variable name and unique within the workbench. -% titleText - Optional group title. Use "" for an untitled group. Default: -% "". -% children - Nonempty cell row vector containing field, rangeField, panner, -% action, or nested group nodes. Default: {}. An empty group can be -% constructed but is rejected when the workbench is launched. -% -% Name-Value Arguments: -% layout - Group presentation mode: "auto", "actions", "form", "inline", -% or "grid". "auto" uses an action-button layout when every child is an -% action and a form layout otherwise. "actions" requires every child to -% be an action. The other modes currently use the standard form -% presentation. Default: "auto". -% -% Outputs: -% layout - Scalar group node with kind, id, props, children, and slots fields. -% -% Description: -% group keeps related controls together inside one section. It is useful for -% a row or block of commands and for a labeled subsection of fields. Concrete -% row heights, spacing, and column widths are selected by the workbench. -% -% Errors: -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. -% labkit:ui:layout:InvalidChildren - children is not a cell row of scalar -% layout nodes. Child kinds and layout mode compatibility are validated at -% workbench launch. -% -% Example: -% commands = labkit.ui.layout.group("commands", "Commands", { ... -% labkit.ui.layout.action("run", "Run", []), ... -% labkit.ui.layout.action("reset", "Reset", [])}); -% assert(numel(commands.children) == 2) -% -% See also labkit.ui.layout.action, labkit.ui.layout.section - - if nargin < 2 - titleText = ""; - end - if nargin < 3 - children = {}; - end - props = optionStruct(varargin); - props.title = char(string(titleText)); - if ~isfield(props, 'layout') - props.layout = 'auto'; - end - layout = makeLayoutNode('group', id, props, children, struct()); -end diff --git a/+labkit/+ui/+layout/logPanel.m b/+labkit/+ui/+layout/logPanel.m deleted file mode 100644 index 980589c46..000000000 --- a/+labkit/+ui/+layout/logPanel.m +++ /dev/null @@ -1,43 +0,0 @@ -function layout = logPanel(id, titleText, varargin) -%LOGPANEL Create a read-only log panel layout node. -% -% Usage: -% layout = labkit.ui.layout.logPanel(id, titleText) -% layout = labkit.ui.layout.logPanel(id, titleText, "value", lines) -% -% Inputs: -% id - Text scalar used to identify the log. It must be a valid MATLAB -% variable name and unique within the workbench. -% titleText - Text displayed in the panel title. -% -% Name-Value Arguments: -% value - Initial log lines as text, a string array, or cellstr. Default: -% {'Ready.'}. -% -% Outputs: -% layout - Scalar logPanel node with kind, id, props, children, and slots -% fields. -% -% Description: -% logPanel displays changing workflow or diagnostic messages in a read-only -% text area. It follows the newest line by default. Users can pause or resume -% automatic scrolling with the visible button or the context menu. Use the -% workbench usage option for static instructions that should always remain -% visible. -% -% Errors: -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. Text conversion and graphics compatibility -% of value are validated when the runtime builds the panel. -% -% Example: -% logView = labkit.ui.layout.logPanel( ... -% "workflowLog", "Log", "value", ["Ready."; "Waiting for files."]); -% assert(logView.kind == "logPanel") -% -% See also labkit.ui.layout.statusPanel, labkit.ui.layout.workbench - - props = optionStruct(varargin); - props.title = char(string(titleText)); - layout = makeLayoutNode('logPanel', id, props, {}, struct()); -end diff --git a/+labkit/+ui/+layout/panner.m b/+labkit/+ui/+layout/panner.m deleted file mode 100644 index 9fba0c244..000000000 --- a/+labkit/+ui/+layout/panner.m +++ /dev/null @@ -1,78 +0,0 @@ -function layout = panner(id, labelText, varargin) -%PANNER Create a numeric spinner with a linked slider. -% -% Usage: -% layout = labkit.ui.layout.panner(id, labelText) -% layout = labkit.ui.layout.panner(id, labelText, Name=Value) -% -% Inputs: -% id - Text scalar used to identify the panner. It must be a valid MATLAB -% variable name and unique within the workbench. -% labelText - Text displayed beside the numeric control. -% -% Name-Value Arguments: -% limits - Increasing two-element numeric vector. Finite limits create a -% spinner linked to a slider; an infinite endpoint creates a spinner only. -% Default: [0 1]. -% value - Initial numeric value. Values outside limits are clamped when the -% control is built. Default: limits(1). -% step - Positive absolute spinner step. When omitted, it is inferred from -% stepFraction for finite limits. -% stepFraction - Fraction of the finite limit span used for the inferred -% step. Default: 0.002. -% minStep - Optional lower bound for the inferred step. -% maxStep - Optional upper bound for the inferred step. -% valueDisplayFormat - Numeric display format such as "%.2f". -% showTicks - Logical value controlling slider ticks. Default: false. -% enabled - Logical value controlling both spinner and slider. Default: true. -% onChange - Function handle called as onChange(control,event). event.value -% is the synchronized value; event.action is "edit" or "slide". -% debounceMs - Delay before onChange runs, in milliseconds. Default: 500. -% Use 0 for immediate dispatch. -% Bind - Runtime V2 path to a scalar project or session value. -% Event - Action ID dispatched after a bound value is written. Requires Bind. -% -% Outputs: -% layout - Scalar panner node with kind, id, props, children, and slots fields. -% -% Description: -% panner gives precise numeric entry and, when both limits are finite, quick -% slider navigation. Editing either control updates the other before the -% callback runs. Reassigning the current value does not fire the callback. -% -% Errors: -% labkit:ui:layout:InvalidPannerLimits - limits is not an increasing -% two-element numeric vector. -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. Remaining numeric/control values are -% validated when the runtime builds the node. -% -% Example: -% frame = labkit.ui.layout.panner("frame", "Frame", ... -% "limits", [1 100], "value", 1, "step", 1); -% assert(frame.props.step == 1) -% -% See also labkit.ui.layout.field, labkit.ui.layout.rangeField - - props = optionStruct(varargin); - props.label = char(string(labelText)); - if ~isfield(props, 'limits') - props.limits = [0 1]; - end - props.limits = numericLimits(props.limits); - if ~isfield(props, 'showTicks') - props.showTicks = false; - end - if ~isfield(props, 'value') - props.value = props.limits(1); - end - layout = makeLayoutNode('panner', id, props, {}, struct()); -end - -function limits = numericLimits(limits) - limits = double(limits(:)).'; - if numel(limits) ~= 2 || any(isnan(limits)) || limits(1) >= limits(2) - error('labkit:ui:layout:InvalidPannerLimits', ... - 'panner limits must be an increasing two-element numeric vector.'); - end -end diff --git a/+labkit/+ui/+layout/previewArea.m b/+labkit/+ui/+layout/previewArea.m deleted file mode 100644 index 7a0fa8749..000000000 --- a/+labkit/+ui/+layout/previewArea.m +++ /dev/null @@ -1,98 +0,0 @@ -function layout = previewArea(id, titleText, varargin) -%PREVIEWAREA Create a workspace preview/axes area layout node. -% -% Usage: -% layout = labkit.ui.layout.previewArea(id, titleText) -% layout = labkit.ui.layout.previewArea(id, titleText, Name=Value) -% -% Inputs: -% id - Text scalar used to identify the preview. It must be a valid MATLAB -% variable name and unique within the workbench. -% titleText - Text displayed in the preview panel title. -% -% Name-Value Arguments: -% layout - Axes arrangement: "single", "pair", or "stack". Default: -% "single". -% count - Positive integer number of axes. The default is 1 for single and 2 -% for pair or stack. axisIds, when supplied, determines the count. -% axisIds - Cell array of valid MATLAB field names used to address each axes -% from presenters and services. Defaults to axis1, axis2, and so on. -% axisTitles - Cell array of axes titles in axis order. A single axes defaults -% to titleText; multiple axes default to their axis IDs. -% xLabels - Cell array of x-axis labels in axis order. Default: blank. -% yLabels - Cell array of y-axis labels in axis order. Default: blank. -% columnWidths - uigridlayout ColumnWidth cell array with one entry per axes -% in pair layout. Default: equal flexible widths. -% rowHeights - uigridlayout RowHeight cell array with one entry per axes in -% stack layout. Default: equal flexible heights. -% scrollZoomAxes - Cell array selecting wheel-zoom dimensions for each axes: -% "xy", "x", or "y". Invalid or missing entries use "xy". -% viewModes - Nonempty list of user-facing modes. When supplied, a dropdown -% is shown above the axes. -% onModeChange - Function handle called as onModeChange(control,event) when -% the mode changes. event.mode and event.value contain the selected text. -% -% Outputs: -% layout - Scalar previewArea node with kind, id, props, children, and slots -% fields. -% -% Description: -% previewArea reserves one or more managed uiaxes in the workspace. Runtime -% renderers address the panel by preview ID and, when needed, an axis ID. Each -% axes receives the standard LabKit pop-out menu and wheel navigation. -% -% Errors: -% labkit:ui:layout:InvalidPreviewLayout or InvalidPreviewCount - layout or -% count is unsupported. -% labkit:ui:runtime:InvalidAxisId or DuplicateAxisId - axisIds contains an -% invalid or repeated identifier. -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. -% -% Example: -% preview = labkit.ui.layout.previewArea("signals", "Signals", ... -% "layout", "pair", "axisIds", {"input","output"}, ... -% "xLabels", {"Time (s)","Time (s)"}); -% assert(numel(preview.props.axisIds) == 2) -% -% See also labkit.ui.layout.workspace, labkit.ui.runtime.define - - props = optionStruct(varargin); - props.title = char(string(titleText)); - props.layout = char(string(optionValue(props, 'layout', 'single'))); - validateLayout(props); - layout = makeLayoutNode('previewArea', id, props, {}, struct()); -end - -function validateLayout(props) - allowed = {'single', 'pair', 'stack'}; - if ~any(strcmp(props.layout, allowed)) - error('labkit:ui:layout: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:layout:InvalidPreviewCount', ... - 'previewArea count must be a positive integer scalar.'); - end - if isfield(props, 'axisIds') && ~isempty(props.axisIds) - ids = string(props.axisIds); - ids = ids(:); - if any(strlength(ids) == 0) || ... - any(~arrayfun(@(value) isvarname(char(value)), ids)) - error('labkit:ui:runtime:InvalidAxisId', ... - 'previewArea axis ids must be valid MATLAB field names.'); - end - if numel(unique(ids, 'stable')) ~= numel(ids) - error('labkit:ui:runtime:DuplicateAxisId', ... - 'previewArea axis ids must be unique.'); - end - end -end - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isfield(opts, name) - value = opts.(name); - end -end diff --git a/+labkit/+ui/+layout/private/layoutChildren.m b/+labkit/+ui/+layout/private/layoutChildren.m deleted file mode 100644 index 6d11be6b8..000000000 --- a/+labkit/+ui/+layout/private/layoutChildren.m +++ /dev/null @@ -1,23 +0,0 @@ -% Private UI layout helper. Expected caller: labkit.ui.layout constructors. -% Normalizes heterogeneous child layout nodes to the required cell row vector shape -% and validates the common scalar layout node contract. -function children = layoutChildren(children) - if nargin < 1 || isempty(children) - children = {}; - return; - end - - if ~iscell(children) || ~isrow(children) - error('labkit:ui:layout:InvalidChildren', ... - 'UI layout children must be a cell row vector of scalar layout node 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:layout:InvalidChildren', ... - 'UI layout child %d must be a scalar layout node struct.', k); - end - end -end diff --git a/+labkit/+ui/+layout/private/makeLayoutNode.m b/+labkit/+ui/+layout/private/makeLayoutNode.m deleted file mode 100644 index 50ad37964..000000000 --- a/+labkit/+ui/+layout/private/makeLayoutNode.m +++ /dev/null @@ -1,58 +0,0 @@ -% Private UI layout helper. Expected caller: labkit.ui.layout constructors. -% Builds the canonical scalar UI layout struct. The struct is data only and -% never creates MATLAB graphics handles. -function layout = makeLayoutNode(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 = layoutChildren(children); - slots = normalizeSlots(slots); - - if ~isstruct(props) || ~isscalar(props) - error('labkit:ui:layout:InvalidProps', ... - 'UI layout props must be a scalar struct.'); - end - - layout = 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:layout:InvalidId', ... - 'UI layout id must be a text scalar.'); - end - - id = char(string(id)); - if ~isvarname(id) - error('labkit:ui:layout:InvalidId', ... - 'UI layout id "%s" must be a valid MATLAB field name.', id); - end -end - -function slots = normalizeSlots(slots) - if ~isstruct(slots) || ~isscalar(slots) - error('labkit:ui:layout:InvalidSlots', ... - 'UI layout 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}) = layoutChildren(value); - end - end -end diff --git a/+labkit/+ui/+layout/private/optionStruct.m b/+labkit/+ui/+layout/private/optionStruct.m deleted file mode 100644 index 71ddd55dc..000000000 --- a/+labkit/+ui/+layout/private/optionStruct.m +++ /dev/null @@ -1,29 +0,0 @@ -% Private UI layout helper. Expected caller: labkit.ui.layout 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:layout:InvalidOptions', ... - 'UI layout 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:layout:InvalidOptionName', ... - 'UI layout option names must be text scalars.'); - end - field = char(string(name)); - if ~isvarname(field) - error('labkit:ui:layout:InvalidOptionName', ... - 'UI layout option name "%s" is not a valid MATLAB field name.', field); - end - opts.(field) = args{k + 1}; - end -end diff --git a/+labkit/+ui/+layout/private/usagePanel.m b/+labkit/+ui/+layout/private/usagePanel.m deleted file mode 100644 index bf1b36ce3..000000000 --- a/+labkit/+ui/+layout/private/usagePanel.m +++ /dev/null @@ -1,21 +0,0 @@ -% Private UI layout helper. Creates the workbench-owned usage panel node. -function layout = usagePanel(id, titleText, varargin) -% -% App-facing contract: -% layout = labkit.ui.layout.usagePanel(id, title, "value", lines) -% -% Inputs: -% id - globally unique usage panel id. -% titleText - usage/help panel title. -% value - usage lines as text or cellstr, default ''. -% Prefer app-level usage on labkit.ui.layout.workbench so the framework places -% first-page workflow instructions consistently. -% Concrete usage-panel sizing is owned by the framework. -% -% Output: -% layout - scalar data-only UI layout struct. - - props = optionStruct(varargin); - props.title = char(string(titleText)); - layout = makeLayoutNode('usagePanel', id, props, {}, struct()); -end diff --git a/+labkit/+ui/+layout/rangeField.m b/+labkit/+ui/+layout/rangeField.m deleted file mode 100644 index 353336ff9..000000000 --- a/+labkit/+ui/+layout/rangeField.m +++ /dev/null @@ -1,48 +0,0 @@ -function layout = rangeField(id, labelText, varargin) -%RANGEFIELD Create a paired start/end or min/max field layout node. -% -% Usage: -% layout = labkit.ui.layout.rangeField(id, labelText) -% layout = labkit.ui.layout.rangeField(id, labelText, Name=Value) -% -% Inputs: -% id - Text scalar used to identify the range. It must be a valid MATLAB -% variable name and unique within the workbench. -% labelText - Text displayed beside the two numeric fields. -% -% Name-Value Arguments: -% value - Two-element numeric value [first second]. Default: [0 0]. -% limits - Two-element limits applied independently to both numeric fields. -% onChange - Function handle called as onChange(control,event). event.value -% contains the current two-element range. -% debounceMs - Delay before onChange runs, in milliseconds. Default: 500. -% Use 0 for immediate dispatch. -% Bind - Runtime V2 path to a two-element project or session value. -% Event - Action ID dispatched after a bound value is written. Requires Bind. -% -% Outputs: -% layout - Scalar rangeField node with kind, id, props, children, and slots -% fields. -% -% Description: -% rangeField renders two adjacent numeric edit fields as one semantic value. -% Both edits report the complete two-element value. Runtime construction -% rejects values that do not contain exactly two elements. -% -% Errors: -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. The constructor stores option values as -% data; invalid value/limits/callback combinations are rejected when the -% runtime builds the control. -% -% Example: -% window = labkit.ui.layout.rangeField( ... -% "timeWindow", "Time window (s)", "value", [0 5], "limits", [0 Inf]); -% assert(isequal(window.props.value, [0 5])) -% -% See also labkit.ui.layout.field, labkit.ui.layout.panner - - props = optionStruct(varargin); - props.label = char(string(labelText)); - layout = makeLayoutNode('rangeField', id, props, {}, struct()); -end diff --git a/+labkit/+ui/+layout/resultTable.m b/+labkit/+ui/+layout/resultTable.m deleted file mode 100644 index 0d1255d97..000000000 --- a/+labkit/+ui/+layout/resultTable.m +++ /dev/null @@ -1,55 +0,0 @@ -function layout = resultTable(id, titleText, varargin) -%RESULTTABLE Create a titled result table layout node. -% -% Usage: -% layout = labkit.ui.layout.resultTable(id, titleText) -% layout = labkit.ui.layout.resultTable(id, titleText, Name=Value) -% -% Inputs: -% id - Text scalar used to identify the table. It must be a valid MATLAB -% variable name and unique within the workbench. -% titleText - Text displayed in the table panel title. -% -% Name-Value Arguments: -% columns - Column names as a cell array. Default: {}. -% data - Initial table, numeric/logical array, or cell data. Default: an empty -% cell array with one column per entry in columns. -% columnEditable - Logical scalar or row vector passed to uitable. -% columnFormat - Cell array of MATLAB uitable column formats. -% rowName - Row labels. Default: {}, which hides MATLAB row numbers. -% onCellEdit - Function handle called as onCellEdit(control,event) after an -% edit. event.value is the complete table data; event.indices, -% previousData, newData, and editData describe the edited cell. -% onSelectionChange - Function handle called after a completed cell -% selection change. event.value is the complete data and event.indices -% identifies cells. Rapid intermediate selection changes are coalesced -% so the callback receives the latest completed range. A presenter may -% update the displayed Data, ColumnName, and RowName properties through -% the table control's presentation spec. -% -% Outputs: -% layout - Scalar resultTable node with kind, id, props, children, and slots -% fields. -% -% Description: -% resultTable creates a titled uitable that can be placed in a section or the -% workspace. String and other displayable cell values are converted to text -% when the table is built. Programmatic data updates do not call onCellEdit. -% -% Errors: -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. Table data, formats, editability, and -% callback values are validated by MATLAB when the runtime builds the table. -% -% Example: -% results = labkit.ui.layout.resultTable("summary", "Summary", ... -% "columns", {"Metric","Value"}, ... -% "data", {"Mean", 2.5}); -% assert(numel(results.props.columns) == 2) -% -% See also labkit.ui.layout.statusPanel, labkit.ui.layout.workspace - - props = optionStruct(varargin); - props.title = char(string(titleText)); - layout = makeLayoutNode('resultTable', id, props, {}, struct()); -end diff --git a/+labkit/+ui/+layout/section.m b/+labkit/+ui/+layout/section.m deleted file mode 100644 index 0ea0f3047..000000000 --- a/+labkit/+ui/+layout/section.m +++ /dev/null @@ -1,43 +0,0 @@ -function layout = section(id, titleText, children, varargin) -%SECTION Create a titled control-section layout node. -% -% Usage: -% layout = labkit.ui.layout.section(id, titleText, children) -% -% Inputs: -% id - Text scalar used to identify the section. It must be a valid MATLAB -% variable name and unique within the workbench. -% titleText - Text displayed in the section header. -% children - Cell row vector of field, rangeField, panner, action, group, -% filePanel, resultTable, statusPanel, or logPanel nodes. Default: {}. -% An empty section can be constructed but is rejected at launch. -% -% Outputs: -% layout - Scalar section node with kind, id, props, children, and slots -% fields. -% -% Description: -% A section is the ordered block of controls shown within a control tab. Apps -% choose the title, child controls, and their order; the workbench chooses the -% concrete height, spacing, padding, and border presentation. -% -% Errors: -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. -% labkit:ui:layout:InvalidChildren - children is not a cell row of scalar -% layout nodes. Empty or unsupported child sets are rejected at launch. -% -% Example: -% inputs = labkit.ui.layout.section("inputs", "Inputs", { ... -% labkit.ui.layout.field("sampleName", "Sample name")}); -% assert(inputs.kind == "section") -% -% See also labkit.ui.layout.tab, labkit.ui.layout.group - - if nargin < 3 - children = {}; - end - props = optionStruct(varargin); - props.title = char(string(titleText)); - layout = makeLayoutNode('section', id, props, children, struct()); -end diff --git a/+labkit/+ui/+layout/statusPanel.m b/+labkit/+ui/+layout/statusPanel.m deleted file mode 100644 index c2eeed689..000000000 --- a/+labkit/+ui/+layout/statusPanel.m +++ /dev/null @@ -1,40 +0,0 @@ -function layout = statusPanel(id, titleText, varargin) -%STATUSPANEL Create a read-only status/details panel layout node. -% -% Usage: -% layout = labkit.ui.layout.statusPanel(id, titleText) -% layout = labkit.ui.layout.statusPanel(id, titleText, "value", lines) -% -% Inputs: -% id - Text scalar used to identify the panel. It must be a valid MATLAB -% variable name and unique within the workbench. -% titleText - Text displayed in the panel title. -% -% Name-Value Arguments: -% value - Initial details as text, a string array, or cellstr. Default: "". -% -% Outputs: -% layout - Scalar statusPanel node with kind, id, props, children, and slots -% fields. -% -% Description: -% statusPanel shows read-only summaries or details that the presenter may -% replace as app state changes. Unlike logPanel, it has no follow-latest -% controls. Use the workbench usage option for static workflow instructions. -% -% Errors: -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. Text conversion and graphics compatibility -% of value are validated when the runtime builds the panel. -% -% Example: -% status = labkit.ui.layout.statusPanel( ... -% "selectionStatus", "Selection", "value", "No file selected"); -% assert(status.kind == "statusPanel") -% -% See also labkit.ui.layout.logPanel, labkit.ui.layout.resultTable - - props = optionStruct(varargin); - props.title = char(string(titleText)); - layout = makeLayoutNode('statusPanel', id, props, {}, struct()); -end diff --git a/+labkit/+ui/+layout/tab.m b/+labkit/+ui/+layout/tab.m deleted file mode 100644 index 8112001a1..000000000 --- a/+labkit/+ui/+layout/tab.m +++ /dev/null @@ -1,46 +0,0 @@ -function layout = tab(id, titleText, children, varargin) -%TAB Create a LabKit control or workspace tab layout node. -% -% Usage: -% layout = labkit.ui.layout.tab(id, titleText, children) -% -% Inputs: -% id - Text scalar used to identify the tab. It must be a valid MATLAB -% variable name and unique within the workbench. -% titleText - Text displayed on the tab. -% children - Cell row vector in display order. A control tab contains section -% nodes. A tab placed directly in workspace contains previewArea, -% resultTable, statusPanel, or logPanel nodes. Default: {}. -% -% Outputs: -% layout - Scalar tab node with kind, id, props, children, and slots fields. -% -% Description: -% tab defines one selectable page. Add it to the controlTabs cell array for a -% left-side control page, or place two or more tab nodes directly in a -% workspace for user-selectable right-side pages. Runtime owns the native tab -% group, selection behavior, and page geometry. -% -% Errors: -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. -% labkit:ui:layout:InvalidChildren - children is not a cell row of scalar -% layout nodes. Child kinds and empty-section rules are checked at launch. -% -% Example: -% setupTab = labkit.ui.layout.tab("setup", "Setup", { ... -% labkit.ui.layout.section("inputs", "Inputs", { ... -% labkit.ui.layout.field("name", "Name")})}); -% assert(setupTab.kind == "tab") -% -% See also labkit.ui.layout.section, -% labkit.ui.layout.workspace, -% labkit.ui.layout.workbench - - if nargin < 3 - children = {}; - end - props = optionStruct(varargin); - props.title = char(string(titleText)); - layout = makeLayoutNode('tab', id, props, children, struct()); -end diff --git a/+labkit/+ui/+layout/workbench.m b/+labkit/+ui/+layout/workbench.m deleted file mode 100644 index 5e7172557..000000000 --- a/+labkit/+ui/+layout/workbench.m +++ /dev/null @@ -1,90 +0,0 @@ -function layout = workbench(id, titleText, varargin) -%WORKBENCH Create a declarative LabKit workbench layout. -% -% Usage: -% layout = labkit.ui.layout.workbench(id, titleText, "controlTabs", tabs, ... -% "workspace", workspace) -% layout = labkit.ui.layout.workbench(..., Name=Value) -% -% Inputs: -% id - Text scalar used to identify the app layout. It must be a valid MATLAB -% variable name and unique within the layout tree. -% titleText - Text used for the app figure title. -% -% Required Name-Value Arguments: -% controlTabs - Nonempty cell row vector of tab nodes for the left control -% pane. -% workspace - One workspace node for the right content pane. -% -% Optional Name-Value Arguments: -% usage - Static workflow instructions as text, a string array, or cellstr. -% When nonempty, a read-only Usage section is appended to the first tab. -% Default: no usage section. -% usageTitle - Character vector or scalar string used as the title of the -% generated usage section. Default: "Usage". -% -% Outputs: -% layout - Scalar app node consumed by labkit.ui.runtime.create or by the -% Layout callback of labkit.ui.runtime.define. -% -% Description: -% workbench is the root of a declarative LabKit UI tree. Every node ID in the -% tree must be unique. Launch validation rejects missing tabs or workspace, -% invalid child types, empty sections, and app-owned pixel geometry such as -% position, height, padding, or pane width. The function constructs data only; -% it does not open a figure. -% -% Errors: -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. Missing required slots, duplicate IDs, -% invalid child kinds, empty sections, and forbidden concrete geometry are -% rejected when runtime.define or runtime.create validates the full tree. -% -% Example: -% controls = labkit.ui.layout.tab("main", "Main", { ... -% labkit.ui.layout.section("commands", "Commands", { ... -% labkit.ui.layout.action("run", "Run", [])})}); -% content = labkit.ui.layout.workspace("workspace", "Preview", {}); -% appLayout = labkit.ui.layout.workbench("exampleApp", "Example App", ... -% "controlTabs", {controls}, "workspace", content); -% assert(appLayout.kind == "app") -% -% See also labkit.ui.runtime.define, labkit.ui.runtime.create, -% labkit.ui.layout.tab, labkit.ui.layout.workspace - - props = optionStruct(varargin); - props.title = char(string(titleText)); - props = addUsagePanelToFirstTab(id, props); - layout = makeLayoutNode('app', id, props, {}, struct()); -end - -function props = addUsagePanelToFirstTab(appId, props) - if ~isfield(props, 'usage') || isempty(props.usage) || ... - ~isfield(props, 'controlTabs') || isempty(props.controlTabs) - return; - end - - tabs = props.controlTabs; - firstTab = tabs{1}; - usageId = [char(string(appId)) 'Usage']; - sectionId = [usageId 'Section']; - titleText = optionValue(props, 'usageTitle', 'Usage'); - panel = usagePanel(usageId, titleText, ... - 'value', props.usage); - firstTab.children{end+1} = labkit.ui.layout.section( ... - sectionId, titleText, {panel}); - tabs{1} = firstTab; - props.controlTabs = tabs; - removeNames = intersect(fieldnames(props), ... - {'usage', 'usageTitle'}); - if ~isempty(removeNames) - props = rmfield(props, removeNames); - 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/+layout/workspace.m b/+labkit/+ui/+layout/workspace.m deleted file mode 100644 index 3a15b59a8..000000000 --- a/+labkit/+ui/+layout/workspace.m +++ /dev/null @@ -1,46 +0,0 @@ -function layout = workspace(id, titleText, children, varargin) -%WORKSPACE Create a right-side LabKit workbench workspace layout node. -% -% Usage: -% layout = labkit.ui.layout.workspace(id, titleText, children) -% -% Inputs: -% id - Text scalar used to identify the workspace. It must be a valid MATLAB -% variable name and unique within the workbench. -% titleText - Text displayed above the right-side workspace. -% children - Cell row vector of previewArea, resultTable, statusPanel, or -% logPanel nodes in display order. To expose multiple user-selectable -% workspace pages, supply two or more tab nodes instead. Default: {}. -% -% Outputs: -% layout - Scalar workspace node with kind, id, props, children, and slots -% fields. -% -% Description: -% workspace defines the app's right-hand content area. Direct panel children -% form one vertical workspace. Two or more tab children form user-selectable -% workspace pages. The workbench assigns rows, page selection, and sizing; -% apps choose only semantic content and order. -% -% Errors: -% labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or -% Name-value syntax is malformed. -% labkit:ui:layout:InvalidChildren - children is not a cell row of scalar -% layout nodes. Unsupported workspace child kinds are rejected at launch. -% -% Example: -% preview = labkit.ui.layout.workspace("workspace", "Preview", { ... -% labkit.ui.layout.previewArea("image", "Image")}); -% assert(preview.kind == "workspace") -% -% See also labkit.ui.layout.tab, -% labkit.ui.layout.previewArea, -% labkit.ui.layout.workbench - - if nargin < 3 - children = {}; - end - props = optionStruct(varargin); - props.title = char(string(titleText)); - layout = makeLayoutNode('workspace', id, props, children, struct()); -end diff --git a/+labkit/+ui/+plot/clampData.m b/+labkit/+ui/+plot/clampData.m deleted file mode 100644 index 90ba63c73..000000000 --- a/+labkit/+ui/+plot/clampData.m +++ /dev/null @@ -1,47 +0,0 @@ -function xy = clampData(ax, xy, varargin) -%CLAMPDATA Keep data coordinates inside the visible axes box. -% -% Usage: -% xyOut = labkit.ui.plot.clampData(ax, xy) -% xyOut = labkit.ui.plot.clampData(ax, xy, Name=Value) -% -% Inputs: -% ax - Valid scalar MATLAB axes or uiaxes handle. Its XLim, YLim, XScale, -% YScale, XDir, and YDir properties define the visible box. -% xy - N-by-2 numeric matrix of [x y] data coordinates. -% -% Name-Value Arguments: -% Padding - Minimum distance from each axes edge, expressed as a fraction of -% the visible width or height. Values are limited to [0, 0.49]. Default: -% 0.04. -% -% Outputs: -% xy - N-by-2 data coordinates, shown as xyOut in the usage syntax. Each -% point is moved only as far as needed to satisfy Padding. -% -% Description: -% clampData is useful for labels and annotations that must remain readable -% near an axes boundary. Conversion through normalized axes coordinates keeps -% the result visually consistent on logarithmic or reversed axes. -% -% Errors: -% labkit:ui:plot:InvalidAxes - ax is not a valid scalar axes handle. -% labkit:ui:plot:InvalidPointPairs - xy is not an N-by-2 numeric array. -% labkit:ui:plot:InvalidOptions or labkit:ui:plot:InvalidOption - -% Name-value arguments are malformed or unsupported. -% -% Example: -% fig = figure("Visible", "off"); -% cleanup = onCleanup(@() close(fig)); -% ax = axes(fig, "XLim", [0 10], "YLim", [0 20]); -% xy = labkit.ui.plot.clampData(ax, [-2 25], "Padding", 0.1); -% assert(isequal(xy, [1 18])) -% -% See also labkit.ui.plot.offsetData - - opts = parseAxesOptions(varargin, struct('Padding', 0.04)); - pad = max(0, min(0.49, double(opts.Padding))); - uv = dataToFraction(ax, xy); - uv = min(max(uv, pad), 1 - pad); - xy = fractionToData(ax, uv); -end diff --git a/+labkit/+ui/+plot/clear.m b/+labkit/+ui/+plot/clear.m deleted file mode 100644 index 76b25eab5..000000000 --- a/+labkit/+ui/+plot/clear.m +++ /dev/null @@ -1,69 +0,0 @@ -function clear(ax, varargin) -%CLEAR Prepare an axes for an app-owned redraw. -% -% Usage: -% labkit.ui.plot.clear(ax) -% labkit.ui.plot.clear(ax, Name=Value) -% -% Inputs: -% ax - Valid scalar MATLAB axes or uiaxes handle to clear. -% -% Name-Value Arguments: -% ResetScale - Logical value. true restores linear X/Y scales and automatic -% X/Y tick modes. false preserves those four properties. Default: false. -% ClearLegend - Logical value. true turns the legend off. Default: true. -% -% Outputs: -% None. -% -% Description: -% clear deletes plotted children, clears LabKit's cached image home view, -% releases hold, and returns XLim, YLim, ZLim, and CLim to automatic mode. -% Labels and other axes decorations follow MATLAB cla behavior. Call it once -% at the start of a complete redraw, not when adding an overlay that should -% preserve the current zoom. -% -% Errors: -% labkit:ui:plot:InvalidAxes - ax is not a valid scalar axes handle. -% labkit:ui:plot:InvalidOptions or labkit:ui:plot:InvalidOption - -% Name-value arguments are malformed or unsupported. MATLAB graphics errors -% raised while clearing a valid axes propagate to the caller. -% -% Example: -% fig = figure("Visible", "off"); -% cleanup = onCleanup(@() close(fig)); -% ax = axes(fig); -% plot(ax, 1:3, [2 1 3]); -% labkit.ui.plot.clear(ax, "ResetScale", true); -% assert(isempty(ax.Children)) -% -% See also labkit.ui.plot.fit, labkit.ui.plot.message - - opts = parseAxesOptions(varargin, struct( ... - 'ResetScale', false, ... - 'ClearLegend', true)); - validateAxesHandle(ax, 'clearPlot'); - - children = allchild(ax); - for k = 1:numel(children) - if isgraphics(children(k)) && isvalid(children(k)) - delete(children(k)); - end - end - cla(ax); - clearAxesViewState(ax); - if logical(opts.ClearLegend) - legend(ax, 'off'); - end - hold(ax, 'off'); - ax.XLimMode = 'auto'; - ax.YLimMode = 'auto'; - ax.ZLimMode = 'auto'; - ax.CLimMode = 'auto'; - if logical(opts.ResetScale) - ax.XScale = 'linear'; - ax.YScale = 'linear'; - ax.XTickMode = 'auto'; - ax.YTickMode = 'auto'; - end -end diff --git a/+labkit/+ui/+plot/fit.m b/+labkit/+ui/+plot/fit.m deleted file mode 100644 index f1f31004a..000000000 --- a/+labkit/+ui/+plot/fit.m +++ /dev/null @@ -1,70 +0,0 @@ -function limits = fit(ax, varargin) -%FIT Fit axes limits to finite plotted X/Y data. -% -% Usage: -% limits = labkit.ui.plot.fit(ax) -% limits = labkit.ui.plot.fit(ax, graphicsHandles) -% limits = labkit.ui.plot.fit(..., Name=Value) -% -% Inputs: -% ax - Valid scalar MATLAB axes or uiaxes handle whose limits are changed. -% graphicsHandles - Optional graphics handle array. Objects with numeric -% XData and YData contribute to the fitted range. When omitted, all -% current axes children are examined. -% -% Name-Value Arguments: -% Padding - Nonnegative fractional padding added on each side of the data -% range. Default: 0.02. For logarithmic axes, padding is computed in -% base-10 logarithmic space. -% -% Outputs: -% limits - Scalar struct with x and y fields. Each field contains the applied -% two-element limit, or [] when that dimension had no usable data and was -% returned to automatic limit mode. -% -% Description: -% fit ignores nonfinite XData and YData. Nonpositive values do not contribute -% to a logarithmic dimension. Supplying graphicsHandles lets an app exclude -% annotations such as reference lines from the fitted range. -% -% Errors: -% labkit:ui:plot:InvalidAxes - ax is not a valid scalar axes handle. -% labkit:ui:plot:InvalidOptions or labkit:ui:plot:InvalidOption - -% Name-value arguments are malformed or unsupported. Invalid supplied -% graphics handles are ignored when they do not expose numeric XData/YData. -% -% Example: -% fig = figure("Visible", "off"); -% cleanup = onCleanup(@() close(fig)); -% ax = axes(fig); -% h = plot(ax, [1 2 3], [10 20 15]); -% xline(ax, 100); -% limits = labkit.ui.plot.fit(ax, h, "Padding", 0); -% assert(isequal(limits.x, [1 3])) -% -% See also labkit.ui.plot.clear - - validateAxesHandle(ax, 'fit'); - [handles, opts] = parseFitInputs(ax, varargin); - [xLim, yLim] = finitePlotLimits(ax, handles, opts.Padding); - if isempty(xLim) - xlim(ax, 'auto'); - else - xlim(ax, xLim); - end - if isempty(yLim) - ylim(ax, 'auto'); - else - ylim(ax, yLim); - end - limits = struct('x', xLim, 'y', yLim); -end - -function [handles, opts] = parseFitInputs(ax, args) - handles = allchild(ax); - if ~isempty(args) && ~(ischar(args{1}) || isstring(args{1})) - handles = args{1}; - args = args(2:end); - end - opts = parseAxesOptions(args, struct('Padding', 0.02)); -end diff --git a/+labkit/+ui/+plot/fitCanvas.m b/+labkit/+ui/+plot/fitCanvas.m deleted file mode 100644 index f6896512e..000000000 --- a/+labkit/+ui/+plot/fitCanvas.m +++ /dev/null @@ -1,121 +0,0 @@ -function [applied, frame] = fitCanvas(ax, width, height, varargin) -%FITCANVAS Fit a preview axes into a fixed-aspect pixel frame. -% -% Usage: -% [applied, frame] = labkit.ui.plot.fitCanvas(ax, width, height) -% [applied, frame] = labkit.ui.plot.fitCanvas(..., Name=Value) -% -% Inputs: -% ax - UI axes whose parent is a uigridlayout created for a LabKit preview. -% width - Positive finite source-canvas width in pixels. -% height - Positive finite source-canvas height in pixels. -% -% Name-Value Arguments: -% margin - Preferred empty margin around the frame in pixels. Default: 24. -% maxScale - Largest allowed ratio between displayed and source dimensions. -% Default: 1, so the helper does not enlarge the source canvas. -% -% Outputs: -% applied - true when the grid and axes positions were updated; false when -% the axes, parent grid, dimensions, or available space were unsuitable. -% frame - Scalar struct with width, height, ratio, position, scale, and -% pixelPosition fields. It is an empty struct when applied is false. -% -% Description: -% fitCanvas centers the axes in the middle row and column of a three-by-three -% flexible grid and preserves the requested aspect ratio. It returns false -% instead of throwing when the host has not been laid out yet or is smaller -% than the minimum usable preview area. -% -% Failure Behavior: -% Invalid axes, host grids, source dimensions, or insufficient available -% space return applied=false and frame=struct(). Malformed or unsupported -% name-value arguments throw labkit:ui:plot:InvalidOptions or -% labkit:ui:plot:InvalidOption. -% -% Typical Call: -% [ok, frame] = labkit.ui.plot.fitCanvas(ax, 720, 540); -% -% See also labkit.ui.layout.previewArea - - opts = parseOptions(varargin); - frame = struct(); - applied = false; - if isempty(ax) || ~isvalid(ax) - return; - end - parent = ax.Parent; - if isempty(parent) || ~isvalid(parent) || ... - ~contains(class(parent), 'GridLayout') - return; - end - - canvasWidth = finiteScalar(width, NaN); - canvasHeight = finiteScalar(height, NaN); - if ~isfinite(canvasWidth) || ~isfinite(canvasHeight) || ... - canvasWidth <= 0 || canvasHeight <= 0 - return; - end - - try - drawnow; - parentPixels = getpixelposition(parent, true); - margin = finiteScalar(opts.margin, 24); - maxScale = finiteScalar(opts.maxScale, 1); - availableWidth = max(1, parentPixels(3) - 2 * margin); - availableHeight = max(1, parentPixels(4) - 2 * margin); - if availableWidth < 240 || availableHeight < 180 - return; - end - - scale = min(maxScale, min(availableWidth / canvasWidth, ... - availableHeight / canvasHeight)); - frameWidth = max(1, round(canvasWidth * scale)); - frameHeight = max(1, round(canvasHeight * scale)); - parent.RowHeight = {'1x', frameHeight, '1x'}; - parent.ColumnWidth = {'1x', frameWidth, '1x'}; - ax.Layout.Row = 2; - ax.Layout.Column = 2; - frame = struct( ... - 'width', canvasWidth, ... - 'height', canvasHeight, ... - 'ratio', canvasWidth / canvasHeight, ... - 'position', [NaN NaN frameWidth frameHeight], ... - 'scale', scale, ... - 'pixelPosition', [NaN NaN frameWidth frameHeight]); - applied = true; - catch - frame = struct(); - applied = false; - end -end - -function opts = parseOptions(args) - opts = struct('margin', 24, 'maxScale', 1); - if isempty(args) - return; - end - if mod(numel(args), 2) ~= 0 - error('labkit:ui:plot:InvalidOptions', ... - 'fitCanvas options must be name/value pairs.'); - end - for k = 1:2:numel(args) - name = char(string(args{k})); - if ~isfield(opts, name) - error('labkit:ui:plot:InvalidOption', ... - 'Unsupported fitCanvas option "%s".', name); - end - opts.(name) = args{k + 1}; - end -end - -function value = finiteScalar(candidate, fallback) - value = fallback; - if isempty(candidate) - return; - end - candidate = double(candidate); - if isscalar(candidate) && isfinite(candidate) - value = candidate; - end -end diff --git a/+labkit/+ui/+plot/message.m b/+labkit/+ui/+plot/message.m deleted file mode 100644 index abd1916af..000000000 --- a/+labkit/+ui/+plot/message.m +++ /dev/null @@ -1,58 +0,0 @@ -function hText = message(ax, message, varargin) -%MESSAGE Show a centered empty-state message in an axes. -% -% Usage: -% hText = labkit.ui.plot.message(ax, message) -% hText = labkit.ui.plot.message(ax, message, Name=Value) -% -% Inputs: -% ax - Valid scalar MATLAB axes or uiaxes handle. -% message - User-visible text scalar displayed at the axes center. -% -% Name-Value Arguments: -% Title - Axes title text. Default: "". -% Color - MATLAB color value for the message text. Default: -% [0.30 0.30 0.30]. -% -% Outputs: -% hText - MATLAB text object containing the message. -% -% Description: -% message clears the axes, resets it to linear unit limits [0 1], removes -% ticks, and draws non-pickable text. Use it for an empty preview, loading -% prompt, or unavailable result. Because it performs a complete clear, it is -% not an overlay operation and does not preserve the previous zoom. -% -% Errors: -% labkit:ui:plot:InvalidAxes - ax is not a valid scalar axes handle. -% labkit:ui:plot:InvalidOptions or labkit:ui:plot:InvalidOption - -% Name-value arguments are malformed or unsupported. Invalid MATLAB text or -% color values propagate their originating graphics error. -% -% Example: -% fig = figure("Visible", "off"); -% cleanup = onCleanup(@() close(fig)); -% ax = axes(fig); -% h = labkit.ui.plot.message(ax, "No data", "Title", "Preview"); -% assert(string(h.String) == "No data") -% -% See also labkit.ui.plot.clear - - opts = parseAxesOptions(varargin, struct( ... - 'Title', "", ... - 'Color', [0.30 0.30 0.30])); - labkit.ui.plot.clear(ax, 'ResetScale', true); - xlim(ax, [0 1]); - ylim(ax, [0 1]); - ax.XTick = []; - ax.YTick = []; - title(ax, char(string(opts.Title)), 'Interpreter', 'none'); - hText = text(ax, 0.5, 0.5, string(message), ... - 'Units', 'normalized', ... - 'HorizontalAlignment', 'center', ... - 'VerticalAlignment', 'middle', ... - 'Color', opts.Color, ... - 'Interpreter', 'none', ... - 'HitTest', 'off', ... - 'PickableParts', 'none'); -end diff --git a/+labkit/+ui/+plot/offsetData.m b/+labkit/+ui/+plot/offsetData.m deleted file mode 100644 index b74b566e4..000000000 --- a/+labkit/+ui/+plot/offsetData.m +++ /dev/null @@ -1,43 +0,0 @@ -function xy = offsetData(ax, xy, offsetFraction) -%OFFSETDATA Move data coordinates by normalized axes fractions. -% -% Usage: -% xyOut = labkit.ui.plot.offsetData(ax, xy, offsetFraction) -% -% Inputs: -% ax - Valid scalar MATLAB axes or uiaxes handle. Its current limits, scales, -% and directions define the coordinate conversion. -% xy - N-by-2 numeric matrix of [x y] data coordinates. -% offsetFraction - 1-by-2 or N-by-2 axes-fraction offsets. One row is reused -% for every point; otherwise the row count must match xy. For example, -% [0.03 -0.04] moves a label slightly right and down in visual axes -% space, including on log or reversed axes. -% -% Outputs: -% xy - N-by-2 offset coordinates in data units, shown as xyOut in the usage -% syntax. Values are not clamped to the visible axes box. -% -% Description: -% offsetData expresses annotation spacing in visual axes fractions instead of -% data units. This keeps the apparent offset stable as data ranges change and -% correctly handles logarithmic and reversed axes. -% -% Errors: -% labkit:ui:plot:InvalidAxes - ax is not a valid scalar axes handle. -% labkit:ui:plot:InvalidPointPairs - xy is not an N-by-2 numeric array. -% labkit:ui:plot:InvalidPointOffsets - offsetFraction is neither one row nor -% one row per point. -% -% Example: -% fig = figure("Visible", "off"); -% cleanup = onCleanup(@() close(fig)); -% ax = axes(fig, "XLim", [0 10], "YLim", [0 20]); -% xy = labkit.ui.plot.offsetData(ax, [5 10], [0.1 -0.1]); -% assert(isequal(xy, [6 8])) -% -% See also labkit.ui.plot.clampData - - uv = dataToFraction(ax, xy); - offsetFraction = normalizePointOffsets(offsetFraction, size(uv, 1)); - xy = fractionToData(ax, uv + offsetFraction); -end diff --git a/+labkit/+ui/+runtime/create.m b/+labkit/+ui/+runtime/create.m deleted file mode 100644 index e8de8fab7..000000000 --- a/+labkit/+ui/+runtime/create.m +++ /dev/null @@ -1,59 +0,0 @@ -function ui = create(layout, varargin) -%CREATE Build a LabKit workbench from a declarative layout. -% -% Usage: -% ui = labkit.ui.runtime.create(layout) -% ui = labkit.ui.runtime.create(layout, "debug", debugContext) -% -% Inputs: -% layout - Scalar layout tree returned by labkit.ui.layout.workbench. Control -% IDs must be unique throughout the tree. -% -% Name-Value Arguments: -% debug - labkit.ui.debug context used to instrument the new figure and show -% trace messages in the first log panel. The default is no debug context. -% -% Outputs: -% ui - Struct containing the figure, shell panels, controls, tabs, workspace, -% source layout, and debug context. Controls and sections are indexed by -% their layout IDs. -% -% Description: -% create builds the complete workbench immediately and returns its handle -% registry. Use this lower-level function when code already has a finished -% layout tree. Most apps should use labkit.ui.runtime.launch, which also owns -% project state, actions, presentation, startup, and persistence. -% -% Errors: -% labkit:ui:runtime:InvalidOptions - Name-value arguments are not paired. -% Layout-tree validation errors identify invalid IDs, duplicate IDs, missing -% workbench slots, unsupported child kinds, empty sections, or forbidden -% concrete geometry. MATLAB graphics construction errors propagate after -% validation; a partially created figure is cleaned up by the runtime. -% -% See also labkit.ui.runtime.launch, labkit.ui.layout.workbench - - opts = parseOptions(varargin); - debug = optionValue(opts, 'debug', []); - ui = buildRuntimeWorkbench(layout, debug, ... - startupProgressReporter(struct())); - startupLifecycle(ui.figure, 'finish', "Ready."); -end - -function opts = parseOptions(args) - if mod(numel(args), 2) ~= 0 - error('labkit:ui:runtime:InvalidOptions', ... - 'labkit.ui.runtime.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 diff --git a/+labkit/+ui/+runtime/defaultOutputFolder.m b/+labkit/+ui/+runtime/defaultOutputFolder.m deleted file mode 100644 index b0c06a848..000000000 --- a/+labkit/+ui/+runtime/defaultOutputFolder.m +++ /dev/null @@ -1,112 +0,0 @@ -function folder = defaultOutputFolder(sourcePaths, subfolderName, fallbackFolder) -%DEFAULTOUTPUTFOLDER Return a source-adjacent app output folder. -% -% Usage: -% folder = labkit.ui.runtime.defaultOutputFolder(sourcePaths, subfolderName) -% folder = labkit.ui.runtime.defaultOutputFolder(sourcePaths, subfolderName, fallbackFolder) -% -% Inputs: -% sourcePaths - File path, folder path, string array, or cell array of paths. -% The first nonempty path selects the base folder. The default is empty. -% subfolderName - Name of the output folder to create. Characters that are -% illegal in common file systems are replaced with underscores. The -% default is "labkit_output". -% fallbackFolder - Existing folder to use when sourcePaths does not identify -% an existing source location. The default is LabKit's remembered output -% location or the current user's home folder. -% -% Outputs: -% folder - Character vector naming an existing output folder. -% -% Description: -% When the first path is a file, the new subfolder is created beside that -% file. When it is a folder, the subfolder is created inside it. If the source -% cannot be resolved or the new folder cannot be created, the function -% returns a safe existing fallback folder instead of failing because the -% requested output folder could not be created. -% -% Failure Behavior: -% Missing source locations, unsafe names, and mkdir failures fall back to an -% existing remembered output folder or user home directory. Input values -% must be convertible to text; incompatible MATLAB containers may raise the -% originating string conversion error before fallback selection. -% -% Typical Call: -% outputFolder = labkit.ui.runtime.defaultOutputFolder( ... -% importedFiles, "Processed Results", pwd); -% -% See also labkit.ui.runtime.sourcePaths, -% labkit.ui.runtime.saveState - - if nargin < 1 - sourcePaths = strings(0, 1); - end - if nargin < 2 || strlength(strtrim(string(subfolderName))) == 0 - subfolderName = "labkit_output"; - end - if nargin < 3 - fallbackFolder = ""; - end - - baseFolder = sourceBaseFolder(sourcePaths); - if strlength(baseFolder) == 0 - baseFolder = string(defaultDialogFolder("output", fallbackFolder)); - end - - target = string(fullfile(char(baseFolder), char(safeSubfolderName(subfolderName)))); - if ensureFolder(target) - folder = char(target); - return; - end - - folder = defaultDialogFolder("output", fallbackFolder); -end - -function folder = sourceBaseFolder(sourcePaths) - folder = ""; - if isempty(sourcePaths) - return; - end - - paths = string(sourcePaths); - paths = paths(strlength(strtrim(paths)) > 0); - if isempty(paths) - return; - end - - firstPath = strtrim(paths(1)); - if exist(char(firstPath), 'dir') == 7 - folder = firstPath; - return; - end - - [parentFolder, ~, ~] = fileparts(char(firstPath)); - if ~isempty(parentFolder) && exist(parentFolder, 'dir') == 7 - folder = string(parentFolder); - end -end - -function name = safeSubfolderName(value) - name = strtrim(string(value)); - if isempty(name) || strlength(name(1)) == 0 - name = "labkit_output"; - else - name = name(1); - end - invalid = ["<", ">", ":", """", "/", "\", "|", "?", "*"]; - for k = 1:numel(invalid) - name = replace(name, invalid(k), "_"); - end -end - -function ok = ensureFolder(folder) - ok = exist(char(folder), 'dir') == 7; - if ok - return; - end - try - [ok, ~, ~] = mkdir(char(folder)); - catch - ok = false; - end -end diff --git a/+labkit/+ui/+runtime/define.m b/+labkit/+ui/+runtime/define.m deleted file mode 100644 index a4de8e2a9..000000000 --- a/+labkit/+ui/+runtime/define.m +++ /dev/null @@ -1,253 +0,0 @@ -function def = define(varargin) -%DEFINE Create a LabKit declarative app runtime definition. -% -% Usage: -% def = labkit.ui.runtime.define("Command", command, "Id", id, ... -% "Title", title, "Family", family, "AppVersion", version, ... -% "Updated", date, "Requirements", requirements, ... -% "Layout", layoutFcn) -% def = labkit.ui.runtime.define(..., Name=Value) -% -% Outputs: -% def - Validated scalar Runtime V2 definition accepted by -% labkit.ui.runtime.launch. -% -% Description: -% define describes an app without creating a figure. The definition connects -% durable project data, temporary session data, a declarative layout, event -% actions, and view presentation. Invalid definitions fail here, before the -% app begins startup. -% -% Required Name-Value Arguments: -% Command - Public MATLAB function used to launch the App, for example -% "labkit_Example_app". Single-definition launch uses this value for -% request errors and version metadata. -% Id - Stable scalar text identifier for saved projects, recovery storage, -% results, and diagnostics. It starts with an ASCII letter and contains -% only letters, digits, underscore, hyphen, or period. Treat it as a -% permanent compatibility identifier after projects have been saved. -% Title - Text shown in the app window title. -% Family - Nonempty character vector or scalar string naming the -% reader-facing App family used by the launcher and documentation. -% AppVersion - Semantic App version in X.Y.Z form. This versions the App -% product, not the Runtime V2 project payload. -% Updated - Last product-change date in YYYY-MM-DD form. -% Requirements - labkit.contract.requirements result declaring compatible -% reusable LabKit facades. -% Layout - Function handle returning a labkit.ui.layout.workbench tree. It -% may accept no inputs, callbacks, or callbacks and initial state: -% layoutFcn(), layoutFcn(callbacks), or layoutFcn(callbacks,state). -% -% Optional Name-Value Arguments: -% DisplayName - Short product name shown by the launcher. Default: Title. -% Project - Scalar struct that owns a durable project schema. Omit it for an -% empty version-1 project with no App validator or migrations. Supplied -% project fields are listed under Project Fields. -% CreateSession - Function handle returning transient session state. It may -% accept no inputs or the newly created project. Missing selection, -% workflow, view, and cache fields are added automatically. Default: an -% empty session struct. -% Actions - Scalar struct whose field names are event IDs and whose values -% are functions of the form state = action(state,event,services). -% Default: struct(), which is valid for a static App. -% Present - Function handle of the form view = present(state). The returned -% presenter model supplies control values, lists, tables, text, plots, -% and interaction models for one committed view. Default: an empty -% presenter model, which preserves values declared by a static layout. -% Renderers - Scalar struct of renderer functions keyed by renderer ID. A -% renderer may accept no inputs, the presented model, or the target axes -% and model. Default: struct(). -% Start - Action ID or action function queued after the first view appears. -% A function uses the normal action signature. Default: no startup action. -% DebugSample - Function called as pack = writer(debugContext) after a debug -% launch. It is not called during normal launch. Default: none. -% Utilities - Scalar struct controlling framework menus. Fields are Visible, -% Plot, Screenshot, and State. Visible defaults to true. Plot defaults to -% true when the layout contains a preview area and false otherwise. -% Screenshot defaults to true. State accepts "on" or "off" and defaults -% to "on". -% -% Project Fields: -% Version - Positive integer payload version. Version 1 has no migrations. -% Create - Function handle project = createProject(). Missing inputs, -% parameters, annotations, results, and extensions fields are added. -% Validate - Function that checks a complete project. It may throw on invalid -% data or return a logical scalar. -% Migrate - Function project = migrate(project,fromVersion). The runtime -% calls it once for each missing version and validates every returned -% payload. Required when Version is greater than 1. Default: none. -% LegacyImports - Scalar struct mapping MAT-file variable names to import -% functions. An importer is called as project = import(value) or -% [project,resume] = import(value), where value is the named MAT-file -% variable. Imported formats are read-only; new saves use labkitProject. -% CreateResume - Function resume = createResume(session,project) for optional -% lightweight view/workflow state saved with the project. -% ApplyResume - Function session = applyResume(session,resume,project) used -% after a fresh session is created during load. -% RelinkSources - Function project = relink(project,unresolved,projectFile) -% used when required external files cannot be found. Returning [] cancels -% the load. -% -% Action State and Event Fields: -% state.project - Durable project data. Changes mark the document dirty and -% are included in the next project save. -% state.session - Transient selection, workflow, view, and cache data. It is -% recreated when a project opens unless resume callbacks preserve a small -% part of it. -% event.id - Action ID that selected the handler. -% event.source - Event origin, such as "user", "service", "startup", or -% "interaction". -% event.target - ID of the control, interaction, or runtime target. -% event.value - Value supplied by the control, interaction, or dispatch call. -% event.meta - Scalar struct containing source-specific details. For UI -% events, meta.original contains the layout event without runtime handles. -% -% Service Fields: -% services.figure - Owning app figure, for example as the parent of a custom -% dialog. -% services.debug - Debug context for trace messages and diagnostic reports. -% services.request - Read-only launch request returned by RequestAdapter. -% services.dispatch - Queues another action. Call dispatch(id,value) or pass -% a scalar event struct. Nested actions run after the current action. -% services.workflow - workflow.log(state,message) appends a visible workflow -% message and returns the updated state. -% services.diagnostics - diagnostics.report(context,exception) records a -% caught exception in the runtime debug report. -% services.events - Helpers entries(event,field), paths(event,field), and -% indices(event,field,count) decode values from UI event metadata. -% services.dialogs - App-parented alert, choice, inputFile, inputFolder, -% outputFile, and outputFolder dialogs, plus defaultFolder and -% defaultOutputFolder path helpers. File and folder selectors return the -% selected string path and a logical cancelled flag. -% services.project - newState creates a fresh canonical project and session -% through the current definition. sourceRecord, upsertSource, and -% reconcileSources create external-file records understood by project -% save/load. Apps read current paths with labkit.ui.runtime.sourcePaths. -% Each save -% rebases their relative paths from its actual destination; saveState -% saves a named project, while saveAutosave(state) immediately writes the -% framework-managed recovery copy. saveAutosave(state,filepath) writes -% the same recovery envelope to an app-determined path. Neither form -% prompts for a path or changes named-project ownership. -% services.previews - previews.axes(previewId,axisId) returns axes owned by a -% declared preview area. -% services.resources - set, get, remove, and clearScope manage resources with -% optional cleanup functions at event, interaction, or figure scope. -% set replaces and disposes an existing resource with the same scope and -% id; choose distinct ids for resources that must coexist. -% services.results - results.emptyOutputs creates the canonical empty -% output array; results.output creates one validated manifest output; -% results.writeManifest writes the app's result manifest. -% -% Action Processing: -% Actions are processed in FIFO order. After an action returns, the runtime -% validates the complete state and presents the new view as one transaction. -% If the action, validation, or presentation throws, the previous state and -% view are restored and the error is rethrown. An action with no output is -% allowed for side effects, but it cannot change value-based project or -% session state. -% -% Errors: -% labkit:ui:runtime:InvalidDefinitionOptions - Name-value arguments are not -% paired. -% labkit:ui:runtime:MissingDefinitionField - A required definition field is -% absent. -% labkit:ui:runtime:InvalidDefinition - Product metadata, requirements, -% project specification, callbacks, actions, utilities, or startup IDs do -% not satisfy the Runtime V2 contract. Layout callback output is validated -% later when launch or create builds the workbench. -% -% Typical Call: -% def = labkit.ui.runtime.define( ... -% "Command", "labkit_ExampleViewer_app", ... -% "Id", "org.example.viewer", "Title", "Example Viewer", ... -% "Family", "Examples", "AppVersion", "1.0.0", ... -% "Updated", "2026-07-16", ... -% "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ... -% "Layout", @buildStaticLayout); -% -% See also labkit.ui.runtime.launch, labkit.ui.runtime.sourceRecord, -% labkit.ui.runtime.sourcePaths, -% labkit.ui.runtime.saveState, labkit.ui.runtime.loadState - - opts = parseOptions(varargin); - def = createV2Definition(opts); - validateAppDefinition(def); -end - -function def = createV2Definition(opts) - def = struct(); - def.type = "labkit.ui.runtime.definition"; - def.contractVersion = 2; - def.id = string(requiredOption(opts, "Id")); - def.title = string(requiredOption(opts, "Title")); - def.product = productMetadata(opts, def.title); - def.requirements = optionValue(opts, "Requirements", []); - def.project = optionValue(opts, "Project", defaultProjectSpec()); - def.createSession = optionValue(opts, "CreateSession", []); - def.layout = requiredOption(opts, "Layout"); - def.actions = optionValue(opts, "Actions", struct()); - def.present = optionValue(opts, "Present", @emptyPresentation); - def.renderers = optionValue(opts, "Renderers", struct()); - def.start = optionValue(opts, "Start", []); - def.debugSample = optionValue(opts, "DebugSample", []); - def.utilities = optionValue(opts, "Utilities", struct()); -end - -function product = productMetadata(opts, title) - product = struct( ... - "command", string(optionValue(opts, "Command", "")), ... - "displayName", string(optionValue(opts, "DisplayName", title)), ... - "family", string(optionValue(opts, "Family", "")), ... - "version", string(optionValue(opts, "AppVersion", "")), ... - "updated", string(optionValue(opts, "Updated", ""))); -end - -function spec = defaultProjectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createEmptyProject, ... - "Validate", @acceptProject, ... - "Migrate", []); -end - -function project = createEmptyProject() - project = struct(); -end - -function accepted = acceptProject(~) - accepted = true; -end - -function presentation = emptyPresentation(~) - presentation = struct(); -end - -function opts = parseOptions(args) - if mod(numel(args), 2) ~= 0 - error('labkit:ui:runtime:InvalidDefinitionOptions', ... - 'labkit.ui.runtime.define options must be name-value pairs.'); - end - opts = struct(); - for k = 1:2:numel(args) - name = char(string(args{k})); - opts.(name) = args{k + 1}; - end -end - -function value = requiredOption(opts, name) - field = char(name); - if ~isfield(opts, field) - error('labkit:ui:runtime:MissingDefinitionField', ... - 'App definition is missing required field "%s".', field); - end - value = opts.(field); -end - -function value = optionValue(opts, name, defaultValue) - field = char(name); - value = defaultValue; - if isfield(opts, field) - value = opts.(field); - end -end diff --git a/+labkit/+ui/+runtime/emptySourceRecords.m b/+labkit/+ui/+runtime/emptySourceRecords.m deleted file mode 100644 index 1387475e1..000000000 --- a/+labkit/+ui/+runtime/emptySourceRecords.m +++ /dev/null @@ -1,43 +0,0 @@ -function sources = emptySourceRecords() -%EMPTYSOURCERECORDS Create an empty Runtime V2 source array. -% -% Usage: -% sources = labkit.ui.runtime.emptySourceRecords() -% -% Outputs: -% sources - 0-by-1 struct array with id, required, role, and a -% runtime-owned portable reference. -% -% Source Record Fields: -% id - Stable source identifier chosen by the app. -% required - Logical value indicating whether project load must resolve the -% file before committing the project. -% role - App-defined description of how the source is used. -% reference - Runtime-owned portable reference. Apps obtain populated -% records from injected services.project operations and read resolved -% paths through labkit.ui.runtime.sourcePaths rather than its fields. -% -% Description: -% Use this value to initialize project.inputs.sources when a new project has -% no external files. Starting with the expected empty shape avoids struct -% assignment errors when source records are added later. -% -% Failure Behavior: -% The function accepts no caller input and performs no file-system access. -% It returns the fixed Runtime V2 empty source schema without a recoverable -% failure state. -% -% Example: -% project.inputs = struct( ... -% "sources", labkit.ui.runtime.emptySourceRecords()); -% assert(isempty(project.inputs.sources)) -% -% See also labkit.ui.runtime.sourceRecord, labkit.ui.runtime.sourcePaths, -% labkit.ui.runtime.define - - reference = struct("schemaVersion", 1, "relativePath", "", ... - "originalPath", "", "fileName", ""); - prototype = struct("id", "", "required", true, "role", "", ... - "reference", reference); - sources = repmat(prototype, 0, 1); -end diff --git a/+labkit/+ui/+runtime/launch.m b/+labkit/+ui/+runtime/launch.m deleted file mode 100644 index d2416210d..000000000 --- a/+labkit/+ui/+runtime/launch.m +++ /dev/null @@ -1,158 +0,0 @@ -function varargout = launch(definitionFcn, varargin) -%LAUNCH Dispatch requests and launch a LabKit runtime definition. -% -% Usage: -% fig = labkit.ui.runtime.launch(definitionFcn, varargin{:}) -% [fig, debug] = labkit.ui.runtime.launch(..., "debug") -% requirements = labkit.ui.runtime.launch(..., "requirements") -% version = labkit.ui.runtime.launch(..., "version") -% fig = labkit.ui.runtime.launch(..., "RequestAdapter", adapter, args{:}) -% -% Inputs: -% definitionFcn - Function handle returning a definition created by define. -% -% Outputs: -% fig - App figure for normal and debug launches. -% debug - Debug context returned only when the request is "debug". -% requirements - Requirements metadata for a "requirements" request. -% version - Version metadata for a "version" request. -% -% Description: -% launch checks product requirements, creates project and session state, -% builds the layout, presents the first view, and then queues the definition's -% Start action. The startup window reports these phases until the app is -% ready. A debug launch additionally enables tracing and queues DebugSample. -% The "requirements" and "version" requests return metadata without building -% a GUI. -% -% Request Adapter: -% Apps that accept typed entry-point arguments can pass "RequestAdapter", -% adapter before those arguments. MATLAB calls -% [request,dispatchArgs] = adapter(args), where args is a cell array. request -% must be a scalar struct and is available to actions as services.request; -% dispatchArgs must be a cell array containing a normal runtime request such -% as {} or {"debug"}. -% -% Errors: -% labkit:ui:runtime:InvalidLaunchFactory - definitionFcn is not a function -% handle, or retired requirements/version factories were passed after -% the definition factory. -% labkit:ui:runtime:MissingProductMetadata - The definition does not provide -% its complete product metadata and Requirements. -% labkit:ui:runtime:InvalidProductMetadata - AppVersion or Updated does not -% use the documented semantic-version or ISO-date form. -% -% Typical Call: -% fig = labkit.ui.runtime.launch(@appDefinition); -% -% See also labkit.ui.runtime.define - - assertFactory(definitionFcn, "definitionFcn"); - if ~isempty(varargin) && isa(varargin{1}, 'function_handle') - error('labkit:ui:runtime:InvalidLaunchFactory', ... - ['launch accepts one definition factory. Put product metadata ' ... - 'and Requirements in the definition returned by that factory.']); - end - definition = definitionFcn(); - [requirements, info] = definitionLaunchMetadata(definition); - appName = char(string(info.name)); - [request, dispatchArgs] = prepareRequest(varargin); - [handled, outputs, debug] = dispatchRequest( ... - appName, dispatchArgs, nargout, "Requirements", requirements, ... - "Version", info); - if handled - varargout = outputs; - return; - end - validateOutputCount(appName, debug, nargout); - request.debug = debug; - fig = runAppDefinition(definition, request); - applyVersionTitle(fig, info); - if nargout >= 1 - varargout{1} = fig; - end - if nargout >= 2 - varargout{2} = debug; - end -end - -function [requirements, info] = definitionLaunchMetadata(definition) - if ~isstruct(definition) || ~isscalar(definition) || ... - ~isfield(definition, 'product') || ... - ~isfield(definition, 'requirements') - error('labkit:ui:runtime:MissingProductMetadata', ... - 'Single-definition launch requires Runtime V2 product metadata.'); - end - product = definition.product; - fields = ["command", "displayName", "family", "version", "updated"]; - for field = fields - if ~isfield(product, field) || ... - strlength(strtrim(string(product.(field)))) == 0 - error('labkit:ui:runtime:MissingProductMetadata', ... - 'Single-definition launch requires product field %s.', field); - end - end - if isempty(regexp(char(product.version), '^\d+\.\d+\.\d+$', 'once')) - error('labkit:ui:runtime:InvalidProductMetadata', ... - 'AppVersion must use X.Y.Z semantic version form.'); - end - if isempty(regexp(char(product.updated), '^\d{4}-\d{2}-\d{2}$', 'once')) - error('labkit:ui:runtime:InvalidProductMetadata', ... - 'Updated must use YYYY-MM-DD form.'); - end - requirements = definition.requirements; - if isempty(requirements) - error('labkit:ui:runtime:MissingProductMetadata', ... - 'Single-definition launch requires Requirements.'); - end - info = struct( ... - "name", string(product.command), ... - "displayName", string(product.displayName), ... - "family", string(product.family), ... - "version", string(product.version), ... - "updated", string(product.updated)); -end - -function [request, dispatchArgs] = prepareRequest(args) - request = struct(); - dispatchArgs = args; - if numel(args) < 2 || ~isScalarText(args{1}) || ... - string(args{1}) ~= "RequestAdapter" - return; - end - adapter = args{2}; - if ~isa(adapter, 'function_handle') - error('labkit:ui:runtime:InvalidRequestAdapter', ... - 'RequestAdapter must be a function handle.'); - end - [request, dispatchArgs] = adapter(args(3:end)); - if ~isstruct(request) || ~isscalar(request) || ~iscell(dispatchArgs) - error('labkit:ui:runtime:InvalidRequestAdapter', ... - ['RequestAdapter must return a scalar request struct and a ' ... - 'dispatch-argument cell array.']); - end -end - -function tf = isScalarText(value) - tf = ischar(value) || (isstring(value) && isscalar(value)); -end - -function assertFactory(value, name) - if ~isa(value, 'function_handle') - error('labkit:ui:runtime:InvalidLaunchFactory', ... - '%s must be a function handle.', name); - end -end - -function validateOutputCount(appName, debug, nout) - if isstruct(debug) && isfield(debug, 'enabled') && logical(debug.enabled) - maximum = 2; - else - maximum = 1; - end - if nout > maximum - error([appName ':TooManyOutputs'], ... - '%s returns at most %d output(s) for this request.', ... - appName, maximum); - end -end diff --git a/+labkit/+ui/+runtime/loadState.m b/+labkit/+ui/+runtime/loadState.m deleted file mode 100644 index c9ce3d33b..000000000 --- a/+labkit/+ui/+runtime/loadState.m +++ /dev/null @@ -1,86 +0,0 @@ -function filepath = loadState(fig, filepath) -%LOADSTATE Load a compatible Runtime V2 project or declared legacy import. -% -% Usage: -% filepath = labkit.ui.runtime.loadState(fig) -% filepath = labkit.ui.runtime.loadState(fig, filepath) -% -% Inputs: -% fig - Live app figure created by labkit.ui.runtime.launch. -% filepath - MAT-file to load. When omitted, MATLAB opens a file-selection -% dialog. -% -% Outputs: -% filepath - Selected or supplied path as a string scalar, or "" when the -% user cancels the dialog. -% -% Description: -% loadState accepts a current labkitProject envelope, an older Runtime V2 -% snapshot, or a MAT-file variable named in Project.LegacyImports. Current -% payloads are migrated one version at a time, validated, and checked for -% required source files. When automatic source resolution fails, the runtime -% identifies each missing file and lets the user locate it or cancel. A fresh -% session is then created and optional resume data is applied. The live app -% changes only after the complete candidate and its first presentation -% succeed; an error or cancellation leaves the previous project and view -% intact. Relinked or migrated documents open as unsaved work. Saving them -% writes the current labkitProject format rather than the imported old format. -% -% Errors: -% labkit:ui:runtime:ProjectLoadCancelled - Required-source relinking was -% cancelled. The live project remains unchanged. -% Project-format, validation, migration, source-decoding, and presentation -% errors are rethrown after the Runtime records diagnostic context. The -% live project remains unchanged. -% -% Typical Call: -% loadedFile = labkit.ui.runtime.loadState(fig, "analysis.project.mat"); -% -% See also labkit.ui.runtime.saveState, labkit.ui.runtime.define - - if nargin < 2 - filepath = chooseProjectInput(); - if strlength(filepath) == 0 - return; - end - else - filepath = string(filepath); - end - try - restoreV2Project(fig, filepath); - catch ME - reportLoadFailure(fig, filepath, ME); - rethrow(ME); - end -end - -function filepath = chooseProjectInput() - [file, path] = uigetfile({'*.mat', 'MAT files (*.mat)'}, ... - 'Load LabKit Project'); - if isequal(file, 0) || isequal(path, 0) - filepath = ""; - else - filepath = string(fullfile(path, file)); - end -end - -function reportLoadFailure(fig, filepath, exception) - if string(exception.identifier) == ... - "labkit:ui:runtime:ProjectLoadCancelled" - return; - end - try - runtime = getAppRuntime(fig); - debug = runtime.debug; - if isstruct(debug) && isfield(debug, 'reportException') && ... - isa(debug.reportException, 'function_handle') - [~, name, extension] = fileparts(filepath); - context = sprintf('project load failed for %s%s', ... - name, extension); - debug.reportException(char(string(runtime.definition.id)), ... - context, exception); - end - catch - % Diagnostic reporting must not replace the original load failure. - end -end diff --git a/+labkit/+ui/+runtime/private/addRowResizeHandle.m b/+labkit/+ui/+runtime/private/addRowResizeHandle.m deleted file mode 100644 index 39cc926b6..000000000 --- a/+labkit/+ui/+runtime/private/addRowResizeHandle.m +++ /dev/null @@ -1,88 +0,0 @@ -% Private UI runtime helper. Expected caller: labkit.ui.runtime shell construction -% code. Inputs and outputs are internal uifigure, grid, tab, or resize handle -% values. Side effects are limited to UI object creation or callback wiring on -% supplied parents; assumes the caller owns component lifecycle. -function handle = addRowResizeHandle(fig, grid, handleRow, opts) -%ADDROWRESIZEHANDLE Add a draggable horizontal resize handle between grid rows. -% -% Usage: -% handle = addRowResizeHandle(fig, grid, 2, ... -% struct('topRow', 1)); -% -% Inputs: -% fig - owning figure. -% grid - parent uigridlayout. -% handleRow - physical row reserved for the drag handle. -% opts - optional struct. -% -% Options: -% topRow - physical row above handle, default handleRow-1. -% minTopHeight - pixels, default 80. -% handleHeight - pixels, default 6. -% -% Output: -% handle - panel component used as the draggable separator. - - if nargin < 4 - opts = struct(); - end - - topRow = optionValue(opts, 'topRow', handleRow - 1); - minTopHeight = optionValue(opts, 'minTopHeight', 80); - handleHeight = optionValue(opts, 'handleHeight', 6); - - rows = grid.RowHeight; - rows{handleRow} = handleHeight; - grid.RowHeight = rows; - - handle = uipanel(grid, ... - 'BackgroundColor', [0.75 0.75 0.75], ... - 'BorderType', 'none'); - handle.Layout.Row = handleRow; - handle.Layout.Column = 1; - if isprop(handle, 'Tooltip') - handle.Tooltip = 'Drag to resize panels'; - end - - attachDragHandle(fig, handle, struct( ... - 'pointer', 'top', ... - 'onStart', @startResize, ... - 'onDrag', @dragResize, ... - 'onTrace', optionValue(opts, 'onTrace', []), ... - 'traceName', 'row-resize')); - - function data = startResize(~) - data = struct( ... - 'topHeight', rowHeightFromChild(grid, topRow, minTopHeight)); - end - - function dragResize(data, deltaPoint, ~) - deltaDown = -deltaPoint(2); - rowHeights = grid.RowHeight; - rowHeights{topRow} = max(minTopHeight, data.topHeight + deltaDown); - rowHeights{handleRow} = handleHeight; - grid.RowHeight = rowHeights; - end -end - -function height = rowHeightFromChild(grid, row, fallback) - height = fallback; - children = grid.Children; - for k = 1:numel(children) - layout = children(k).Layout; - if isprop(layout, 'Row') && isequal(layout.Row, row) - pos = children(k).Position; - if numel(pos) >= 4 && isfinite(pos(4)) && pos(4) > 0 - height = pos(4); - return; - end - end - end -end - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isfield(opts, name) - value = opts.(name); - end -end diff --git a/+labkit/+ui/+runtime/private/appRuntimeKey.m b/+labkit/+ui/+runtime/private/appRuntimeKey.m deleted file mode 100644 index c9193b7d9..000000000 --- a/+labkit/+ui/+runtime/private/appRuntimeKey.m +++ /dev/null @@ -1,5 +0,0 @@ -% Private UI runtime helper. Expected caller: runtime and snapshot services. -% Output is the single appdata key used for LabKit app runtime storage. -function key = appRuntimeKey() - key = 'labkitUiAppRuntime'; -end diff --git a/+labkit/+ui/+runtime/private/appStorageKey.m b/+labkit/+ui/+runtime/private/appStorageKey.m deleted file mode 100644 index 65f42a10f..000000000 --- a/+labkit/+ui/+runtime/private/appStorageKey.m +++ /dev/null @@ -1,12 +0,0 @@ -% Private Runtime V2 identity helper. Expected callers are recovery readers -% and writers. Input is one validated app id. Output is a reversible, -% filesystem-safe UTF-8 hex key, so distinct app ids cannot share storage. -function key = appStorageKey(appId) - appId = string(appId); - if ~isscalar(appId) || strlength(appId) == 0 - error('labkit:ui:runtime:InvalidAppId', ... - 'App id must be nonempty scalar text.'); - end - bytes = unicode2native(char(appId), 'UTF-8'); - key = "app_" + lower(string(reshape(dec2hex(bytes, 2).', 1, []))); -end diff --git a/+labkit/+ui/+runtime/private/appVersionTitle.m b/+labkit/+ui/+runtime/private/appVersionTitle.m deleted file mode 100644 index 7074c0ff9..000000000 --- a/+labkit/+ui/+runtime/private/appVersionTitle.m +++ /dev/null @@ -1,63 +0,0 @@ -% Private UI runtime helper. Formats an app title with version metadata. -function titleText = appVersionTitle(baseTitle, info) -% -% Internal contract: -% titleText = appVersionTitle(baseTitle, info) -% -% Inputs: -% baseTitle - scalar text already used as the figure title. -% info - app version struct with version and updated scalar text fields. -% -% Outputs: -% titleText - string in the form " v ()". - - version = textField(info, 'version'); - updated = textField(info, 'updated'); - suffix = " v" + version + " (" + updated + ")"; - baseTitle = stripVersionSuffix(normalizeTitle(baseTitle, info), suffix); - titleText = baseTitle + suffix; -end - -function baseTitle = stripVersionSuffix(baseTitle, suffix) - if endsWith(baseTitle, suffix) - suffixStart = strlength(baseTitle) - strlength(suffix) + 1; - if suffixStart > 1 - baseTitle = strtrim(extractBefore(baseTitle, suffixStart)); - end - end -end - -function baseTitle = normalizeTitle(value, info) - if nargin > 0 && (ischar(value) || (isstring(value) && isscalar(value))) - baseTitle = strtrim(string(value)); - else - baseTitle = ""; - end - if strlength(baseTitle) == 0 && isstruct(info) && isfield(info, 'displayName') - baseTitle = textField(info, 'displayName'); - end - if strlength(baseTitle) == 0 && isstruct(info) && isfield(info, 'name') - baseTitle = textField(info, 'name'); - end - if strlength(baseTitle) == 0 - error('labkit:ui:runtime:InvalidVersionInfo', ... - 'App version title requires a base title, displayName, or name.'); - end -end - -function text = textField(info, fieldName) - if ~isstruct(info) || ~isscalar(info) || ~isfield(info, fieldName) - error('labkit:ui:runtime:InvalidVersionInfo', ... - 'App version info must include scalar text field "%s".', fieldName); - end - value = info.(fieldName); - if ~(ischar(value) || (isstring(value) && isscalar(value))) - error('labkit:ui:runtime:InvalidVersionInfo', ... - 'App version info field "%s" must be scalar text.', fieldName); - end - text = strtrim(string(value)); - if strlength(text) == 0 - error('labkit:ui:runtime:InvalidVersionInfo', ... - 'App version info field "%s" cannot be empty.', fieldName); - end -end diff --git a/+labkit/+ui/+runtime/private/applyCommonValueProps.m b/+labkit/+ui/+runtime/private/applyCommonValueProps.m deleted file mode 100644 index cd3ffad5d..000000000 --- a/+labkit/+ui/+runtime/private/applyCommonValueProps.m +++ /dev/null @@ -1,25 +0,0 @@ -% Private UI runtime helper. Expected caller: declarative control builders. Inputs are -% a MATLAB UI handle and validated spec props. Output is none. Side effects -% assign common value, limit, item, and display-format properties when the -% target handle supports them. -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 - if isfield(props, 'value') && isprop(control, 'Text') && ~isprop(control, 'Value') - control.Text = char(string(props.value)); - applyTextFit(control); - end -end diff --git a/+labkit/+ui/+runtime/private/applySelectedFileContext.m b/+labkit/+ui/+runtime/private/applySelectedFileContext.m deleted file mode 100644 index 44f3f2bcf..000000000 --- a/+labkit/+ui/+runtime/private/applySelectedFileContext.m +++ /dev/null @@ -1,151 +0,0 @@ -% Private UI runtime helper. Expected caller: setControlFileSelection. -% Inputs are the current UI registry and a filePanel id. Side effects are -% figure appdata/title updates and previewArea axes title refreshes. -function applySelectedFileContext(ui, id) - if ~(isstruct(ui) && isfield(ui, 'figure') && isfield(ui, 'controls')) - return; - end - - fig = ui.figure; - if isempty(fig) || ~isvalid(fig) - return; - end - - control = resolveControl(ui, id); - context = selectedFileContext(control); - setappdata(fig, 'labkitSelectedFileContext', context); - refreshFigureTitle(fig, context); - refreshPreviewTitles(ui, context); -end - -function context = selectedFileContext(control) - context = struct( ... - 'valid', false, ... - 'filePanelId', string(control.id), ... - 'index', 0, ... - 'count', 0, ... - 'name', ""); - if ~isfield(control, 'currentFiles') || ~isa(control.currentFiles, 'function_handle') || ... - ~isfield(control, 'currentSelectedFiles') || ~isa(control.currentSelectedFiles, 'function_handle') - return; - end - - files = control.currentFiles(); - selected = control.currentSelectedFiles(); - if isempty(files) || isempty(selected) - return; - end - - chosen = selected(1); - context.count = numel(files); - context.index = selectedIndex(files, chosen); - context.name = selectedName(chosen); - context.valid = context.index > 0 && context.count > 0 && ... - strlength(context.name) > 0; -end - -function index = selectedIndex(files, chosen) - index = 0; - if isfield(chosen, 'index') - value = double(chosen.index); - if isscalar(value) && isfinite(value) && value > 0 - index = value; - return; - end - end - if isfield(chosen, 'id') && isfield(files, 'id') - ids = string({files.id}); - match = find(ids == string(chosen.id), 1, 'first'); - if ~isempty(match) - index = match; - end - end -end - -function name = selectedName(chosen) - name = ""; - for fieldName = ["displayName", "name", "path"] - field = char(fieldName); - if isfield(chosen, field) - value = string(chosen.(field)); - if isempty(value) - continue; - end - value = value(1); - if strlength(value) > 0 - name = value; - return; - end - end - end -end - -function refreshFigureTitle(fig, context) - if ~isprop(fig, 'Name') - return; - end - baseTitle = stripFileContextSuffix(fig.Name); - if isValidContext(context) - fig.Name = char(string(baseTitle) + " | " + fileContextSuffix(context)); - else - fig.Name = baseTitle; - end -end - -function refreshPreviewTitles(ui, context) - names = fieldnames(ui.controls); - for k = 1:numel(names) - control = ui.controls.(names{k}); - if ~isfield(control, 'kind') || ~strcmp(control.kind, 'previewArea') - continue; - end - axesList = previewAxes(control); - for n = 1:numel(axesList) - ax = axesList(n); - if isgraphics(ax) && isprop(ax, 'Title') - title(ax, selectedFileContextTitle(ax.Title.String, context)); - end - end - end -end - -function titleText = selectedFileContextTitle(titleText, context) - titleText = stripFileContextSuffix(char(string(titleText))); - if ~isValidContext(context) - return; - end - - suffix = fileContextSuffix(context); - if strlength(string(titleText)) == 0 - titleText = char(suffix); - else - titleText = char(string(titleText) + " | " + suffix); - end -end - -function axesList = previewAxes(control) - axesList = gobjects(0); - if isfield(control, 'axes') - axesList = control.axes; - elseif isfield(control, 'primaryAxes') - axesList = control.primaryAxes; - end -end - -function tf = isValidContext(context) - tf = isstruct(context) && isfield(context, 'valid') && ... - isscalar(context.valid) && logical(context.valid); -end - -function suffix = fileContextSuffix(context) - suffix = sprintf('file %d/%d: %s', context.index, ... - context.count, char(string(context.name))); -end - -function titleText = stripFileContextSuffix(titleText) - titleText = char(string(titleText)); - titleText = regexprep(titleText, ... - '^file\s+\d+/\d+:\s.*$', ''); - titleText = regexprep(titleText, ... - '\s\|\sfile\s+\d+/\d+:\s.*$', ''); -end diff --git a/+labkit/+ui/+runtime/private/applyTextFit.m b/+labkit/+ui/+runtime/private/applyTextFit.m deleted file mode 100644 index cfda3dbf4..000000000 --- a/+labkit/+ui/+runtime/private/applyTextFit.m +++ /dev/null @@ -1,107 +0,0 @@ -% Private UI runtime helper. Expected caller: declarative control builders. Inputs are -% a MATLAB UI text-bearing handle and optional fitting limits. Output is none. -% Side effects: enables wrapping when supported, reduces font size for long -% text, and installs a tooltip with the full text when supported. -function applyTextFit(handle, varargin) - opts = parseOptions(varargin{:}); - text = controlText(handle); - if strlength(text) == 0 - return; - end - - enableWrap(handle); - applyShrink(handle, text, opts); - applyTooltip(handle, text); -end - -function opts = parseOptions(varargin) - opts = struct( ... - 'baseFontSize', NaN, ... - 'minFontSize', 10, ... - 'charsPerStep', 28, ... - 'maxShrinkSteps', 3); - for k = 1:2:numel(varargin) - name = char(string(varargin{k})); - if k + 1 <= numel(varargin) && isfield(opts, name) - opts.(name) = varargin{k + 1}; - end - end -end - -function enableWrap(handle) - if ~isprop(handle, 'WordWrap') - return; - end - try - if ~strcmp(handle.WordWrap, 'on') - handle.WordWrap = 'on'; - end - catch - end -end - -function applyShrink(handle, text, opts) - if ~isprop(handle, 'FontSize') - return; - end - try - baseSize = double(handle.FontSize); - if isfinite(opts.baseFontSize) - baseSize = double(opts.baseFontSize); - end - longestLine = max(strlength(splitlines(text))); - shrinkSteps = max(0, ceil(double(longestLine) ./ opts.charsPerStep) - 1); - shrinkSteps = min(double(opts.maxShrinkSteps), shrinkSteps); - targetSize = max(double(opts.minFontSize), baseSize - shrinkSteps); - if handle.FontSize ~= targetSize - handle.FontSize = targetSize; - end - catch - end -end - -function applyTooltip(handle, text) - tooltip = char(text); - if isprop(handle, 'Tooltip') - try - if ~strcmp(handle.Tooltip, tooltip) - handle.Tooltip = tooltip; - end - return; - catch - end - end - if isprop(handle, 'TooltipString') - try - if ~strcmp(handle.TooltipString, tooltip) - handle.TooltipString = tooltip; - end - catch - end - end -end - -function text = controlText(handle) - text = ""; - if isprop(handle, 'Text') - try - text = string(handle.Text); - text = join(text(:), newline); - return; - catch - end - end - if isprop(handle, 'Value') - try - value = handle.Value; - if iscell(value) - text = join(string(value(:)), newline); - else - text = string(value); - text = join(text(:), newline); - end - catch - text = ""; - end - end -end diff --git a/+labkit/+ui/+runtime/private/applyVersionTitle.m b/+labkit/+ui/+runtime/private/applyVersionTitle.m deleted file mode 100644 index d8cc2cbc2..000000000 --- a/+labkit/+ui/+runtime/private/applyVersionTitle.m +++ /dev/null @@ -1,21 +0,0 @@ -% Private UI runtime helper. Applies app version metadata to a figure title. -function titleText = applyVersionTitle(fig, info) -% -% Internal contract: -% titleText = applyVersionTitle(fig, info) -% -% Inputs: -% fig - scalar app figure handle with a Name property. -% info - app version struct with version and updated scalar text fields. -% -% Outputs: -% titleText - title written to fig.Name. - - if isempty(fig) || ~isscalar(fig) || ~isvalid(fig) || ~isprop(fig, 'Name') - error('labkit:ui:runtime:InvalidFigure', ... - 'Version title can only be applied to a valid scalar figure.'); - end - titleText = appVersionTitle(string(fig.Name), info); - fig.Name = char(titleText); - setappdata(fig, 'labkitUiAppVersion', info); -end diff --git a/+labkit/+ui/+runtime/private/attachColumnResize.m b/+labkit/+ui/+runtime/private/attachColumnResize.m deleted file mode 100644 index f37417271..000000000 --- a/+labkit/+ui/+runtime/private/attachColumnResize.m +++ /dev/null @@ -1,73 +0,0 @@ -% Private UI runtime helper. Expected caller: labkit.ui.runtime shell construction -% code. Inputs and outputs are internal uifigure, grid, tab, or resize handle -% values. Side effects are limited to UI object creation or callback wiring on -% supplied parents; assumes the caller owns component lifecycle. -function attachColumnResize(fig, grid, leftColumn, separatorColumn, opts) -%ATTACHCOLUMNRESIZE Attach drag-to-resize behavior to a grid separator. -% -% Called by: -% createTabbedWorkbenchShell and other UI shell internals. -% -% Inputs: -% fig - owning uifigure whose pointer and window callbacks are used. -% grid - uigridlayout with a separator panel in separatorColumn. -% leftColumn - column index whose width should be resized. -% separatorColumn - column index containing the drag handle panel. -% opts - optional struct with minWidth, rightReserve, and separatorWidth. -% -% Side effects: -% Installs ButtonDownFcn on the separator and temporary figure -% WindowButtonMotionFcn/WindowButtonUpFcn callbacks while dragging. -% -% Notes: -% This helper mutates layout handles only; apps request resizable workbench -% layouts through labkit.ui.runtime.create. - - if nargin < 5 - opts = struct(); - end - - minWidth = optionValue(opts, 'minWidth', 260); - rightReserve = optionValue(opts, 'rightReserve', 360); - separatorWidth = optionValue(opts, 'separatorWidth', 6); - - separator = findSeparator(grid, separatorColumn); - attachDragHandle(fig, separator, struct( ... - 'pointer', 'left', ... - 'onDrag', @dragResize, ... - 'onTrace', optionValue(opts, 'onTrace', []), ... - 'traceName', 'column-resize')); - - function dragResize(~, ~, currentPoint) - newWidth = max(minWidth, currentPoint(1) - grid.Position(1)); - maxWidth = max(minWidth, fig.Position(3) - rightReserve); - newWidth = min(newWidth, maxWidth); - - widths = grid.ColumnWidth; - widths{leftColumn} = newWidth; - widths{separatorColumn} = separatorWidth; - grid.ColumnWidth = widths; - end -end - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isfield(opts, name) - value = opts.(name); - end -end - -function separator = findSeparator(grid, separatorColumn) - separator = []; - children = grid.Children; - for k = 1:numel(children) - layout = children(k).Layout; - if isprop(layout, 'Column') && isequal(layout.Column, separatorColumn) - separator = children(k); - return; - end - end - - error('labkit:ui:MissingSeparator', ... - 'Could not find a grid child in separator column %d.', separatorColumn); -end diff --git a/+labkit/+ui/+runtime/private/attachDragHandle.m b/+labkit/+ui/+runtime/private/attachDragHandle.m deleted file mode 100644 index 17b6f88a5..000000000 --- a/+labkit/+ui/+runtime/private/attachDragHandle.m +++ /dev/null @@ -1,210 +0,0 @@ -% Private UI runtime helper. Expected caller: UI runtime shell resize helpers. -% Inputs are a figure, a draggable UI component, and callbacks for drag start -% and motion. Side effects are limited to installing handle and temporary -% figure callbacks, then restoring the previous figure callback state. -function attachDragHandle(fig, handle, opts) -%ATTACHDRAGHANDLE Install shared drag lifecycle behavior on a UI handle. -% -% Inputs: -% fig - owning uifigure whose pointer and window callbacks are used. -% handle - UI component receiving the initial pointer down event. -% opts - struct with optional fields: -% pointer - pointer shape during drag, default 'arrow'. -% onStart - function data = onStart(startPoint). -% onDrag - function onDrag(data, deltaPoint, currentPoint). -% onStop - function onStop(data). -% onTrace - function trace(message), default []. -% traceName - label in trace messages, default 'drag-handle'. -% -% The helper centralizes the behavior used by column and row resize handles: -% make the handle pickable when supported, save existing figure callbacks, -% install temporary motion/release/cancel callbacks, and restore the previous -% state on release. A click outside the drag handle or Escape cancels a stuck -% drag lifecycle when a slow app misses the mouse-up event. - - if nargin < 3 - opts = struct(); - end - - applyPointerHitTarget(handle); - handle.ButtonDownFcn = @startDrag; - - drag = struct('active', false, 'startPoint', [NaN NaN], 'data', [], ... - 'oldPointer', '', 'oldMotionFcn', [], 'oldUpFcn', [], ... - 'oldDownFcn', [], 'oldKeyPressFcn', []); - - function startDrag(~, evt) - if drag.active - finishDrag(false, 'restart'); - end - drawnow; - drag.active = true; - drag.startPoint = pointerPoint(fig, evt); - drag.oldPointer = fig.Pointer; - drag.oldMotionFcn = fig.WindowButtonMotionFcn; - drag.oldUpFcn = fig.WindowButtonUpFcn; - drag.oldDownFcn = fig.WindowButtonDownFcn; - drag.oldKeyPressFcn = fig.WindowKeyPressFcn; - drag.data = invokeStart(optionValue(opts, 'onStart', []), ... - drag.startPoint); - fig.Pointer = optionValue(opts, 'pointer', 'arrow'); - fig.WindowButtonMotionFcn = @doDrag; - fig.WindowButtonUpFcn = @stopDrag; - fig.WindowButtonDownFcn = @cancelDrag; - fig.WindowKeyPressFcn = @keyCancelDrag; - trace(opts, 'begin', drag.startPoint, [0 0]); - end - - function doDrag(~, evt) - if ~drag.active || ~isLiveHandle(fig) - finishDrag(false, 'invalid'); - return; - end - currentPoint = pointerPoint(fig, evt); - if any(~isfinite(currentPoint)) - return; - end - callback = optionValue(opts, 'onDrag', []); - if ~isempty(callback) - callback(drag.data, currentPoint - drag.startPoint, currentPoint); - end - trace(opts, 'drag', currentPoint, currentPoint - drag.startPoint); - end - - function stopDrag(~, ~) - finishDrag(true, 'end'); - end - - function cancelDrag(~, ~) - finishDrag(false, 'cancel'); - end - - function keyCancelDrag(~, evt) - if isEscapeKey(evt) - finishDrag(false, 'cancel'); - return; - end - oldCallback = drag.oldKeyPressFcn; - if isa(oldCallback, 'function_handle') - oldCallback(fig, evt); - elseif iscell(oldCallback) && ~isempty(oldCallback) && ... - isa(oldCallback{1}, 'function_handle') - oldCallback{1}(fig, evt, oldCallback{2:end}); - end - end - - function finishDrag(runStopCallback, eventName) - if ~drag.active - return; - end - if runStopCallback - callback = optionValue(opts, 'onStop', []); - if ~isempty(callback) - callback(drag.data); - end - end - restoreDragState(); - drag.active = false; - trace(opts, eventName, [NaN NaN], [NaN NaN]); - end - - function restoreDragState() - if ~isLiveHandle(fig) - return; - end - fig.WindowButtonMotionFcn = drag.oldMotionFcn; - fig.WindowButtonUpFcn = drag.oldUpFcn; - fig.WindowButtonDownFcn = drag.oldDownFcn; - fig.WindowKeyPressFcn = drag.oldKeyPressFcn; - fig.Pointer = drag.oldPointer; - end -end - -function trace(opts, eventName, point, delta) - callback = optionValue(opts, 'onTrace', []); - if isempty(callback) - return; - end - name = optionValue(opts, 'traceName', 'drag-handle'); - try - callback(sprintf(['component=%s event=%s point=[%.1f %.1f] ' ... - 'delta=[%.1f %.1f]'], name, eventName, point(1), point(2), ... - delta(1), delta(2))); - catch - end -end - -function data = invokeStart(callback, startPoint) - data = []; - if ~isempty(callback) - data = callback(startPoint); - end -end - -function applyPointerHitTarget(handle) - if isprop(handle, 'HitTest') - handle.HitTest = 'on'; - end - if isprop(handle, 'PickableParts') - handle.PickableParts = 'all'; - end -end - -function point = pointerPoint(fig, evt) - point = [NaN NaN]; - if nargin >= 2 && ~isempty(evt) - point = pointFromEvent(evt); - end - if any(~isfinite(point)) - try - value = fig.CurrentPoint; - if isnumeric(value) && numel(value) >= 2 - point = value(1:2); - end - catch - end - end -end - -function point = pointFromEvent(evt) - point = [NaN NaN]; - names = {'CurrentPoint', 'Point', 'Position'}; - for k = 1:numel(names) - value = eventValue(evt, names{k}); - if isnumeric(value) && numel(value) >= 2 - point = value(1:2); - return; - end - end -end - -function value = eventValue(evt, name) - value = []; - if isstruct(evt) && isfield(evt, name) - value = evt.(name); - elseif isobject(evt) && isprop(evt, name) - value = evt.(name); - end -end - -function tf = isEscapeKey(evt) - key = string(eventValue(evt, 'Key')); - character = string(eventValue(evt, 'Character')); - tf = any(strcmpi([key character], ["escape" "esc"])); -end - -function tf = isLiveHandle(h) - tf = false; - try - tf = ~isempty(h) && isvalid(h); - catch - tf = false; - 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/+runtime/private/buildControl.m b/+labkit/+ui/+runtime/private/buildControl.m deleted file mode 100644 index 153bb6f73..000000000 --- a/+labkit/+ui/+runtime/private/buildControl.m +++ /dev/null @@ -1,613 +0,0 @@ -% Private UI runtime 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 'panner' - ui.controls.(controlSpec.id) = buildPannerControl(controlSpec, ... - parentGrid, row); - case 'action' - ui = buildAction(ui, controlSpec, parentGrid, row, [1 2]); - case 'group' - ui = buildGroup(ui, controlSpec, parentGrid, row, debug); - case 'filePanel' - ui = buildFilePanel(ui, controlSpec, parentGrid, row); - case 'resultTable' - ui = buildResultTable(ui, controlSpec, parentGrid, row); - case 'logPanel' - ui = buildLogPanel(ui, controlSpec, parentGrid, row, debug); - case 'usagePanel' - ui = buildUsagePanel(ui, controlSpec, parentGrid, row); - case 'statusPanel' - ui = buildStatusPanel(ui, controlSpec, parentGrid, row); - otherwise - error('labkit:ui:runtime:UnsupportedControl', ... - 'Unsupported declarative 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)); - applyTextFit(control); - 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'); - applyTextFit(label); - 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); - if strcmp(kind, 'readonly') - adapter.getValue = @() getReadonlyText(control); - adapter.setValue = @(value) setReadonlyText(control, value); - end - 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 = uitextarea(parentGrid, ... - 'Value', char(string(optionValue(props, 'value', ''))), ... - 'Editable', 'off', ... - 'Enable', onOff(enabled), ... - 'Tag', 'LabKitReadonlyText'); - applyTextFit(control); - otherwise - error('labkit:ui:runtime:UnsupportedFieldKind', ... - 'Unsupported declarative field kind "%s".', kind); - end - applyCommonValueProps(control, props); - applySliderTicks(control, props); -end - -function applySliderTicks(control, props) - if isempty(control) || ~contains(class(control), 'Slider') || ... - ~isfield(props, 'showTicks') || logical(props.showTicks) - return; - end - if isprop(control, 'MajorTicks') - control.MajorTicks = []; - end - if isprop(control, 'MinorTicks') - control.MinorTicks = []; - end -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:runtime:InvalidRangeValue', ... - 'rangeField "%s" value must have two elements.', rangeSpec.id); - end - - label = uilabel(parentGrid, 'Text', labelText, ... - 'HorizontalAlignment', 'right'); - applyTextFit(label); - 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:runtime:InvalidRangeValue', ... - 'rangeField "%s" value must have two elements.', rangeSpec.id); - end - first.Value = newValue(1); - second.Value = newValue(2); - end -end - -function ui = buildGroup(ui, groupSpec, parentGrid, row, debug) - if usesActionLayout(groupSpec) - ui = buildActionLayout(ui, groupSpec, parentGrid, row); - return; - end - ui = buildFormGroup(ui, groupSpec, parentGrid, row, debug); -end - -function tf = usesActionLayout(groupSpec) - layout = lower(char(string(optionValue(groupSpec.props, 'layout', 'auto')))); - childKinds = string(cellfun(@(child) child.kind, groupSpec.children, ... - 'UniformOutput', false)); - tf = strcmp(layout, 'actions') || ... - (strcmp(layout, 'auto') && all(childKinds == "action")); -end - -function ui = buildActionLayout(ui, groupSpec, parentGrid, row) - actions = groupSpec.children; - count = max(1, numel(actions)); - maxColumns = actionLayoutMaxColumns(groupSpec); - columnCount = min(count, maxColumns); - rowCount = max(1, ceil(count / columnCount)); - grid = uigridlayout(parentGrid, [rowCount columnCount]); - grid.Padding = [0 0 0 0]; - grid.RowSpacing = 6; - grid.ColumnSpacing = 8; - grid.RowHeight = repmat({'fit'}, 1, rowCount); - grid.ColumnWidth = repmat({'1x'}, 1, columnCount); - grid.Layout.Row = row; - grid.Layout.Column = [1 2]; - adapter = baseAdapter(groupSpec, 'group'); - adapter.grid = grid; - adapter.actions = struct(); - ui.controls.(groupSpec.id) = adapter; - for k = 1:numel(actions) - actionRow = ceil(k / columnCount); - actionColumn = mod(k - 1, columnCount) + 1; - if columnCount > 1 && actionColumn == 1 && k == numel(actions) && ... - mod(numel(actions), columnCount) == 1 - actionColumn = [1 columnCount]; - end - [ui, actionAdapter] = buildAction(ui, actions{k}, grid, ... - actionRow, actionColumn); - ui.controls.(groupSpec.id).actions.(actions{k}.id) = actionAdapter; - end -end - -function ui = buildFormGroup(ui, groupSpec, parentGrid, row, debug) - childCount = max(1, numel(groupSpec.children)); - titleText = char(string(optionValue(groupSpec.props, 'title', ''))); - if strlength(string(titleText)) > 0 - host = uipanel(parentGrid, 'Title', titleText); - else - host = uipanel(parentGrid, 'BorderType', 'none'); - end - host.Layout.Row = row; - host.Layout.Column = [1 2]; - - grid = uigridlayout(host, [childCount 2]); - grid.RowHeight = groupRowHeights(groupSpec.children); - grid.ColumnWidth = {120, '1x'}; - grid.RowSpacing = 6; - grid.ColumnSpacing = 8; - grid.Padding = [0 0 0 0]; - - adapter = baseAdapter(groupSpec, 'group'); - adapter.panel = host; - adapter.grid = grid; - ui.controls.(groupSpec.id) = adapter; - for iChild = 1:numel(groupSpec.children) - ui = buildControl(ui, groupSpec.children{iChild}, grid, iChild, debug); - end -end - -function rowHeight = groupRowHeights(children) - count = max(1, numel(children)); - rowHeight = repmat({'fit'}, 1, count); - for k = 1:numel(children) - rowHeight{k} = layoutRowHeight(children{k}, 'fit'); - end -end - -function maxColumns = actionLayoutMaxColumns(groupSpec) - maxColumns = 2; - labels = actionLabels(groupSpec.children); - if any(strlength(labels) > 28) - maxColumns = 1; - end -end - -function labels = actionLabels(actions) - labels = strings(1, numel(actions)); - for k = 1:numel(actions) - labels(k) = string(optionValue(actions{k}.props, ... - 'label', actions{k}.id)); - 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))); - applyTextFit(button, 'charsPerStep', 18, 'maxShrinkSteps', 3); - 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 = buildFilePanel(ui, fileSpec, parentGrid, row) - callbacks = struct( ... - 'choose', semanticFileChooseCallback(fileSpec.id, ... - optionValue(fileSpec.props, 'onChoose', [])), ... - 'remove', semanticFileRemoveCallback(fileSpec.id, ... - optionValue(fileSpec.props, 'onRemove', [])), ... - 'clear', semanticFileClearCallback(fileSpec.id, ... - optionValue(fileSpec.props, 'onClear', [])), ... - 'selection', semanticFileSelectionCallback(fileSpec.id, ... - optionValue(fileSpec.props, 'onSelectionChange', [])), ... - 'trace', @(source, eventName, reason) traceFilePanelFromSource( ... - fileSpec.id, source, eventName, reason), ... - 'setOriginalCallbackName', @setOriginalCallbackName); - adapter = buildFilePanelControl(fileSpec, parentGrid, row, callbacks); - ui.controls.(fileSpec.id) = adapter; -end - -function ui = buildResultTable(ui, tableSpec, parentGrid, row) - callbacks = struct( ... - 'cellEdit', semanticTableCellEditCallback(tableSpec.id, ... - optionValue(tableSpec.props, 'onCellEdit', [])), ... - 'selection', semanticTableSelectionCallback(tableSpec.id, ... - optionValue(tableSpec.props, 'onSelectionChange', [])), ... - 'setOriginalCallbackName', @setOriginalCallbackName); - adapter = buildResultTableControl(tableSpec, parentGrid, row, callbacks); - ui.controls.(tableSpec.id) = adapter; -end - -function ui = buildLogPanel(ui, logSpec, parentGrid, row, debug) - ui.controls.(logSpec.id) = buildPanelControl('logPanel', logSpec, ... - parentGrid, row, debug); -end - -function ui = buildUsagePanel(ui, usageSpec, parentGrid, row) - ui.controls.(usageSpec.id) = buildPanelControl('usagePanel', usageSpec, ... - parentGrid, row, struct()); -end - -function ui = buildStatusPanel(ui, statusSpec, parentGrid, row) - ui.controls.(statusSpec.id) = buildPanelControl('statusPanel', statusSpec, ... - parentGrid, row, struct()); -end - -function callback = semanticValueCallback(id, appCallback) - if isempty(appCallback) - callback = []; - return; - end - callback = @wrapped; - - function wrapped(source, rawEvent) - ui = currentUiRegistry(source); - if isFigureBusy(ui.figure) - return; - end - control = ui.controls.(id); - event = semanticEvent(control, source, rawEvent, 'user'); - if isfield(control, 'getValue') - event.value = control.getValue(); - end - runSemanticAppCallback(ui, control, event, appCallback, id); - end -end - -function callback = semanticTableCellEditCallback(id, appCallback) - if isempty(appCallback) - callback = []; - return; - end - callback = @wrapped; - - function wrapped(source, rawEvent) - ui = currentUiRegistry(source); - if isFigureBusy(ui.figure) - return; - end - control = ui.controls.(id); - event = semanticEvent(control, source, rawEvent, 'user'); - event.value = source.Data; - event.indices = rawEventValue(rawEvent, 'Indices', []); - event.previousData = rawEventValue(rawEvent, 'PreviousData', []); - event.newData = rawEventValue(rawEvent, 'NewData', []); - event.editData = rawEventValue(rawEvent, 'EditData', []); - runSemanticAppCallback(ui, control, event, appCallback, id); - end -end - -function callback = semanticTableSelectionCallback(id, appCallback) - if isempty(appCallback) - callback = []; - return; - end - callback = @wrapped; - - function wrapped(source, rawEvent) - ui = currentUiRegistry(source); - if isFigureBusy(ui.figure) - return; - end - control = ui.controls.(id); - event = semanticEvent(control, source, rawEvent, 'user'); - event.value = source.Data; - event.indices = rawEventValue(rawEvent, 'Selection', []); - if isempty(event.indices) - event.indices = rawEventValue(rawEvent, 'Indices', []); - end - runSemanticAppCallback( ... - ui, control, event, appCallback, id, "debounced", 0.120); - end -end - -function callback = semanticActionCallback(id, appCallback) - callback = @wrapped; - - function wrapped(source, rawEvent) - ui = currentUiRegistry(source); - if isFigureBusy(ui.figure) - return; - end - control = ui.controls.(id); - event = semanticEvent(control, source, rawEvent, 'user'); - event.action = id; - runSemanticAppCallback(ui, control, event, appCallback, id); - end -end - -function tf = isFigureBusy(fig) - tf = false; - try - tf = isappdata(fig, 'labkitUiBusy') && ... - logical(getappdata(fig, 'labkitUiBusy')); - catch - tf = false; - end -end - -function callback = semanticFileChooseCallback(id, appCallback) - callback = @wrapped; - - function wrapped(source, rawEvent) - ui = currentUiRegistry(source); - if isFigureBusy(ui.figure) - return; - end - control = ui.controls.(id); - traceFilePanelFromSource(id, source, 'choose requested', 'user'); - paths = control.normalizePathList(control.choosePaths( ... - control, fileChooserRequest(source))); - traceFilePanelFromSource(id, source, 'paths selected', ... - sprintf('count=%d', numel(paths))); - if isempty(paths) - return; - end - [control, addedFiles] = control.appendSelection(control, paths); - control = control.setFileSelection(control, addedFiles); - ui.controls.(id) = control; - setappdata(ui.figure, 'labkitUiRegistry', ui); - setControlFileSelection(ui, id, control.currentSelectedFiles()); - traceFilePanelFromSource(id, source, 'selection updated', sprintf( ... - 'total=%d added=%d selected=%d', numel(control.currentFiles()), ... - numel(addedFiles), numel(control.currentSelectedFiles()))); - event = fileEvent(control, source, rawEvent, 'choose'); - event.addedFiles = addedFiles; - traceFilePanelFromSource(id, source, 'callback start', 'action=choose'); - runSemanticAppCallback(ui, control, event, appCallback, id); - traceFilePanelFromSource(id, source, 'callback end', 'action=choose'); - end -end - -function request = fileChooserRequest(source) - request = "file"; - if isempty(source) || ~isprop(source, 'UserData') || isempty(source.UserData) - return; - end - value = string(source.UserData); - if ~isempty(value) && strlength(value(1)) > 0 - request = value(1); - end -end - -function callback = semanticFileRemoveCallback(id, appCallback) - callback = @wrapped; - - function wrapped(source, rawEvent) - ui = currentUiRegistry(source); - if isFigureBusy(ui.figure) - return; - end - control = ui.controls.(id); - [control, removedFiles] = control.removeSelection(control); - ui.controls.(id) = control; - setappdata(ui.figure, 'labkitUiRegistry', ui); - setControlFileSelection(ui, id, control.currentSelectedFiles()); - traceFilePanelFromSource(id, source, 'selection updated', sprintf( ... - 'total=%d removed=%d selected=%d', numel(control.currentFiles()), ... - numel(removedFiles), numel(control.currentSelectedFiles()))); - event = fileEvent(control, source, rawEvent, 'remove'); - event.removedFiles = removedFiles; - traceFilePanelFromSource(id, source, 'callback start', 'action=remove'); - runSemanticAppCallback(ui, control, event, appCallback, id); - traceFilePanelFromSource(id, source, 'callback end', 'action=remove'); - end -end - -function callback = semanticFileClearCallback(id, appCallback) - callback = @wrapped; - - function wrapped(source, rawEvent) - ui = currentUiRegistry(source); - if isFigureBusy(ui.figure) - return; - end - control = ui.controls.(id); - previousFiles = control.currentFiles(); - control = control.applySelection(control, {}, true); - ui.controls.(id) = control; - setappdata(ui.figure, 'labkitUiRegistry', ui); - setControlFileSelection(ui, id, control.currentSelectedFiles()); - traceFilePanelFromSource(id, source, 'selection updated', sprintf( ... - 'total=0 removed=%d selected=0', numel(previousFiles))); - event = fileEvent(control, source, rawEvent, 'clear'); - event.removedFiles = previousFiles; - traceFilePanelFromSource(id, source, 'callback start', 'action=clear'); - runSemanticAppCallback(ui, control, event, appCallback, id); - traceFilePanelFromSource(id, source, 'callback end', 'action=clear'); - end -end - -function callback = semanticFileSelectionCallback(id, appCallback) - callback = @wrapped; - - function wrapped(source, rawEvent) - ui = currentUiRegistry(source); - if isFigureBusy(ui.figure) - return; - end - control = ui.controls.(id); - setControlFileSelection(ui, id, control.currentSelectedFiles()); - selectedFiles = control.currentSelectedFiles(); - traceFilePanelFromSource(id, source, 'selection changed', sprintf( ... - 'selected=%d ids=%s', numel(selectedFiles), ... - char(strjoin(selectedFileIds(selectedFiles), ',')))); - if isempty(appCallback) - return; - end - event = fileEvent(control, source, rawEvent, 'select'); - traceFilePanelFromSource(id, source, 'callback start', 'action=select'); - runSemanticAppCallback(ui, control, event, appCallback, id); - traceFilePanelFromSource(id, source, 'callback end', 'action=select'); - end -end - -function ids = selectedFileIds(files) - ids = strings(0, 1); - if isstruct(files) && isfield(files, 'id') - ids = string({files.id}).'; - end -end - -function event = fileEvent(control, source, rawEvent, action) - event = semanticEvent(control, source, rawEvent, 'user'); - event.action = action; - event.mode = 'filePanel'; - event.files = control.currentFiles(); - event.selectedFiles = control.currentSelectedFiles(); - event.value = event.selectedFiles; -end - -function ui = currentUiRegistry(source) - fig = ancestor(source, 'figure'); - if isempty(fig) || ~isappdata(fig, 'labkitUiRegistry') - error('labkit:ui:runtime:MissingRegistry', ... - 'UI registry appdata was not found on the current figure.'); - end - ui = getappdata(fig, 'labkitUiRegistry'); -end - -function restoreValueChangedCallback(handle, callback) - if ~isempty(handle) && isvalid(handle) - handle.ValueChangedFcn = callback; - end -end - -function value = rawEventValue(rawEvent, propertyName, defaultValue) - value = defaultValue; - if isstruct(rawEvent) && isfield(rawEvent, propertyName) - value = rawEvent.(propertyName); - elseif ~isempty(rawEvent) && isprop(rawEvent, propertyName) - value = rawEvent.(propertyName); - end -end - -function adapter = baseAdapter(spec, kind) - adapter = struct(); - adapter.id = spec.id; - adapter.kind = kind; - adapter.layout = 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 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/+runtime/private/buildControlTabs.m b/+labkit/+ui/+runtime/private/buildControlTabs.m deleted file mode 100644 index 555c3d6ca..000000000 --- a/+labkit/+ui/+runtime/private/buildControlTabs.m +++ /dev/null @@ -1,28 +0,0 @@ -% Private UI runtime helper. Expected caller: labkit.ui.runtime.create. Inputs are the -% current UI registry, validated tab layouts, 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) - tabLayout = tabs{iTab}; - grid = ui.([tabLayout.id 'Grid']); - ui.tabs.(tabLayout.id) = struct('id', tabLayout.id, ... - 'layout', tabLayout, 'grid', grid, ... - 'tab', ui.([tabLayout.id 'Tab'])); - rowMap = logicalRowMap(grid, numel(tabLayout.children)); - for iSection = 1:numel(tabLayout.children) - ui = buildSection(ui, tabLayout.children{iSection}, grid, ... - rowMap(iSection), debug); - end - end -end - -function rowMap = logicalRowMap(grid, rowCount) - rowMap = 1:rowCount; - try - data = grid.UserData; - if isstruct(data) && isfield(data, 'LabKitLogicalRowMap') - rowMap = data.LabKitLogicalRowMap; - end - catch - end -end diff --git a/+labkit/+ui/+runtime/private/buildFilePanelControl.m b/+labkit/+ui/+runtime/private/buildFilePanelControl.m deleted file mode 100644 index 90edc7c0a..000000000 --- a/+labkit/+ui/+runtime/private/buildFilePanelControl.m +++ /dev/null @@ -1,649 +0,0 @@ -% Private UI runtime helper. Expected caller: buildControl filePanel branch. -% Inputs are one validated filePanel spec, parent grid, target row, and -% callback wiring functions. Output is the semantic filePanel adapter. -% Side effects: creates MATLAB UI controls and may open chooser dialogs. -function adapter = buildFilePanelControl(fileSpec, parentGrid, row, callbacks) - props = fileSpec.props; - if isSingleMode(props) - adapter = buildSingleFilePanelControl(fileSpec, parentGrid, row, callbacks); - else - adapter = buildMultiFilePanelControl(fileSpec, parentGrid, row, callbacks); - end -end - -function adapter = buildMultiFilePanelControl(fileSpec, parentGrid, row, callbacks) - props = fileSpec.props; - panel = uipanel(parentGrid, 'Title', optionValue(props, 'label', fileSpec.id)); - panel.Layout.Row = row; - panel.Layout.Column = [1 2]; - parts = createMultiFilePanelParts(panel, props, callbacks); - - adapter = baseFilePanelAdapter(fileSpec, props, panel, parts.grid, callbacks); - adapter.chooseButton = parts.chooseButton; - adapter.folderButton = parts.folderButton; - adapter.recursiveFolderButton = parts.recursiveFolderButton; - adapter.removeButton = parts.removeButton; - adapter.clearButton = parts.clearButton; - adapter.listbox = parts.listbox; - adapter.status = parts.status; - adapter.valueHandle = parts.listbox; - adapter.storageHandle = parts.listbox; - adapter = attachFilePanelMethods(adapter); - - storeFiles(adapter, emptyFiles()); -end - -function adapter = buildSingleFilePanelControl(fileSpec, parentGrid, row, callbacks) - props = fileSpec.props; - panel = uipanel(parentGrid, 'Title', optionValue(props, 'label', fileSpec.id)); - panel.Layout.Row = row; - panel.Layout.Column = [1 2]; - grid = uigridlayout(panel, [2 2]); - grid.RowHeight = {32, '1x'}; - grid.ColumnWidth = {128, '1x'}; - grid.RowSpacing = 0; - grid.ColumnSpacing = 8; - grid.Padding = [8 8 8 8]; - - chooseButton = uibutton(grid, ... - 'Text', optionValue(props, 'chooseLabel', 'Choose...'), ... - 'ButtonPushedFcn', callbacks.choose); - applyTextFit(chooseButton, 'charsPerStep', 18, 'maxShrinkSteps', 3); - callbacks.setOriginalCallbackName(chooseButton, optionValue(props, ... - 'onChoose', [])); - chooseButton.Layout.Row = 1; - chooseButton.Layout.Column = 1; - - displayField = uieditfield(grid, 'text', ... - 'Value', emptyFileText(props), ... - 'Editable', 'off', ... - 'Tag', 'LabKitFilePanelStatusText'); - applyTextFit(displayField); - displayField.Layout.Row = 1; - displayField.Layout.Column = 2; - - adapter = baseFilePanelAdapter(fileSpec, props, panel, grid, callbacks); - adapter.chooseButton = chooseButton; - adapter.status = displayField; - adapter.displayField = displayField; - adapter.valueHandle = displayField; - adapter.storageHandle = displayField; - adapter = attachFilePanelMethods(adapter); - - storeFiles(adapter, emptyFiles()); -end - -function adapter = baseFilePanelAdapter(fileSpec, props, panel, grid, callbacks) - adapter = struct(); - adapter.id = fileSpec.id; - adapter.kind = 'filePanel'; - adapter.layout = fileSpec; - adapter.props = props; - adapter.panel = panel; - adapter.grid = grid; - adapter.trace = optionValue(callbacks, 'trace', []); -end - -function adapter = attachFilePanelMethods(adapter) - adapter.getValue = @() currentSelectedFiles(adapter); - adapter.setValue = @(paths) applyFileSelection(adapter, paths, true); - adapter.currentValue = @() currentSelectedFiles(adapter); - adapter.currentFiles = @() currentFiles(adapter); - adapter.currentSelectedFiles = @() currentSelectedFiles(adapter); - adapter.setFileSelection = @setCurrentFileSelection; - adapter.applySelection = @applyFileSelection; - adapter.appendSelection = @appendFileSelection; - adapter.removeSelection = @removeCurrentSelection; - adapter.choosePaths = @(control, request) chooseFilePaths( ... - currentControl(adapter, control), request); - adapter.normalizePathList = @filePanelNormalizePathList; - adapter.normalizeFiles = @normalizeFiles; -end - -function value = initialListValue(listbox, text) - if strcmp(listbox.Multiselect, 'on') - value = {text}; - else - value = text; - end -end - -function control = applyFileSelection(control, pathsOrFiles, updateStatus) - files = normalizeFiles(pathsOrFiles); - files = enforceMaxFiles(files, control.props); - files = assignFileIds(files, 0); - files = completeFileEntries(files); - storeFiles(control, files); - - emptyText = emptyFileText(control.props); - if isSingleMode(control.props) - if isempty(files) - setText(control.displayField, emptyText); - else - setText(control.displayField, char(files(1).displayName)); - end - return; - end - - labels = fileLabels(files); - if isempty(files) - control.listbox.Items = {emptyText}; - control.listbox.Value = initialListValue(control.listbox, emptyText); - else - control.listbox.Items = labels; - if strcmp(control.listbox.Multiselect, 'on') - control.listbox.Value = labels; - else - control.listbox.Value = labels{1}; - end - end - if updateStatus - setText(control.status, fileStatusText(control.props, files)); - end -end - -function [control, addedFiles] = appendFileSelection(control, pathsOrFiles) - existingFiles = currentFiles(control); - newFiles = normalizeFiles(pathsOrFiles); - if isempty(newFiles) - addedFiles = emptyFiles(); - return; - end - - maxFiles = double(optionValue(control.props, 'maxFiles', Inf)); - if isSingleMode(control.props) || (isfinite(maxFiles) && maxFiles == 1) - combinedFiles = newFiles(1); - addedStart = 1; - elseif isempty(existingFiles) - combinedFiles = newFiles(:); - addedStart = 1; - else - combinedFiles = [existingFiles; newFiles(:)]; - addedStart = numel(existingFiles) + 1; - end - - combinedFiles = enforceMaxFiles(combinedFiles, control.props); - combinedFiles = assignFileIds(combinedFiles, 0); - combinedFiles = completeFileEntries(combinedFiles); - if addedStart > numel(combinedFiles) - addedFiles = emptyFiles(); - else - addedFiles = combinedFiles(addedStart:end); - end - control = applyFileSelection(control, combinedFiles, true); -end - -function control = setCurrentFileSelection(control, filesOrIds) - files = currentFiles(control); - if isempty(files) || isSingleMode(control.props) - return; - end - if isstruct(filesOrIds) - ids = fileIds(filesOrIds); - else - ids = string(filesOrIds); - ids = ids(:); - end - ids = ids(strlength(ids) > 0); - if isempty(ids) - return; - end - labels = fileLabels(files); - selected = labels(ismember(fileIds(files), ids)); - if isempty(selected) - return; - end - if strcmp(control.listbox.Multiselect, 'on') - control.listbox.Value = selected(:).'; - else - control.listbox.Value = selected{1}; - end -end - -function [control, removedFiles] = removeCurrentSelection(control) - files = currentFiles(control); - removedFiles = emptyFiles(); - if isempty(files) - return; - end - if isSingleMode(control.props) - removedFiles = files; - control = applyFileSelection(control, emptyFiles(), true); - return; - end - labels = string(fileLabels(files)); - selected = string(control.listbox.Value); - removeMask = ismember(labels, selected(:)); - removedFiles = files(removeMask); - files = files(~removeMask); - control = applyFileSelection(control, files, true); -end - -function paths = chooseFilePaths(control, request) - props = control.props; - request = string(request); - if (isSingleMode(props) || request == "file") && ... - isfield(props, 'dialogProvider') && ... - isa(props.dialogProvider, 'function_handle') - traceFilePanelControl(control, 'dialog provider start', 'source=custom'); - paths = filePanelNormalizePathList(props.dialogProvider(props)); - traceFilePanelControl(control, 'dialog provider end', sprintf('count=%d', numel(paths))); - if isSingleMode(props) - if ~isempty(paths) - paths = paths(1); - end - else - paths = expandFileChoices(paths, props, control); - end - return; - end - - startPath = defaultDialogFolder("input", ... - optionValue(props, 'startPath', "")); - if isSingleMode(props) - traceFilePanelControl(control, 'file chooser start', 'mode=single'); - paths = chooseFiles(props, startPath); - traceFilePanelControl(control, 'file chooser end', sprintf('count=%d', numel(paths))); - if ~isempty(paths) - paths = paths(1); - end - return; - end - - if request == "folder" - traceFilePanelControl(control, 'folder chooser start', 'mode=direct'); - folder = chooseFolder(startPath, props); - paths = filesDirectlyUnderFolder(string(folder), props); - if ~isempty(paths) && shouldRejectLargeFolder( ... - paths, string(folder), props, control) - paths = strings(0, 1); - end - traceFilePanelControl(control, 'folder chooser end', ... - sprintf('count=%d', numel(paths))); - elseif request == "recursiveFolder" - traceFilePanelControl(control, 'folder chooser start', 'mode=recursive'); - folder = chooseFolder(startPath, props); - paths = expandFileChoices(string(folder), props, control); - traceFilePanelControl(control, 'folder chooser end', ... - sprintf('count=%d', numel(paths))); - else - traceFilePanelControl(control, 'file chooser start', 'mode=multi'); - paths = chooseFiles(props, startPath); - traceFilePanelControl(control, 'file chooser end', ... - sprintf('count=%d', numel(paths))); - end -end - -function paths = chooseFiles(props, startPath) - filters = normalizeFilePanelFilters(optionValue(props, 'filters', {'*.*', 'All files'})); - titleText = optionValue(props, 'dialogTitle', optionValue(props, 'chooseLabel', 'Choose files')); - allowMulti = ~isSingleMode(props) && ... - (isinf(double(optionValue(props, 'maxFiles', Inf))) || ... - double(optionValue(props, 'maxFiles', Inf)) > 1); - 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 = strings(0, 1); - return; - end - rememberDialogFolder(folder); - if ischar(files) || isstring(files) - files = {char(string(files))}; - end - paths = strings(numel(files), 1); - for k = 1:numel(files) - paths(k) = string(fullfile(folder, char(string(files{k})))); - end -end - -function paths = filesDirectlyUnderFolder(folder, props) - paths = strings(0, 1); - if strlength(folder) == 0 || ~isfolder(folder) - return; - end - filters = normalizeFilePanelFilters( ... - optionValue(props, 'filters', {'*.*', 'All files'})); - patterns = filePanelFilterPatterns(filters); - parts = cell(numel(patterns), 1); - for k = 1:numel(patterns) - entries = dir(fullfile(char(folder), char(patterns(k)))); - entries = entries(~[entries.isdir]); - matches = strings(numel(entries), 1); - for iEntry = 1:numel(entries) - matches(iEntry) = string(fullfile( ... - entries(iEntry).folder, entries(iEntry).name)); - end - parts{k} = matches; - end - paths = sort(unique(vertcat(parts{:}), 'stable')); -end - -function folder = chooseFolder(startPath, props) - if isfield(props, 'folderDialogProvider') && ... - isa(props.folderDialogProvider, 'function_handle') - folder = props.folderDialogProvider(props); - else - folder = uigetdir(startPath, 'Choose folder'); - end - if isequal(folder, 0) - folder = strings(0, 1); - else - rememberDialogFolder(folder); - end -end - -function paths = expandFileChoices(paths, props, control) - paths = filePanelNormalizePathList(paths); - if isempty(paths) - return; - end - filters = normalizeFilePanelFilters(optionValue(props, 'filters', {'*.*', 'All files'})); - traceFilePanelControl(control, 'expand choices start', sprintf('count=%d', numel(paths))); - expandedParts = cell(numel(paths), 1); - for k = 1:numel(paths) - path = paths(k); - if isfolder(path) - traceFilePanelControl(control, 'folder scan start', sprintf('index=%d', k)); - folderPaths = filesUnderFolder(path, filters); - traceFilePanelControl(control, 'folder scan end', sprintf( ... - 'index=%d count=%d', k, numel(folderPaths))); - if shouldRejectLargeFolder(folderPaths, path, props, control) - traceFilePanelControl(control, 'folder scan rejected', sprintf( ... - 'index=%d count=%d', k, numel(folderPaths))); - folderPaths = strings(0, 1); - end - expandedParts{k} = folderPaths; - else - expandedParts{k} = path; - end - end - paths = vertcat(expandedParts{:}); - traceFilePanelControl(control, 'expand choices end', sprintf('count=%d', numel(paths))); -end - -function tf = shouldRejectLargeFolder(paths, folder, props, control) - threshold = double(optionValue(props, 'folderWarningThreshold', 500)); - tf = false; - if numel(paths) <= threshold - return; - end - traceFilePanelControl(control, 'large folder prompt', sprintf( ... - 'count=%d threshold=%d', numel(paths), threshold)); - if isfield(props, 'folderWarningProvider') && isa(props.folderWarningProvider, 'function_handle') - tf = ~logical(props.folderWarningProvider(folder, numel(paths), threshold)); - return; - end - message = sprintf(['Recursive scan found %d matching file(s) under:\n%s\n\n' ... - 'Loading a very large folder may take a while. Continue?'], ... - numel(paths), char(folder)); - try - fig = ancestor(control.panel, 'figure'); - answer = uiconfirm(fig, message, 'Large folder scan', ... - 'Options', {'Continue', 'Cancel'}, ... - 'DefaultOption', 'Cancel', ... - 'CancelOption', 'Cancel'); - catch - answer = questdlg(message, 'Large folder scan', ... - 'Continue', 'Cancel', 'Cancel'); - end - tf = ~strcmp(answer, 'Continue'); -end - -function paths = filesUnderFolder(folder, filters) - patterns = filePanelFilterPatterns(filters); - pathParts = cell(numel(patterns), 1); - for k = 1:numel(patterns) - entries = dir(fullfile(char(folder), '**', char(patterns(k)))); - entries = entries(~[entries.isdir]); - patternPaths = strings(numel(entries), 1); - for iEntry = 1:numel(entries) - patternPaths(iEntry) = string(fullfile(entries(iEntry).folder, entries(iEntry).name)); - end - pathParts{k} = patternPaths; - end - paths = vertcat(pathParts{:}); - paths = sort(unique(paths, 'stable')); -end - -function labels = fileLabels(files) - if isempty(files) - labels = {}; - return; - end - paths = string({files.path}).'; - status = strings(numel(files), 1); - for k = 1:numel(files) - if isfield(files(k), 'status') - status(k) = string(files(k).status); - end - end - labels = formatFileLabels(paths, 'status', status); -end - -function files = normalizeFiles(value) - if isempty(value) - files = emptyFiles(); - return; - end - if isstruct(value) && all(isfield(value, {'path'})) - files = value(:); - files = assignFileIds(files, 0); - files = completeFileEntries(files); - return; - end - paths = filePanelNormalizePathList(value); - files = repmat(emptyFile(), numel(paths), 1); - for k = 1:numel(paths) - files(k).path = paths(k); - files(k).status = ""; - end - files = completeFileEntries(files); -end - -function files = completeFileEntries(files) - for k = 1:numel(files) - pathValue = ""; - if isfield(files(k), 'path') - pathValue = filePanelScalarText(files(k).path, ""); - end - files(k).path = pathValue; - [~, base, ext] = fileparts(char(pathValue)); - name = string([base ext]); - if strlength(name) == 0 - name = pathValue; - end - files(k).name = name; - displayName = ""; - if isfield(files(k), 'displayName') - displayName = filePanelScalarText(files(k).displayName, ""); - end - if strlength(displayName) == 0 - files(k).displayName = name; - else - files(k).displayName = displayName; - end - if ~isfield(files(k), 'status') - files(k).status = ""; - else - files(k).status = filePanelScalarText(files(k).status, ""); - end - end -end - -function files = assignFileIds(files, offset) - seen = strings(numel(files), 1); - for k = 1:numel(files) - id = ""; - if isfield(files(k), 'id') - id = filePanelScalarText(files(k).id, ""); - end - usedIds = seen(1:k-1); - if strlength(id) == 0 || any(usedIds == id) - idNumber = offset + k; - candidate = "file" + string(idNumber); - while any(usedIds == candidate) - idNumber = idNumber + 1; - candidate = "file" + string(idNumber); - end - files(k).id = candidate; - end - files(k).index = offset + k; - seen(k) = string(files(k).id); - end -end - -function ids = fileIds(files) - ids = strings(numel(files), 1); - for k = 1:numel(files) - if isfield(files(k), 'id') - ids(k) = string(files(k).id); - end - end -end - -function file = emptyFile() - file = struct('id', "", 'index', 0, 'path', "", ... - 'name', "", 'displayName', "", 'status', ""); -end - -function files = emptyFiles() - files = repmat(emptyFile(), 0, 1); -end - -function files = enforceMaxFiles(files, props) - if isSingleMode(props) - maxFiles = 1; - else - maxFiles = double(optionValue(props, 'maxFiles', Inf)); - end - if isfinite(maxFiles) && numel(files) > maxFiles - files = files(1:maxFiles); - end -end - -function files = currentSelectedFiles(control) - allFiles = currentFiles(control); - files = emptyFiles(); - if isempty(allFiles) - return; - end - if isSingleMode(control.props) - files = allFiles; - return; - end - labels = string(control.listbox.Items(:)); - selected = string(control.listbox.Value); - if numel(labels) ~= numel(allFiles) - labels = string(fileLabels(allFiles)); - end - [matched, idx] = ismember(selected(:), labels); - if any(matched) - files = allFiles(idx(matched)); - end -end - -function files = currentFiles(control) - files = emptyFiles(); - if isfield(control, 'storageHandle') && isvalid(control.storageHandle) - try - files = getappdata(control.storageHandle, 'labkitFilePanelFiles'); - files = files(:); - catch - files = emptyFiles(); - end - end -end - -function storeFiles(control, files) - try - setappdata(control.storageHandle, 'labkitFilePanelFiles', files(:)); - catch - end -end - -function control = currentControl(defaultControl, varargin) - control = defaultControl; - if ~isempty(varargin) - control = varargin{1}; - end -end - -function text = fileStatusText(props, files) - if isempty(files) - text = emptyStatusText(props); - elseif numel(files) == 1 - text = '1 file'; - else - text = sprintf('%d files', numel(files)); - end -end - -function tf = showFilePanelStatus(props) - tf = true; - if isstruct(props) && isfield(props, 'showStatus') - tf = logical(props.showStatus); - end -end - -function text = emptyStatusText(props) - if isstruct(props) && isfield(props, 'status') - text = char(string(props.status)); - else - text = emptyFileText(props); - end -end - -function text = emptyFileText(props) - if isstruct(props) && isfield(props, 'emptyText') - text = char(string(props.emptyText)); - return; - end - if isstruct(props) && isfield(props, 'status') - text = char(string(props.status)); - return; - end - text = 'No files loaded'; -end - -function setText(handle, text) - if isempty(handle) - return; - end - if isprop(handle, 'Text') - handle.Text = char(string(text)); - applyTextFit(handle); - elseif isprop(handle, 'Value') - handle.Value = char(string(text)); - applyTextFit(handle); - end -end - -function value = fileMultiselect(props) - mode = optionValue(props, 'selectionMode', 'single'); - if strcmp(mode, 'multiple') - value = 'on'; - else - value = 'off'; - end -end - -function tf = isSingleMode(props) - tf = strcmp(char(string(optionValue(props, 'mode', 'multi'))), 'single'); -end - -function rememberDialogFolder(folder) - try - setpref('LabKit', 'LastInputFolder', char(string(folder))); - catch - 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/+runtime/private/buildPanelControl.m b/+labkit/+ui/+runtime/private/buildPanelControl.m deleted file mode 100644 index d4d5a86cb..000000000 --- a/+labkit/+ui/+runtime/private/buildPanelControl.m +++ /dev/null @@ -1,192 +0,0 @@ -% Private UI runtime helper. Expected caller: buildControl panel branches. -% Inputs are a panel kind, validated control spec, parent grid, target row, -% and debug context. Output is the semantic panel adapter. -% Side effects: creates MATLAB panel/text controls. -function adapter = buildPanelControl(kind, spec, parentGrid, row, debug) - switch kind - case 'logPanel' - adapter = buildTextPanel(spec, parentGrid, row, {'Ready.'}, kind); - if isDebugEnabled(debug) - debug.attachTextLog(adapter.textArea); - end - case 'usagePanel' - adapter = buildTextPanel(spec, parentGrid, row, {''}, kind); - case 'statusPanel' - adapter = buildTextPanel(spec, parentGrid, row, {''}, kind); - otherwise - error('labkit:ui:runtime:UnsupportedPanelKind', ... - 'Unsupported panel control kind "%s".', kind); - end -end - -function adapter = buildTextPanel(spec, parentGrid, row, defaultValue, kind) - props = spec.props; - panel = uipanel(parentGrid, 'Title', optionValue(props, 'title', spec.id)); - panel.Layout.Row = row; - panel.Layout.Column = [1 2]; - isLogPanel = strcmp(kind, 'logPanel'); - gridRows = 1 + double(isLogPanel); - grid = uigridlayout(panel, [gridRows 1]); - grid.Padding = [8 8 8 8]; - if isLogPanel - grid.RowHeight = {'fit', '1x'}; - followButton = uibutton(grid, 'Text', 'Pause auto-scroll'); - applyTextFit(followButton, 'charsPerStep', 18, 'maxShrinkSteps', 2); - followButton.Layout.Row = 1; - followButton.Layout.Column = 1; - textRow = 2; - else - grid.RowHeight = {'1x'}; - followButton = []; - textRow = 1; - end - textArea = uitextarea(grid, 'Editable', 'off', ... - 'Value', textLines(optionValue(props, 'value', defaultValue))); - applyTextFit(textArea, 'charsPerStep', 48, 'maxShrinkSteps', 1); - textArea.Layout.Row = textRow; - textArea.Layout.Column = 1; - - adapter = baseAdapter(spec, kind); - adapter.panel = panel; - adapter.grid = grid; - adapter.textArea = textArea; - adapter.valueHandle = textArea; - if isLogPanel - adapter.followLatestButton = followButton; - adapter = configureLogFollowLatest(adapter); - end -end - -function adapter = configureLogFollowLatest(adapter) - textArea = adapter.textArea; - button = adapter.followLatestButton; - setappdata(textArea, logFollowKey(), true); - setappdata(textArea, logFollowMenuKey(), []); - setappdata(textArea, logFollowButtonKey(), button); - button.ButtonPushedFcn = @(src, ~) toggleLogFollowLatest(textArea, [], src); - try - fig = ancestor(textArea, 'figure'); - menu = uicontextmenu(fig); - item = uimenu(menu, 'Text', 'Pause auto-scroll', ... - 'Checked', 'on', ... - 'MenuSelectedFcn', @(src, ~) toggleLogFollowLatest(textArea, src, [])); - textArea.ContextMenu = menu; - setappdata(textArea, logFollowMenuKey(), item); - adapter.followLatestMenu = item; - catch - adapter.followLatestMenu = []; - end - scrollLogToBottom(textArea); -end - -function toggleLogFollowLatest(textArea, menuItem, button) - enabled = ~logFollowLatest(textArea); - setLogFollowLatest(textArea, enabled); - updateLogFollowControls(textArea, menuItem, button); - if enabled - scrollLogToBottom(textArea); - end -end - -function setLogFollowLatest(textArea, enabled) - setappdata(textArea, logFollowKey(), logical(enabled)); -end - -function enabled = logFollowLatest(textArea) - enabled = true; - try - if isappdata(textArea, logFollowKey()) - enabled = logical(getappdata(textArea, logFollowKey())); - end - catch - enabled = true; - end -end - -function updateLogFollowControls(textArea, menuItem, button) - if nargin < 2 || isempty(menuItem) - menuItem = []; - try - if isappdata(textArea, logFollowMenuKey()) - menuItem = getappdata(textArea, logFollowMenuKey()); - end - catch - menuItem = []; - end - end - if nargin < 3 || isempty(button) - button = []; - try - if isappdata(textArea, logFollowButtonKey()) - button = getappdata(textArea, logFollowButtonKey()); - end - catch - button = []; - end - end - if logFollowLatest(textArea) - label = 'Pause auto-scroll'; - checked = 'on'; - else - label = 'Follow latest'; - checked = 'off'; - end - if ~isempty(menuItem) && isvalid(menuItem) - menuItem.Text = label; - menuItem.Checked = checked; - end - if ~isempty(button) && isvalid(button) - button.Text = label; - end -end - -function scrollLogToBottom(textArea) - try - scroll(textArea, 'bottom'); - catch - end -end - -function key = logFollowKey() - key = 'labkitLogFollowLatest'; -end - -function key = logFollowMenuKey() - key = 'labkitLogFollowLatestMenu'; -end - -function key = logFollowButtonKey() - key = 'labkitLogFollowLatestButton'; -end - -function adapter = baseAdapter(spec, kind) - adapter = struct(); - adapter.id = spec.id; - adapter.kind = kind; - adapter.layout = spec; - adapter.props = spec.props; -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 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/+runtime/private/buildPannerControl.m b/+labkit/+ui/+runtime/private/buildPannerControl.m deleted file mode 100644 index 60bfc3dc2..000000000 --- a/+labkit/+ui/+runtime/private/buildPannerControl.m +++ /dev/null @@ -1,282 +0,0 @@ -% Private UI runtime helper. Expected caller: buildControl panner branch. -% Inputs are one panner spec, parent grid, and target row. Output is the -% semantic panner adapter. Side effects: creates spinner/slider controls and -% wires debounced semantic callbacks. -function adapter = buildPannerControl(pannerSpec, parentGrid, row) - props = pannerSpec.props; - labelText = optionValue(props, 'label', pannerSpec.id); - enabled = optionValue(props, 'enabled', true); - - label = uilabel(parentGrid, 'Text', labelText, ... - 'HorizontalAlignment', 'right'); - applyTextFit(label); - label.Layout.Row = row; - label.Layout.Column = 1; - - hasSlider = pannerHasFiniteSlider(props); - if hasSlider - grid = uigridlayout(parentGrid, [1 2]); - grid.ColumnWidth = {76, '1x'}; - else - grid = uigridlayout(parentGrid, [1 1]); - grid.ColumnWidth = {'1x'}; - end - grid.Padding = [0 0 0 0]; - grid.ColumnSpacing = 6; - grid.Layout.Row = row; - grid.Layout.Column = 2; - - valueSpinner = uispinner(grid, 'Enable', onOff(enabled)); - valueSpinner.Layout.Row = 1; - valueSpinner.Layout.Column = 1; - applyCommonValueProps(valueSpinner, props); - valueSpinner.Value = clampNumericValue(valueSpinner.Value, valueSpinner.Limits); - applyPannerSpinnerStep(valueSpinner, props); - - slider = []; - if hasSlider - slider = uislider(grid, 'Enable', onOff(enabled)); - slider.Layout.Row = 1; - slider.Layout.Column = 2; - applyCommonValueProps(slider, props); - applySliderTicks(slider, props); - slider.Value = clampNumericValue(slider.Value, slider.Limits); - syncPannerValue(slider, valueSpinner, slider.Value); - end - - adapter = basePannerAdapter(pannerSpec); - adapter.label = label; - adapter.grid = grid; - adapter.valueSpinner = valueSpinner; - adapter.slider = slider; - adapter.handle = valueSpinner; - adapter.valueHandle = valueSpinner; - adapter.getValue = @() valueSpinner.Value; - adapter.setValue = @(value) setPannerValue(slider, valueSpinner, value); - - appCallback = optionValue(props, 'onChange', []); - if hasSlider - slider.ValueChangedFcn = semanticPannerSliderCallback(pannerSpec.id, appCallback); - if isprop(slider, 'ValueChangingFcn') - slider.ValueChangingFcn = semanticPannerSliderChangingCallback( ... - pannerSpec.id, appCallback); - end - setOriginalCallbackName(slider, appCallback); - end - valueSpinner.ValueChangedFcn = semanticPannerSpinnerCallback( ... - pannerSpec.id, appCallback); - setOriginalCallbackName(valueSpinner, appCallback); -end - -function adapter = basePannerAdapter(spec) - adapter = struct(); - adapter.id = spec.id; - adapter.kind = 'panner'; - adapter.layout = spec; - adapter.props = spec.props; -end - -function tf = pannerHasFiniteSlider(props) - tf = isfield(props, 'limits') && numel(props.limits) == 2 && ... - all(isfinite(double(props.limits))); -end - -function applySliderTicks(control, props) - if isempty(control) || ~contains(class(control), 'Slider') || ... - ~isfield(props, 'showTicks') || logical(props.showTicks) - return; - end - if isprop(control, 'MajorTicks') - control.MajorTicks = []; - end - if isprop(control, 'MinorTicks') - control.MinorTicks = []; - end -end - -function setPannerValue(slider, valueSpinner, value) - value = clampNumericValue(value, valueSpinner.Limits); - sliderValueMatches = isempty(slider) || isequaln(slider.Value, value); - if sliderValueMatches && isequaln(valueSpinner.Value, value) - return; - end - sliderCallback = []; - if ~isempty(slider) - sliderCallback = slider.ValueChangedFcn; - end - spinnerCallback = valueSpinner.ValueChangedFcn; - cleanupObj = onCleanup(@() restorePannerCallbacks( ... - slider, sliderCallback, valueSpinner, spinnerCallback)); - if ~isempty(slider) - slider.ValueChangedFcn = []; - end - valueSpinner.ValueChangedFcn = []; - syncPannerValue(slider, valueSpinner, value); - clear cleanupObj; -end - -function syncPannerValue(slider, valueSpinner, value) - value = clampNumericValue(value, valueSpinner.Limits); - if ~isempty(slider) - slider.Value = clampNumericValue(value, slider.Limits); - end - valueSpinner.Value = value; -end - -function applyPannerSpinnerStep(valueSpinner, props) - if isfield(props, 'step') - return; - end - if ~all(isfinite(double(valueSpinner.Limits))) - return; - end - valueSpinner.Step = pannerStepAmount(props, valueSpinner.Limits); -end - -function restorePannerCallbacks(slider, sliderCallback, valueSpinner, spinnerCallback) - restoreValueChangedCallback(slider, sliderCallback); - restoreValueChangedCallback(valueSpinner, spinnerCallback); -end - -function callback = semanticPannerSliderCallback(id, appCallback) - callback = @wrapped; - - function wrapped(source, rawEvent) - ui = currentUiRegistry(source); - if isFigureBusy(ui.figure) - return; - end - control = ui.controls.(id); - control.setValue(source.Value); - if isempty(appCallback) - return; - end - event = semanticEvent(control, source, rawEvent, 'user'); - event.value = control.getValue(); - event.action = 'slide'; - runSemanticAppCallback(ui, control, event, appCallback, id); - end -end - -function callback = semanticPannerSliderChangingCallback(id, appCallback) - callback = @wrapped; - - function wrapped(source, rawEvent) - ui = currentUiRegistry(source); - if isFigureBusy(ui.figure) - return; - end - control = ui.controls.(id); - nextValue = rawEventValue(rawEvent, 'Value', source.Value); - control.setValue(nextValue); - if isempty(appCallback) - return; - end - event = semanticEvent(control, source, rawEvent, 'user'); - event.value = control.getValue(); - event.action = 'slide'; - runSemanticAppCallback(ui, control, event, appCallback, id); - end -end - -function callback = semanticPannerSpinnerCallback(id, appCallback) - callback = @wrapped; - - function wrapped(source, rawEvent) - ui = currentUiRegistry(source); - if isFigureBusy(ui.figure) - return; - end - control = ui.controls.(id); - previousValue = rawEventValue(rawEvent, 'PreviousValue', []); - control.setValue(source.Value); - if isempty(appCallback) || isequaln(previousValue, control.getValue()) - return; - end - event = semanticEvent(control, source, rawEvent, 'user'); - event.value = control.getValue(); - event.previousValue = previousValue; - event.action = 'edit'; - runSemanticAppCallback(ui, control, event, appCallback, id); - end -end - -function step = pannerStepAmount(props, limits) - limits = double(limits); - if ~all(isfinite(limits)) - step = 1; - return; - end - span = max(eps, diff(limits)); - step = optionValue(props, 'step', NaN); - if ~isfinite(step) || step <= 0 - fraction = optionValue(props, 'stepFraction', 0.002); - step = span .* max(eps, double(fraction)); - end - if isfield(props, 'minStep') - step = max(step, double(props.minStep)); - end - if isfield(props, 'maxStep') - step = min(step, double(props.maxStep)); - end -end - -function value = clampNumericValue(value, limits) - value = double(value); - if ~isfinite(value) - value = limits(1); - end - value = min(limits(2), max(limits(1), value)); -end - -function restoreValueChangedCallback(handle, callback) - if ~isempty(handle) && isvalid(handle) - handle.ValueChangedFcn = callback; - end -end - -function ui = currentUiRegistry(source) - fig = ancestor(source, 'figure'); - ui = getappdata(fig, 'labkitUiRegistry'); -end - -function tf = isFigureBusy(fig) - tf = false; - try - tf = isappdata(fig, 'labkitUiBusyDepth') && ... - getappdata(fig, 'labkitUiBusyDepth') > 0; - catch - tf = false; - end -end - -function value = rawEventValue(rawEvent, propertyName, defaultValue) - value = defaultValue; - if nargin < 3 - defaultValue = []; - end - if isstruct(rawEvent) && isfield(rawEvent, propertyName) - value = rawEvent.(propertyName); - elseif isobject(rawEvent) && isprop(rawEvent, propertyName) - value = rawEvent.(propertyName); - end -end - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isstruct(opts) && isfield(opts, name) - value = opts.(name); - 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/+runtime/private/buildResultTableControl.m b/+labkit/+ui/+runtime/private/buildResultTableControl.m deleted file mode 100644 index e86b38bc5..000000000 --- a/+labkit/+ui/+runtime/private/buildResultTableControl.m +++ /dev/null @@ -1,118 +0,0 @@ -% Private UI runtime helper. Expected caller: buildControl resultTable branch. -% Inputs are one validated resultTable spec, parent grid, target row, and -% semantic callback wiring. Output is the resultTable adapter. -% Side effects: creates MATLAB UI table controls. -function adapter = buildResultTableControl(tableSpec, parentGrid, row, callbacks) - 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, ... - 'RowName', optionValue(props, 'rowName', {}), ... - 'Data', tableDataForUi(optionValue(props, 'data', ... - cell(0, numel(columns))))); - if isfield(props, 'columnEditable') - table.ColumnEditable = props.columnEditable; - end - if isfield(props, 'columnFormat') - table.ColumnFormat = props.columnFormat; - end - - table.CellEditCallback = callbacks.cellEdit; - callbacks.setOriginalCallbackName(table, optionValue(props, 'onCellEdit', [])); - if isprop(table, 'SelectionChangedFcn') - table.SelectionChangedFcn = callbacks.selection; - else - table.CellSelectionCallback = callbacks.selection; - end - table.Layout.Row = 1; - table.Layout.Column = 1; - - adapter = struct(); - adapter.id = tableSpec.id; - adapter.kind = 'resultTable'; - adapter.layout = tableSpec; - adapter.props = props; - adapter.panel = panel; - adapter.grid = grid; - adapter.table = table; - adapter.valueHandle = table; - adapter.getValue = @() table.Data; - adapter.setValue = @(value) setTableData(table, value); -end - -function setTableData(table, value) - value = tableDataForUi(value); - if isequaln(table.Data, value) - return; - end - callback = table.CellEditCallback; - cleanupObj = onCleanup(@() restoreTableEditCallback(table, callback)); - table.CellEditCallback = []; - table.Data = value; - clear cleanupObj; -end - -function restoreTableEditCallback(handle, callback) - if ~isempty(handle) && isvalid(handle) - handle.CellEditCallback = callback; - end -end - -function data = tableDataForUi(data) - if istable(data) || isnumeric(data) || islogical(data) - return; - end - if isempty(data) - return; - end - if isstring(data) - data = cellstr(data); - end - if ~iscell(data) - data = cellstr(string(data)); - end - for k = 1:numel(data) - data{k} = tableCellValueForUi(data{k}); - end -end - -function value = tableCellValueForUi(value) - if isempty(value) - return; - end - try - if isscalar(value) && ismissing(value) - value = ''; - return; - end - catch - end - if isnumeric(value) || islogical(value) || ischar(value) - return; - end - if isstring(value) - if isscalar(value) - value = char(value); - else - value = char(strjoin(value(:).', ", ")); - end - return; - end - if iscell(value) - value = char(strjoin(string(value(:)).', ", ")); - else - value = 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/+runtime/private/buildRuntimeWorkbench.m b/+labkit/+ui/+runtime/private/buildRuntimeWorkbench.m deleted file mode 100644 index de4358939..000000000 --- a/+labkit/+ui/+runtime/private/buildRuntimeWorkbench.m +++ /dev/null @@ -1,30 +0,0 @@ -% Private UI runtime helper. Expected callers are labkit.ui.runtime.create and -% runV2App. Inputs are one declarative workbench layout and an optional debug -% context. Output is the fully constructed UI registry with startup readiness -% still active. Side effects create and instrument the app figure; the caller -% must finish startup only after its own initial presentation is ready. -function ui = buildRuntimeWorkbench(layout, debug, progressReporter) - if nargin < 3 - progressReporter = []; - end - validateWorkbenchLayout(layout); - ui = buildShellFromLayout(layout, debug); - startupLifecycle(ui.figure, 'start', ui, "Building controls...", ... - progressReporter); - installCloseGuard(ui.figure); - ui = buildControlTabs(ui, layout.props.controlTabs, debug); - startupLifecycle(ui.figure, 'update', "Preparing workspace..."); - ui = buildWorkspace(ui, layout.props.workspace, debug); - startupLifecycle(ui.figure, 'update', "Preparing runtime..."); - - if isDebugEnabled(debug) && isfield(debug, 'instrumentFigure') - debug.instrumentFigure(ui.figure); - end - setappdata(ui.figure, 'labkitUiRegistry', ui); - setappdata(ui.figure, 'labkitUiDebugContext', debug); -end - -function tf = isDebugEnabled(debugContext) - tf = isstruct(debugContext) && isfield(debugContext, 'enabled') && ... - logical(debugContext.enabled); -end diff --git a/+labkit/+ui/+runtime/private/buildSection.m b/+labkit/+ui/+runtime/private/buildSection.m deleted file mode 100644 index 971840f80..000000000 --- a/+labkit/+ui/+runtime/private/buildSection.m +++ /dev/null @@ -1,85 +0,0 @@ -% Private UI runtime 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)); - panelArgs = {}; - if sectionDrawsOwnTitle(sectionSpec) - panelArgs = {'Title', optionValue(sectionSpec.props, 'title', sectionSpec.id)}; - else - panelArgs = {'BorderType', 'none'}; - end - panel = uipanel(parentGrid, panelArgs{:}); - panel.Layout.Row = row; - panel.Layout.Column = 1; - - grid = uigridlayout(panel, [childCount 2]); - grid.RowHeight = sectionRowHeights(sectionSpec.children); - grid.ColumnWidth = {145, '1x'}; - grid.RowSpacing = 8; - grid.ColumnSpacing = 8; - grid.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 tf = sectionDrawsOwnTitle(sectionSpec) - tf = true; - if numel(sectionSpec.children) ~= 1 - return; - end - child = sectionSpec.children{1}; - tf = ~ismember(child.kind, ... - {'previewArea', 'resultTable', 'logPanel', 'statusPanel', ... - 'usagePanel', 'filePanel'}); -end - -function rowHeight = sectionRowHeights(children) - count = max(1, numel(children)); - rowHeight = repmat({'fit'}, 1, count); - if numel(children) == 1 && isGrowableSectionChild(children{1}) - rowHeight{1} = '1x'; - return; - end - for k = 1:numel(children) - if isGrowableSectionChild(children{k}) - rowHeight{k} = '1x'; - else - rowHeight{k} = layoutRowHeight(children{k}, 'fit'); - end - end -end - -function tf = isGrowableSectionChild(child) - if strcmp(child.kind, 'filePanel') - tf = strcmp(char(string(optionValue(child.props, 'mode', 'multi'))), 'multi'); - return; - end - tf = ismember(child.kind, ... - {'previewArea', 'resultTable', 'logPanel', 'statusPanel', ... - 'usagePanel'}); -end - -function adapter = baseAdapter(spec, kind) - adapter = struct(); - adapter.id = spec.id; - adapter.kind = kind; - adapter.layout = 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/+runtime/private/buildShellFromLayout.m b/+labkit/+ui/+runtime/private/buildShellFromLayout.m deleted file mode 100644 index e4b52e5ff..000000000 --- a/+labkit/+ui/+runtime/private/buildShellFromLayout.m +++ /dev/null @@ -1,110 +0,0 @@ -% Private UI runtime helper. Expected caller: labkit.ui.runtime.create. Inputs are a -% validated workbench layout and optional debug context. Output is the initial UI -% registry shell before controls are populated. -function ui = buildShellFromLayout(layout, debug) - tabs = layout.props.controlTabs; - workspaceLayout = layout.props.workspace; - workspaceChildren = workspaceLayout.children; - - shell = createTabbedWorkbenchShell( ... - optionValue(layout.props, 'title', layout.id), ... - [80 60 1500 900], ... - 420, ... - struct('controlsPanel', 'Controls', ... - 'rightPanel', optionValue(workspaceLayout.props, 'title', 'Workspace')), ... - tabShellLayouts(tabs), ... - workspaceGridSize(workspaceChildren), ... - workspaceRowHeights(workspaceChildren), ... - 8, ... - debug, ... - optionValue(layout.props, 'utilities', struct())); - - ui = shell; - ui.figure = shell.fig; - ui.layout = layout; - ui.debug = debug; - ui.controls = struct(); - ui.sections = struct(); - ui.tabs = struct(); - ui.workspace = struct('id', workspaceLayout.id, ... - 'layout', workspaceLayout, 'grid', shell.rightGrid); -end - -function layouts = tabShellLayouts(tabs) - layouts = repmat(struct( ... - 'key', '', 'title', '', 'gridSize', [1 1], ... - 'rowHeight', {{'fit'}}, 'columnWidth', {{'1x'}}, ... - 'resize', 'betweenRows'), 1, numel(tabs)); - for k = 1:numel(tabs) - tabLayout = tabs{k}; - rowCount = max(1, numel(tabLayout.children)); - layouts(k).key = tabLayout.id; - layouts(k).title = optionValue(tabLayout.props, 'title', tabLayout.id); - layouts(k).gridSize = [rowCount 1]; - layouts(k).rowHeight = tabRowHeights(tabLayout.children); - layouts(k).columnWidth = {'1x'}; - layouts(k).resize = optionValue(tabLayout.props, 'resize', 'betweenRows'); - end -end - -function rowHeight = tabRowHeights(children) - count = max(1, numel(children)); - rowHeight = repmat({'fit'}, 1, count); - if numel(children) == 1 && isGrowableTabChild(children{1}) - rowHeight{1} = '1x'; - return; - end - for k = 1:numel(children) - rowHeight{k} = layoutRowHeight(children{k}, 'fit'); - end -end - -function tf = isGrowableTabChild(child) - if strcmp(child.kind, 'section') - tf = numel(child.children) == 1 && isGrowableTabChild(child.children{1}); - return; - end - if strcmp(child.kind, 'filePanel') - tf = strcmp(char(string(optionValue(child.props, 'mode', 'multi'))), 'multi'); - return; - end - tf = ismember(child.kind, ... - {'previewArea', 'resultTable', 'logPanel', 'statusPanel', ... - 'usagePanel'}); -end - -function rowHeight = workspaceRowHeights(children) - if workspaceUsesTabs(children) - rowHeight = {'1x'}; - return; - end - count = max(1, numel(children)); - rowHeight = repmat({'1x'}, 1, count); - for k = 1:numel(children) - rowHeight{k} = workspaceRowHeight(children{k}); - end -end - -function value = workspaceGridSize(children) - if workspaceUsesTabs(children) - value = [1 1]; - else - value = [max(1, numel(children)) 1]; - end -end - -function tf = workspaceUsesTabs(children) - tf = numel(children) >= 2 && ... - all(cellfun(@(child) strcmp(child.kind, 'tab'), children)); -end - -function value = workspaceRowHeight(~) - value = '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/+runtime/private/buildV2RuntimeServices.m b/+labkit/+ui/+runtime/private/buildV2RuntimeServices.m deleted file mode 100644 index 168a75c29..000000000 --- a/+labkit/+ui/+runtime/private/buildV2RuntimeServices.m +++ /dev/null @@ -1,362 +0,0 @@ -% Private UI runtime service factory. Expected caller: runV2App. Inputs are -% the current figure/runtime and the queue dispatch callback. Output exposes -% app-neutral event, dialog, source, result, and resource mechanics to V2 -% handlers without growing the public labkit.ui surface. -function services = buildV2RuntimeServices(fig, runtime, dispatch) - services = struct(); - services.figure = fig; - services.debug = runtime.debug; - services.request = runtime.request; - services.dispatch = dispatch; - services.workflow = struct( ... - "log", @(state, message) appendWorkflowLog( ... - state, runtime.debug, message)); - services.diagnostics = struct( ... - "report", @(context, exception) reportException( ... - runtime.debug, runtime.definition.id, context, exception)); - services.events = struct( ... - "entries", @eventEntries, ... - "paths", @eventPaths, ... - "indices", @eventIndices); - services.dialogs = struct( ... - "alert", @(message, titleText) showAlert( ... - fig, message, titleText), ... - "defaultFolder", @defaultDialogFolder, ... - "defaultOutputFolder", @labkit.ui.runtime.defaultOutputFolder, ... - "inputFile", @(filter, titleText, startPath) inputFile( ... - runtime.request, filter, titleText, startPath), ... - "inputFolder", @(titleText, defaultPath) inputFolder( ... - runtime.request, titleText, defaultPath), ... - "outputFile", @(filter, titleText, defaultPath) outputFile( ... - runtime.request, filter, titleText, defaultPath), ... - "outputFolder", @(titleText, defaultPath) outputFolder( ... - runtime.request, titleText, defaultPath), ... - "choice", @(message, titleText, options, defaultOption, cancelOption) ... - choiceDialog(runtime.request, fig, message, titleText, ... - options, defaultOption, cancelOption)); - services.project = struct( ... - "newState", @() createV2State(runtime.definition), ... - "sourceRecord", @labkit.ui.runtime.sourceRecord, ... - "upsertSource", @upsertSource, ... - "reconcileSources", @reconcileSources, ... - "saveState", @() saveProjectState(fig, runtime.request), ... - "saveAutosave", @(state, varargin) ... - saveAutosaveState(fig, state, varargin{:})); - services.previews = struct( ... - "axes", @(previewId, axisId) resolvePreviewAxes( ... - runtime.ui, previewId, axisId)); - services.resources = struct( ... - "set", @(scope, id, value, varargin) setResource( ... - fig, scope, id, value, varargin{:}), ... - "get", @(scope, id) v2ResourceRegistry(fig, "get", scope, id), ... - "remove", @(scope, id) v2ResourceRegistry(fig, "remove", scope, id), ... - "clearScope", @(scope) v2ResourceRegistry(fig, "clearScope", scope)); - services.results = struct( ... - "emptyOutputs", @emptyResultOutputs, ... - "output", @resultOutput, ... - "writeManifest", @(folder, spec) writeResultManifest( ... - fig, folder, spec)); -end - -function setResource(fig, scope, id, value, varargin) - v2ResourceRegistry(fig, "set", scope, id, value, varargin{:}); -end - -function outputs = emptyResultOutputs() - prototype = struct( ... - "Id", "", ... - "Role", "", ... - "Path", "", ... - "MediaType", "", ... - "Status", "success", ... - "Message", ""); - outputs = repmat(prototype, 0, 1); -end - -function answer = choiceDialog(request, fig, message, titleText, ... - options, defaultOption, cancelOption) - options = string(options(:)).'; - if isstruct(request) && isfield(request, 'choiceDialog') && ... - isa(request.choiceDialog, 'function_handle') - answer = string(request.choiceDialog(message, titleText, options, ... - defaultOption, cancelOption)); - return; - end - answer = string(uiconfirm(fig, char(string(message)), ... - char(string(titleText)), ... - 'Options', cellstr(options), ... - 'DefaultOption', char(string(defaultOption)), ... - 'CancelOption', char(string(cancelOption)))); -end - -function filepath = saveProjectState(fig, request) - filepath = ""; - if isstruct(request) && isfield(request, 'projectStateFile') - filepath = string(request.projectStateFile); - elseif isappdata(fig, 'labkitUiUtilityStateFile') - filepath = string(getappdata(fig, 'labkitUiUtilityStateFile')); - end - if strlength(filepath) == 0 - filepath = labkit.ui.runtime.saveState(fig); - else - filepath = labkit.ui.runtime.saveState(fig, filepath); - end -end - -function filepath = saveAutosaveState(fig, state, filepath) - current = getAppRuntime(fig); - current.state = state; - if nargin < 3 || strlength(string(filepath)) == 0 - filepath = writeV2RecoveryFile(current); - else - filepath = string(filepath); - folder = string(fileparts(filepath)); - if strlength(folder) > 0 && ~isfolder(folder) - mkdir(folder); - end - writeV2ProjectFile(filepath, ... - createV2ProjectEnvelope(current, [], filepath)); - end - setappdata(fig, 'labkitV2RecoveryFile', string(filepath)); -end - -function state = appendWorkflowLog(state, debugLog, message) - line = string(message); - if ~isscalar(line) - line = join(line(:), newline); - end - if ~isfield(state.session.workflow, 'logLines') - state.session.workflow.logLines = strings(0, 1); - end - state.session.workflow.logLines(end + 1, 1) = line; - if isfield(state.session.workflow, 'statusMessage') - state.session.workflow.statusMessage = line; - end - if isDebugEnabled(debugLog) && isfield(debugLog, 'append') - debugLog.append(char(line)); - end -end - -function reportException(debugLog, appId, context, exception) - if isstruct(debugLog) && isfield(debugLog, 'reportException') - debugLog.reportException(char(string(appId)), ... - char(string(context)), exception); - end -end - -function tf = isDebugEnabled(debugLog) - tf = isstruct(debugLog) && isfield(debugLog, 'enabled') && ... - logical(debugLog.enabled); -end - -function values = eventEntries(event, fieldName) - values = []; - if isstruct(event) && isfield(event, 'meta') && ... - isstruct(event.meta) && isfield(event.meta, 'original') && ... - isstruct(event.meta.original) && ... - isfield(event.meta.original, char(fieldName)) - values = event.meta.original.(char(fieldName)); - end -end - -function paths = eventPaths(event, fieldName) - values = eventEntries(event, fieldName); - if isstruct(values) && isfield(values, 'path') - paths = string({values.path}).'; - else - paths = string(values(:)); - end - paths = paths(strlength(paths) > 0); -end - -function indices = eventIndices(event, fieldName, count) - values = eventEntries(event, fieldName); - indices = zeros(0, 1); - if isstruct(values) && isfield(values, 'id') - ids = string({values.id}); - parsed = str2double(extractAfter(ids, "item")); - valid = startsWith(ids, "item") & isfinite(parsed) & ... - parsed == fix(parsed); - indices = parsed(valid).'; - end - if isempty(indices) && isstruct(values) && isfield(values, 'path') - allValues = eventEntries(event, "files"); - if isstruct(allValues) && isfield(allValues, 'path') - [~, indices] = ismember(string({values.path}), ... - string({allValues.path})); - end - end - indices = unique(indices(indices >= 1 & indices <= count), 'stable'); -end - -function [pathValue, cancelled] = inputFile( ... - request, filter, titleText, startPath) - chooser = @uigetfile; - args = chooserArgs(request, ["inputFileChooser", "inputChooser"]); - if ~isempty(args) - chooser = args{2}; - end - [file, folder] = chooser(filter, titleText, startPath); - cancelled = isequal(file, 0) || isequal(folder, 0); - if cancelled - pathValue = ""; - else - pathValue = string(fullfile(folder, file)); - end -end - -function [pathValue, cancelled] = outputFile( ... - request, filter, titleText, defaultPath) - args = chooserArgs(request, ["outputFileChooser", "outputChooser"]); - [pathValue, cancelled] = promptOutputFile( ... - filter, titleText, defaultPath, args{:}); -end - -function [folder, cancelled] = inputFolder(request, titleText, defaultPath) - chooser = @uigetdir; - args = chooserArgs(request, "inputFolderChooser"); - if ~isempty(args) - chooser = args{2}; - end - startFolder = defaultDialogFolder("input", defaultPath); - rawFolder = chooser(startFolder, char(string(titleText))); - cancelled = isequal(rawFolder, 0); - if cancelled - folder = ""; - else - folder = string(rawFolder); - end -end - -function [folder, cancelled] = outputFolder(request, titleText, defaultPath) - args = chooserArgs(request, "outputFolderChooser"); - [folder, cancelled] = promptOutputFolder( ... - titleText, defaultPath, args{:}); -end - -function args = chooserArgs(request, names) - args = {}; - if ~isstruct(request) || ~isscalar(request) - return; - end - for name = string(names) - field = char(name); - if isfield(request, field) && isa(request.(field), 'function_handle') - args = {'Chooser', request.(field)}; - return; - end - end -end - -function sources = upsertSource(sources, id, role, filepath, required) - if nargin < 5 - required = true; - end - validateSourceRecords(sources); - source = labkit.ui.runtime.sourceRecord( ... - id, role, filepath, required); - if isempty(sources) - sources = source; - return; - end - match = find(string({sources.id}) == source.id, 1, 'first'); - if isempty(match) - sources(end + 1) = source; - else - sources(match) = source; - end -end - -function sources = reconcileSources(existing, paths, role, idPrefix, required) - if nargin < 5 - required = true; - end - validateSourceRecords(existing); - idPrefix = string(idPrefix); - if ~isscalar(idPrefix) || strlength(idPrefix) == 0 - error('labkit:ui:runtime:InvalidSourceRecords', ... - 'Source reconciliation idPrefix must be nonempty scalar text.'); - end - paths = string(paths(:)); - sources = labkit.ui.runtime.emptySourceRecords(); - if isempty(paths) - return; - end - prototype = labkit.ui.runtime.sourceRecord( ... - idPrefix + "-1", role, paths(1), required); - sources(numel(paths), 1) = prototype; - for k = 1:numel(paths) - match = sourceIndexForPath(existing, paths(k)); - if isempty(match) - id = nextSourceId(existing, sources(1:k-1), idPrefix); - source = labkit.ui.runtime.sourceRecord( ... - id, role, paths(k), required); - else - source = existing(match); - end - sources(k, 1) = source; - end -end - -function index = sourceIndexForPath(sources, filepath) - index = []; - for k = 1:numel(sources) - if string(sources(k).reference.originalPath) == string(filepath) - index = k; - return; - end - end -end - -function id = nextSourceId(existing, added, prefix) - ids = [string({existing.id}), string({added.id})]; - number = numel(existing) + numel(added) + 1; - id = string(prefix) + "-" + string(number); - while any(ids == id) - number = number + 1; - id = string(prefix) + "-" + string(number); - end -end - -function output = resultOutput(id, role, pathValue, mediaType, status, message) - if nargin < 5 - status = "success"; - end - if nargin < 6 - message = ""; - end - if ~(ischar(id) || (isstring(id) && isscalar(id))) - error('labkit:ui:runtime:InvalidResultManifest', ... - 'Result output id must be nonempty scalar text.'); - end - id = string(id); - if ~isscalar(id) || strlength(id) == 0 - error('labkit:ui:runtime:InvalidResultManifest', ... - 'Result output id must be nonempty scalar text.'); - end - output = struct( ... - "Id", id, ... - "Role", string(role), ... - "Path", string(pathValue), ... - "MediaType", string(mediaType), ... - "Status", string(status), ... - "Message", string(message)); -end - -function varargout = writeResultManifest(fig, folder, spec) - runtime = getappdata(fig, appRuntimeKey()); - runtime.document.exporting = true; - setappdata(fig, appRuntimeKey(), runtime); - cleanup = onCleanup(@() clearExporting(fig)); - [varargout{1:nargout}] = writeV2ResultManifest(runtime, folder, spec); - clear cleanup; -end - -function clearExporting(fig) - if isempty(fig) || ~isvalid(fig) || ~isappdata(fig, appRuntimeKey()) - return; - end - runtime = getappdata(fig, appRuntimeKey()); - runtime.document.exporting = false; - setappdata(fig, appRuntimeKey(), runtime); -end diff --git a/+labkit/+ui/+runtime/private/buildWorkspace.m b/+labkit/+ui/+runtime/private/buildWorkspace.m deleted file mode 100644 index 6d6502a3f..000000000 --- a/+labkit/+ui/+runtime/private/buildWorkspace.m +++ /dev/null @@ -1,303 +0,0 @@ -% Private UI runtime helper. Expected caller: labkit.ui.runtime.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) - if workspaceUsesTabs(workspaceSpec.children) - ui = buildWorkspaceTabs(ui, workspaceSpec.children, debug); - return; - end - 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', 'usagePanel', 'logPanel'} - ui = buildControl(ui, childSpec, ui.rightGrid, iChild, debug); - otherwise - error('labkit:ui:runtime:UnsupportedWorkspaceChild', ... - 'Unsupported workspace child kind "%s".', childSpec.kind); - end - end -end - -function tf = workspaceUsesTabs(children) - tf = numel(children) >= 2 && ... - all(cellfun(@(child) strcmp(child.kind, 'tab'), children)); -end - -function ui = buildWorkspaceTabs(ui, tabSpecs, debug) - tabGroup = uitabgroup(ui.rightGrid); - tabGroup.Layout.Row = 1; - tabGroup.Layout.Column = 1; - ui.workspace.tabGroup = tabGroup; - ui.workspace.pages = struct(); - for tabIndex = 1:numel(tabSpecs) - tabSpec = tabSpecs{tabIndex}; - tab = uitab(tabGroup, ... - 'Title', optionValue(tabSpec.props, 'title', tabSpec.id)); - childCount = numel(tabSpec.children); - grid = uigridlayout(tab, [childCount 2]); - grid.RowHeight = repmat({'1x'}, 1, childCount); - grid.ColumnWidth = {'1x', '1x'}; - grid.RowSpacing = 8; - grid.Padding = [8 8 8 8]; - ui.workspace.pages.(tabSpec.id) = struct( ... - 'tab', tab, 'grid', grid, 'layout', tabSpec); - for childIndex = 1:childCount - childSpec = tabSpec.children{childIndex}; - switch childSpec.kind - case 'previewArea' - ui = buildPreviewArea( ... - ui, childSpec, grid, childIndex); - case {'resultTable', 'statusPanel', ... - 'usagePanel', 'logPanel'} - ui = buildControl( ... - ui, childSpec, grid, childIndex, debug); - otherwise - error('labkit:ui:runtime:UnsupportedWorkspaceChild', ... - 'Unsupported workspace-tab child kind "%s".', ... - childSpec.kind); - end - 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; - columnCount = numel(parentGrid.ColumnWidth); - if columnCount == 1 - panel.Layout.Column = 1; - else - panel.Layout.Column = [1 columnCount]; - end - - 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, 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)); - labkit.ui.interaction.enablePopout(ax); - axesHandles(k) = ax; - axesById.(axisIds{k}) = ax; - end - tagPreviewScrollZoomAxes(axesHandles, props); - installPreviewScrollNavigation(ui.figure, axesHandles); - registerWorkbenchAxes(ui.figure, axesHandles); - - 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 tagPreviewScrollZoomAxes(axesHandles, props) - if isempty(axesHandles) - return; - end - zoomAxes = layoutSizes(props, 'scrollZoomAxes', numel(axesHandles), ... - repmat({'xy'}, 1, numel(axesHandles))); - for k = 1:numel(axesHandles) - value = normalizeScrollZoomAxes(zoomAxes{k}); - if isvalid(axesHandles(k)) && value ~= "xy" - setappdata(axesHandles(k), 'labkitPreviewScrollZoomAxes', value); - end - end -end - -function value = normalizeScrollZoomAxes(value) - value = lower(strtrim(string(value))); - if value == "both" - value = "xy"; - end - if ~isscalar(value) || ~ismember(value, ["xy", "x", "y"]) - value = "xy"; - end -end - -function grid = previewAxesGrid(parent, props, count) - layout = props.layout; - 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 = layoutSizes(props, 'columnWidths', count, ... - repmat({'1x'}, 1, count)); - case 'stack' - grid = uigridlayout(parent, [count 1]); - grid.RowHeight = layoutSizes(props, 'rowHeights', count, ... - repmat({'1x'}, 1, count)); - grid.ColumnWidth = {'1x'}; - otherwise - error('labkit:ui:runtime:InvalidPreviewLayout', ... - 'Unsupported previewArea layout "%s".', layout); - end - grid.Padding = [0 0 0 0]; -end - -function sizes = layoutSizes(props, fieldName, count, defaultSizes) - sizes = defaultSizes; - if ~isfield(props, fieldName) - return; - end - candidate = props.(fieldName); - if ~(iscell(candidate) && numel(candidate) == count) - error('labkit:ui:runtime:InvalidPreviewLayoutSizes', ... - 'previewArea %s must be a cell array with %d entries.', ... - fieldName, count); - end - sizes = candidate; -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:runtime:InvalidAxisId', ... - 'previewArea axis id "%s" must be a valid MATLAB field name.', ids{k}); - end - end - if numel(unique(string(ids), 'stable')) ~= numel(ids) - error('labkit:ui:runtime:DuplicateAxisId', ... - 'previewArea axis ids must be unique.'); - 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.layout = 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:runtime: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/+runtime/private/canonicalSourceRecord.m b/+labkit/+ui/+runtime/private/canonicalSourceRecord.m deleted file mode 100644 index 6dd685c41..000000000 --- a/+labkit/+ui/+runtime/private/canonicalSourceRecord.m +++ /dev/null @@ -1,71 +0,0 @@ -% Private Runtime V2 source-schema owner. Expected callers are the public -% empty-source factory and injected project services. Inputs are scalar source -% metadata. Output is one canonical durable source record with no file IO. -function source = canonicalSourceRecord(id, role, sourceValue, required) - if ~(ischar(id) || (isstring(id) && isscalar(id))) - error('labkit:ui:runtime:InvalidSourceRecords', ... - 'Project source id must be nonempty scalar text.'); - end - id = string(id); - if ~isscalar(id) || strlength(id) == 0 - error('labkit:ui:runtime:InvalidSourceRecords', ... - 'Project source id must be nonempty scalar text.'); - end - reference = canonicalReference(sourceValue); - source = struct( ... - "id", id, ... - "required", logical(required), ... - "role", string(role), ... - "reference", reference); -end - -function reference = canonicalReference(value) - if ~isstruct(value) - filepath = string(value); - [~, name, extension] = fileparts(filepath); - reference = struct( ... - "schemaVersion", 1, ... - "relativePath", "", ... - "originalPath", filepath, ... - "fileName", string(name) + string(extension)); - return; - end - requiredFields = ["schemaVersion", "relativePath", ... - "originalPath", "fileName"]; - if ~isscalar(value) || ... - ~all(isfield(value, cellstr(requiredFields))) - invalid(['Existing portable reference must be a scalar struct with ' ... - 'the canonical fields.']); - end - versionValue = double(value.schemaVersion); - if ~isscalar(versionValue) || ~isfinite(versionValue) || ... - versionValue ~= 1 - invalid('Existing portable reference has an unsupported schema version.'); - end - relativePath = scalarText(value.relativePath, 'relative path', true); - originalPath = scalarText(value.originalPath, 'original path', true); - fileName = scalarText(value.fileName, 'file name', false); - if strlength(relativePath) == 0 && strlength(originalPath) == 0 - invalid(['Existing portable reference must contain a relative or ' ... - 'original path.']); - end - reference = struct( ... - "schemaVersion", 1, ... - "relativePath", relativePath, ... - "originalPath", originalPath, ... - "fileName", fileName); -end - -function value = scalarText(value, label, allowEmpty) - if ~(ischar(value) || (isstring(value) && isscalar(value))) - invalid('Existing portable reference %s must be scalar text.', label); - end - value = string(value); - if ~allowEmpty && strlength(value) == 0 - invalid('Existing portable reference %s must be nonempty.', label); - end -end - -function invalid(message, varargin) - error('labkit:ui:runtime:InvalidSourceRecords', message, varargin{:}); -end diff --git a/+labkit/+ui/+runtime/private/commitV2Presentation.m b/+labkit/+ui/+runtime/private/commitV2Presentation.m deleted file mode 100644 index 08b940487..000000000 --- a/+labkit/+ui/+runtime/private/commitV2Presentation.m +++ /dev/null @@ -1,474 +0,0 @@ -% Private UI runtime helper. Expected caller: runV2App after a validated state -% commit. Inputs are a v2 runtime and semantic state. Side effects reconcile -% bound controls, declared control presentation, and registered plot renderers -% without exposing the raw UI registry to app presenters. -function presentation = commitV2Presentation(runtime, state, preserveView) - if nargin < 3 - preserveView = false; - end - presentation = runtime.definition.present(state); - if isempty(presentation) - presentation = struct(); - end - if ~isstruct(presentation) || ~isscalar(presentation) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Present must return a scalar struct.'); - end - validatePresentationReferences(runtime, presentation); - if isfield(presentation, 'controls') - applyControlConstraints(runtime.ui, presentation.controls); - end - applyBindings(runtime.ui, runtime.bindings, state); - if isfield(presentation, 'controls') - applyControls(runtime.ui, presentation.controls); - end - applyWorkflowLog(runtime.ui, state.session.workflow); - if isfield(presentation, 'previews') - priorPreviews = struct(); - if isfield(runtime.lastPresentation, 'previews') - priorPreviews = runtime.lastPresentation.previews; - end - applyPreviews(runtime, presentation.previews, priorPreviews, ... - logical(preserveView)); - end - if isfield(presentation, 'interactions') - reconcileV2Interactions(runtime, presentation.interactions); - else - reconcileV2Interactions(runtime, struct()); - end -end - -function validatePresentationReferences(runtime, presentation) - if isfield(presentation, 'controls') - controls = presentation.controls; - if ~isstruct(controls) || ~isscalar(controls) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Presentation controls must be a scalar struct.'); - end - ids = string(fieldnames(controls)); - known = string(fieldnames(runtime.ui.controls)); - unknown = setdiff(ids, known, 'stable'); - if ~isempty(unknown) - error('labkit:ui:runtime:UnknownPresentationControl', ... - 'Presentation references unknown control "%s".', unknown(1)); - end - end - if ~isfield(presentation, 'previews') - return; - end - previews = presentation.previews; - if ~isstruct(previews) || ~isscalar(previews) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Presentation previews must be a scalar struct.'); - end - previewIds = string(fieldnames(previews)); - for k = 1:numel(previewIds) - previewId = previewIds(k); - control = resolvePlotControl(runtime.ui, previewId); - spec = previews.(char(previewId)); - [hasAxes, axesSpecs] = propertyValue(spec, "Axes"); - if hasAxes - if ~isstruct(axesSpecs) || ~isscalar(axesSpecs) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Preview "%s" Axes must be a scalar struct.', previewId); - end - axisIds = string(fieldnames(axesSpecs)); - knownAxes = string(fieldnames(control.axesById)); - unknownAxes = setdiff(axisIds, knownAxes, 'stable'); - if ~isempty(unknownAxes) - error('labkit:ui:runtime:UnknownPresentationAxis', ... - 'Preview "%s" references unknown axis "%s".', ... - previewId, unknownAxes(1)); - end - for j = 1:numel(axisIds) - validateRendererReference(runtime, previewId, ... - axesSpecs.(char(axisIds(j)))); - end - else - validateRendererReference(runtime, previewId, spec); - end - end -end - -function validateRendererReference(runtime, previewId, spec) - [hasRenderer, rendererId] = propertyValue(spec, "Renderer"); - [hasModel, ~] = propertyValue(spec, "Model"); - if ~hasRenderer || ~hasModel - error('labkit:ui:runtime:InvalidPresentation', ... - 'Preview "%s" requires Renderer and Model.', previewId); - end - rendererId = string(rendererId); - if ~isscalar(rendererId) || strlength(rendererId) == 0 || ... - ~isfield(runtime.definition.renderers, char(rendererId)) - error('labkit:ui:runtime:UnknownRenderer', ... - 'Preview "%s" references unknown renderer "%s".', ... - previewId, join(rendererId, ", ")); - end -end - -function applyWorkflowLog(ui, workflow) - if ~isstruct(workflow) || ~isscalar(workflow) || ... - ~isfield(workflow, 'logLines') - return; - end - value = cellstr(string(workflow.logLines(:))); - if isempty(value) - value = {''}; - end - ids = fieldnames(ui.controls); - for k = 1:numel(ids) - control = ui.controls.(ids{k}); - if isstruct(control) && isfield(control, 'kind') && ... - strcmp(control.kind, 'logPanel') - setControlValue(ui, ids{k}, value); - end - end -end - -function applyControlConstraints(ui, controls) - if ~isstruct(controls) || ~isscalar(controls) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Presentation controls must be a scalar struct.'); - end - ids = fieldnames(controls); - for k = 1:numel(ids) - id = string(ids{k}); - spec = controls.(ids{k}); - if ~isstruct(spec) || ~isscalar(spec) - continue; - end - [found, value] = propertyValue(spec, "Items"); - if found - setControlItems(ui, id, value); - end - [found, value] = propertyValue(spec, "Limits"); - if found - setControlLimits(ui, id, value); - end - end -end - -function applyBindings(ui, bindings, state) - for k = 1:numel(bindings) - value = valueAtPath(state, bindings(k).path); - setControlValue(ui, bindings(k).id, value); - end -end - -function applyControls(ui, controls) - if ~isstruct(controls) || ~isscalar(controls) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Presentation controls must be a scalar struct.'); - end - ids = fieldnames(controls); - for k = 1:numel(ids) - id = string(ids{k}); - spec = controls.(ids{k}); - if ~isstruct(spec) || ~isscalar(spec) - setControlValue(ui, id, spec); - continue; - end - applyControlSpec(ui, id, spec); - end -end - -function applyControlSpec(ui, id, spec) - [found, value] = propertyValue(spec, "Files"); - if found - setControlValue(ui, id, value); - end - [found, value] = propertyValue(spec, "Selection"); - if found - setControlFileSelection(ui, id, value); - end - [found, value] = propertyValue(spec, "Status"); - if found - applyFilePanelStatus(ui, id, value); - end - [found, value] = propertyValue(spec, "Items"); - if found - setControlItems(ui, id, value); - end - [found, value] = propertyValue(spec, "Limits"); - if found - setControlLimits(ui, id, value); - end - [found, value] = propertyValue(spec, "Enabled"); - if found - setControlEnabled(ui, id, value); - end - [found, value] = propertyValue(spec, "Visible"); - if found - applyControlVisibility(ui, id, value); - end - [found, value] = propertyValue(spec, "Text"); - if found - applyControlText(ui, id, value); - end - tableProperties = ["ColumnName", "RowName"]; - for k = 1:numel(tableProperties) - [found, value] = propertyValue(spec, tableProperties(k)); - if found - applyResultTableProperty(ui, id, tableProperties(k), value); - end - end - names = ["Value", "Data"]; - for k = 1:numel(names) - [found, value] = propertyValue(spec, names(k)); - if found - setControlValue(ui, id, value); - break; - end - end -end - -function applyControlText(ui, id, value) - field = char(id); - if ~isfield(ui.controls, field) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Presentation references unknown control "%s".', id); - end - control = ui.controls.(field); - candidates = {'button', 'valueHandle', 'handle'}; - for k = 1:numel(candidates) - name = candidates{k}; - if isfield(control, name) && isgraphics(control.(name)) && ... - isprop(control.(name), 'Text') - control.(name).Text = char(string(value)); - return; - end - end - setControlValue(ui, id, value); -end - -function applyResultTableProperty(ui, id, propertyName, value) - field = char(id); - if ~isfield(ui.controls, field) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Presentation references unknown control "%s".', id); - end - control = ui.controls.(field); - if ~isfield(control, 'kind') || ~strcmp(control.kind, 'resultTable') || ... - ~isfield(control, 'table') || isempty(control.table) || ... - ~isvalid(control.table) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Control "%s" does not expose result-table properties.', id); - end - propertyName = char(propertyName); - if isequaln(control.table.(propertyName), value) - return; - end - control.table.(propertyName) = value; -end - -function applyControlVisibility(ui, id, value) - field = char(id); - if ~isfield(ui.controls, field) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Presentation references unknown control "%s".', id); - end - control = ui.controls.(field); - if ~isfield(control, 'panel') || isempty(control.panel) || ... - ~isvalid(control.panel) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Control "%s" does not expose panel visibility.', id); - end - visible = logical(value); - if ~isscalar(visible) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Control "%s" visibility must be a logical scalar.', id); - end - if visible - control.panel.Visible = 'on'; - else - control.panel.Visible = 'off'; - end - parent = control.panel.Parent; - if ~isequal(parent, ui.rightGrid) - return; - end - row = control.panel.Layout.Row; - if ~isscalar(row) || row < 1 || row > numel(parent.RowHeight) - return; - end - heights = parent.RowHeight; - key = 'labkitVisibleRowHeight'; - if visible - if isappdata(control.panel, key) - heights{row} = getappdata(control.panel, key); - else - heights{row} = '1x'; - end - else - if ~isappdata(control.panel, key) - setappdata(control.panel, key, heights{row}); - end - heights{row} = 0; - end - parent.RowHeight = heights; -end - -function applyFilePanelStatus(ui, id, value) - field = char(id); - if ~isfield(ui.controls, field) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Presentation references unknown control "%s".', id); - end - control = ui.controls.(field); - if ~isfield(control, 'kind') || ~strcmp(control.kind, 'filePanel') || ... - ~isfield(control, 'status') || isempty(control.status) || ... - ~isvalid(control.status) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Control "%s" does not expose file-panel status text.', id); - end - control.status.Value = char(string(value)); -end - -function applyPreviews(runtime, previews, priorPreviews, preserveView) - if ~isstruct(previews) || ~isscalar(previews) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Presentation previews must be a scalar struct.'); - end - ids = fieldnames(previews); - for k = 1:numel(ids) - id = string(ids{k}); - spec = previews.(ids{k}); - [hasAxes, axesSpecs] = propertyValue(spec, "Axes"); - if hasAxes - priorAxes = priorAxisSpecs(priorPreviews, id); - applyPreviewAxes(runtime, id, axesSpecs, priorAxes, preserveView); - continue; - end - if samePreviewRequest(priorPreviews, id, spec) - continue; - end - applyPreview(runtime, id, spec, preserveView); - end -end - -function applyPreviewAxes(runtime, previewId, axesSpecs, priorAxes, preserveView) - if ~isstruct(axesSpecs) || ~isscalar(axesSpecs) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Preview "%s" Axes must be a scalar struct.', previewId); - end - axisIds = fieldnames(axesSpecs); - for k = 1:numel(axisIds) - axisId = string(axisIds{k}); - spec = axesSpecs.(axisIds{k}); - if ~isstruct(spec) || ~isscalar(spec) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Preview "%s" axis "%s" must be a scalar struct.', ... - previewId, axisId); - end - if isfield(priorAxes, char(axisId)) && ... - isequaln(priorAxes.(char(axisId)), spec) - continue; - end - spec.Axis = axisId; - applyPreview(runtime, previewId, spec, preserveView); - end -end - -function axesSpecs = priorAxisSpecs(priorPreviews, previewId) - axesSpecs = struct(); - if ~isstruct(priorPreviews) || ~isscalar(priorPreviews) || ... - ~isfield(priorPreviews, char(previewId)) - return; - end - [found, value] = propertyValue( ... - priorPreviews.(char(previewId)), "Axes"); - if found && isstruct(value) && isscalar(value) - axesSpecs = value; - end -end - -function tf = samePreviewRequest(priorPreviews, previewId, spec) - tf = isstruct(priorPreviews) && isscalar(priorPreviews) && ... - isfield(priorPreviews, char(previewId)) && ... - isequaln(priorPreviews.(char(previewId)), spec); -end - -function applyPreview(runtime, id, spec, preserveView) - [hasRenderer, rendererId] = propertyValue(spec, "Renderer"); - [hasModel, model] = propertyValue(spec, "Model"); - if ~hasRenderer || ~hasModel - error('labkit:ui:runtime:InvalidPresentation', ... - 'Preview "%s" requires Renderer and Model.', id); - end - rendererId = char(string(rendererId)); - if ~isfield(runtime.definition.renderers, rendererId) - error('labkit:ui:runtime:UnknownRenderer', ... - 'Preview "%s" references unknown renderer "%s".', ... - id, rendererId); - end - [hasAxis, axisId] = propertyValue(spec, "Axis"); - if hasAxis && strlength(string(axisId)) > 0 - ax = resolvePreviewAxes(runtime.ui, id, string(axisId)); - else - ax = resolvePreviewAxes(runtime.ui, id); - end - priorView = captureManualView(ax, preserveView); - restoreView = onCleanup(@() restoreManualView(ax, priorView)); - invokeRenderer(runtime.definition.renderers.(rendererId), ax, model); - labkit.ui.interaction.enablePopout(ax); - clear restoreView; -end - -function view = captureManualView(ax, preserveView) - view = struct('x', [], 'y', []); - if ~preserveView || isempty(ax) || ~isvalid(ax) - return; - end - if strcmp(ax.XLimMode, 'manual') - view.x = double(ax.XLim); - end - if strcmp(ax.YLimMode, 'manual') - view.y = double(ax.YLim); - end -end - -function restoreManualView(ax, view) - if isempty(ax) || ~isvalid(ax) - return; - end - if ~isempty(view.x) - ax.XLim = view.x; - end - if ~isempty(view.y) - ax.YLim = view.y; - end -end - -function invokeRenderer(renderer, ax, model) - count = nargin(renderer); - if count == 0 - renderer(); - elseif count == 1 - renderer(model); - else - renderer(ax, model); - end -end - -function value = valueAtPath(state, path) - parts = split(string(path), "."); - value = state; - for k = 1:numel(parts) - value = value.(char(parts(k))); - end -end - -function [found, value] = propertyValue(spec, name) - if ~isstruct(spec) || ~isscalar(spec) - found = false; - value = []; - return; - end - fields = string(fieldnames(spec)); - index = find(strcmpi(fields, name), 1, 'first'); - found = ~isempty(index); - value = []; - if found - value = spec.(char(fields(index))); - end -end diff --git a/+labkit/+ui/+runtime/private/controlAxes.m b/+labkit/+ui/+runtime/private/controlAxes.m deleted file mode 100644 index a7397615c..000000000 --- a/+labkit/+ui/+runtime/private/controlAxes.m +++ /dev/null @@ -1,28 +0,0 @@ -% Private UI runtime helper. Expected caller: resolvePreviewAxes. -% Returns the requested axes from a previewArea or axes-like runtime 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:plot: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:plot:NoAxes', ... - 'Control "%s" does not expose axes.', control.id); -end diff --git a/+labkit/+ui/+runtime/private/controlHandles.m b/+labkit/+ui/+runtime/private/controlHandles.m deleted file mode 100644 index 5de865695..000000000 --- a/+labkit/+ui/+runtime/private/controlHandles.m +++ /dev/null @@ -1,37 +0,0 @@ -% Private UI runtime helper. Expected caller: semantic presentation helpers. -% Collects MATLAB graphics handles from a runtime 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/+runtime/private/controlValueHandle.m b/+labkit/+ui/+runtime/private/controlValueHandle.m deleted file mode 100644 index 3e24974e4..000000000 --- a/+labkit/+ui/+runtime/private/controlValueHandle.m +++ /dev/null @@ -1,22 +0,0 @@ -% Private UI runtime helper. Expected caller: semantic presentation helpers. -% Returns the primary value-bearing MATLAB handle from a runtime 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:control: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/+runtime/private/createMultiFilePanelParts.m b/+labkit/+ui/+runtime/private/createMultiFilePanelParts.m deleted file mode 100644 index 544027b04..000000000 --- a/+labkit/+ui/+runtime/private/createMultiFilePanelParts.m +++ /dev/null @@ -1,135 +0,0 @@ -% Private UI runtime helper. Expected caller: buildFilePanelControl. Inputs are a -% parent panel, filePanel props, and semantic callback wiring. Output contains -% the grid and child handles for a multi-file filePanel. -function parts = createMultiFilePanelParts(panel, props, callbacks) - grid = createGrid(panel, props); - emptyText = emptyFileText(props); - chooseButton = createButton(grid, props, callbacks, ... - 'chooseLabel', 'Add...', 'choose', 'onChoose', 1); - chooseButton.UserData = "file"; - chooseButton.Tooltip = ['Select one or more files from the same folder. ' ... - 'If MATLAB reports files from different folders, cancel the reopened dialog.']; - folderButton = createButton(grid, props, callbacks, ... - 'folderLabel', 'Add folder', 'choose', 'onChoose', 2); - folderButton.UserData = "folder"; - folderButton.Tooltip = 'Add supported files directly inside one folder.'; - recursiveFolderButton = createButton(grid, props, callbacks, ... - 'recursiveFolderLabel', 'Add folder tree', ... - 'choose', 'onChoose', 3); - recursiveFolderButton.UserData = "recursiveFolder"; - recursiveFolderButton.Tooltip = ... - 'Add supported files from one folder and all of its subfolders.'; - removeButton = createButton(grid, props, callbacks, ... - 'removeLabel', 'Remove selected', 'remove', 'onRemove', [1 2]); - removeButton.Layout.Row = 2; - clearButton = createButton(grid, props, callbacks, ... - 'clearLabel', 'Clear', 'clear', 'onClear', 3); - clearButton.Layout.Row = 2; - listbox = createListbox(grid, props, callbacks, emptyText); - status = createStatusBox(grid, props); - parts = struct('grid', grid, ... - 'chooseButton', chooseButton, ... - 'folderButton', folderButton, ... - 'recursiveFolderButton', recursiveFolderButton, ... - 'removeButton', removeButton, ... - 'clearButton', clearButton, ... - 'listbox', listbox, ... - 'status', status); -end - -function grid = createGrid(panel, props) - if showStatus(props) - grid = uigridlayout(panel, [4 3]); - grid.RowHeight = {'fit', 'fit', '1x', 'fit'}; - else - grid = uigridlayout(panel, [3 3]); - grid.RowHeight = {'fit', 'fit', '1x'}; - end - grid.ColumnWidth = {'1x', '1x', '1x'}; - grid.RowSpacing = 6; - grid.ColumnSpacing = 8; - grid.Padding = [8 8 8 8]; -end - -function button = createButton(grid, props, callbacks, ... - labelProp, defaultLabel, callbackField, originalCallbackProp, column) - button = uibutton(grid, ... - 'Text', optionValue(props, labelProp, defaultLabel), ... - 'ButtonPushedFcn', callbacks.(callbackField)); - applyTextFit(button, 'charsPerStep', 18, 'maxShrinkSteps', 3); - callbacks.setOriginalCallbackName(button, optionValue(props, ... - originalCallbackProp, [])); - button.Layout.Row = 1; - button.Layout.Column = column; -end - -function listbox = createListbox(grid, props, callbacks, emptyText) - listbox = uilistbox(grid, 'Items', {emptyText}, ... - 'Multiselect', fileMultiselect(props)); - listbox.Value = initialListValue(listbox, emptyText); - listbox.ValueChangedFcn = callbacks.selection; - callbacks.setOriginalCallbackName(listbox, optionValue(props, ... - 'onSelectionChange', [])); - listbox.Layout.Row = 3; - listbox.Layout.Column = [1 3]; -end - -function status = createStatusBox(grid, props) - status = []; - if ~showStatus(props) - return; - end - status = uitextarea(grid, ... - 'Value', fileStatusText(props), ... - 'Editable', 'off', ... - 'Tag', 'LabKitFilePanelStatusText'); - applyTextFit(status); - status.Layout.Row = 4; - status.Layout.Column = [1 3]; -end - -function value = initialListValue(listbox, text) - if strcmp(listbox.Multiselect, 'on') - value = {text}; - else - value = text; - end -end - -function mode = fileMultiselect(props) - if strcmp(char(string(optionValue(props, 'selectionMode', 'single'))), 'multiple') - mode = 'on'; - else - mode = 'off'; - end -end - -function text = fileStatusText(props) - text = emptyFileText(props); -end - -function text = emptyFileText(props) - if isstruct(props) && isfield(props, 'emptyText') - text = char(string(props.emptyText)); - return; - end - if isstruct(props) && isfield(props, 'status') - text = char(string(props.status)); - return; - end - text = 'No files loaded'; -end - -function tf = showStatus(props) - tf = true; - if isstruct(props) && isfield(props, 'showStatus') - tf = logical(props.showStatus); - 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/+runtime/private/createPortableFileReference.m b/+labkit/+ui/+runtime/private/createPortableFileReference.m deleted file mode 100644 index f11624873..000000000 --- a/+labkit/+ui/+runtime/private/createPortableFileReference.m +++ /dev/null @@ -1,44 +0,0 @@ -% Private Runtime V2 serialization helper. Expected callers are project -% envelope creation and source relinking. Inputs are the MAT-file anchor path -% and one external source path. Output is the standard portable-reference -% struct; no file is opened or mutated. -function reference = createPortableFileReference(anchorFile, targetFile) - anchorFile = string(anchorFile); - targetFile = string(targetFile); - [~, fileName, extension] = fileparts(targetFile); - reference = struct('schemaVersion', 1, 'relativePath', "", ... - 'originalPath', targetFile, 'fileName', fileName + extension); - if strlength(anchorFile) == 0 || strlength(targetFile) == 0 - return; - end - [anchorFolder, ~, ~] = fileparts(anchorFile); - reference.relativePath = pathRelativeToFolder(anchorFolder, targetFile); -end - -function relativePath = pathRelativeToFolder(folder, target) - folder = replace(string(folder), "\", "/"); - target = replace(string(target), "\", "/"); - if ~isAbsolutePath(target) - relativePath = target; - return; - end - folderParts = split(strip(folder, "/"), "/"); - targetParts = split(strip(target, "/"), "/"); - commonCount = 0; - limit = min(numel(folderParts), numel(targetParts)); - while commonCount < limit && ... - strcmpi(folderParts(commonCount + 1), targetParts(commonCount + 1)) - commonCount = commonCount + 1; - end - if commonCount == 0 - relativePath = ""; - return; - end - parentParts = repmat("..", numel(folderParts) - commonCount, 1); - relativePath = join([parentParts; targetParts(commonCount + 1:end)], "/"); -end - -function tf = isAbsolutePath(pathValue) - tf = startsWith(pathValue, "/") || startsWith(pathValue, "\\") || ... - ~isempty(regexp(pathValue, '^[A-Za-z]:/', 'once')); -end diff --git a/+labkit/+ui/+runtime/private/createTabbedWorkbenchShell.m b/+labkit/+ui/+runtime/private/createTabbedWorkbenchShell.m deleted file mode 100644 index 6e402ba57..000000000 --- a/+labkit/+ui/+runtime/private/createTabbedWorkbenchShell.m +++ /dev/null @@ -1,333 +0,0 @@ -% Private UI runtime helper. Expected caller: labkit.ui.runtime shell construction -% code. Inputs and outputs are internal uifigure, grid, tab, or resize handle -% values. Side effects are limited to UI object creation or callback wiring on -% supplied parents; assumes the caller owns component lifecycle. -function ui = createTabbedWorkbenchShell(figName, figPosition, leftWidth, labels, tabLayouts, rightGridSize, rightRowHeight, rightRowSpacing, debug, utilities) -%CREATETABBEDWORKBENCHSHELL Build the private tabbed workbench skeleton. -% -% Called by: -% labkit.ui.runtime.create through buildShellFromLayout. -% -% 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. -% tabLayouts - internal tab layouts derived from declarative workbench layouts. -% rightGridSize - right-side uigridlayout size. -% rightRowHeight - right-side grid RowHeight cell array. -% rightRowSpacing - right-side grid RowSpacing scalar. -% -% Output: -% ui - workbench handle struct containing fig, main grid, left/right -% panels, tabs, scroll panels, tab grids, resize handles, and rightGrid. -% -% Notes: -% Logical tab rows are expanded with physical resize-handle rows here. -% App code should use declarative layouts rather than depending on physical row -% indices. - - if nargin < 9 - debug = []; - end - if nargin < 10 - utilities = struct(); - end - - ui = struct(); - figArgs = {'Name', figName, 'Position', figPosition, 'Visible', 'off'}; - ui.fig = uifigure(figArgs{:}); - installCloseKeyboardShortcut(ui.fig); - applyGuiTestMode(ui.fig); - - ui.main = uigridlayout(ui.fig, [3 3]); - ui.main.ColumnWidth = {leftWidth, 6, '1x'}; - ui.main.RowHeight = {utilityRowHeight(utilities), 0, '1x'}; - ui.main.Padding = [10 10 10 10]; - ui.main.ColumnSpacing = 0; - ui.main.RowSpacing = 6; - - ui.utilityBarPanel = createUtilityBar(ui.fig, ui.main, utilities); - ui.utilityBarPanel.Layout.Row = 1; - ui.utilityBarPanel.Layout.Column = [1 3]; - - ui.startupStatusPanel = uipanel(ui.main, ... - 'BackgroundColor', [0.94 0.97 1.00], ... - 'BorderType', 'none', ... - 'Visible', 'off'); - ui.startupStatusPanel.Layout.Row = 2; - ui.startupStatusPanel.Layout.Column = [1 3]; - - startupGrid = uigridlayout(ui.startupStatusPanel, [1 1]); - startupGrid.RowHeight = {'1x'}; - startupGrid.ColumnWidth = {'1x'}; - startupGrid.Padding = [8 3 8 3]; - ui.startupStatusLabel = uilabel(startupGrid, ... - 'Text', 'Starting...', ... - 'FontWeight', 'bold'); - ui.startupStatusLabel.Layout.Row = 1; - ui.startupStatusLabel.Layout.Column = 1; - - ui.separator = uipanel(ui.main, ... - 'BackgroundColor', [0.75 0.75 0.75], ... - 'BorderType', 'none'); - ui.separator.Layout.Row = 3; - ui.separator.Layout.Column = 2; - - ui.leftPanel = uipanel(ui.main, 'Title', labels.controlsPanel); - ui.leftPanel.Layout.Row = 3; - ui.leftPanel.Layout.Column = 1; - - ui.leftHost = uigridlayout(ui.leftPanel, [1 1]); - ui.leftHost.RowHeight = {'1x'}; - ui.leftHost.ColumnWidth = {'1x'}; - ui.leftHost.Padding = [8 8 8 8]; - - ui.tabs = uitabgroup(ui.leftHost); - ui.tabs.Layout.Row = 1; - ui.tabs.Layout.Column = 1; - - for k = 1:numel(tabLayouts) - tabLayout = tabLayouts(k); - [tab, panel] = createScrollableTab(ui.tabs, tabLayout.title); - [gridSize, rowHeight, rowMap] = expandedTabGridLayout(tabLayout); - grid = uigridlayout(panel, gridSize); - enableScrollableGrid(grid); - grid.RowHeight = rowHeight; - grid.RowSpacing = optionValue(tabLayout, 'rowSpacing', 10); - grid.Padding = optionValue(tabLayout, 'padding', [8 8 8 8]); - grid.UserData = struct('LabKitLogicalRowMap', rowMap); - if isfield(tabLayout, 'columnWidth') - grid.ColumnWidth = tabLayout.columnWidth; - end - if isfield(tabLayout, 'columnSpacing') - grid.ColumnSpacing = tabLayout.columnSpacing; - end - - ui.([tabLayout.key 'Tab']) = tab; - ui.([tabLayout.key 'ScrollPanel']) = panel; - ui.([tabLayout.key 'Grid']) = grid; - ui.([tabLayout.key 'ResizeHandles']) = attachTabRowResizeHandles( ... - ui.fig, grid, tabLayout, rowMap, debugTrace(debug)); - end - - ui.rightPanel = uipanel(ui.main, 'Title', labels.rightPanel); - ui.rightPanel.Layout.Row = 3; - ui.rightPanel.Layout.Column = 3; - - ui.rightGrid = uigridlayout(ui.rightPanel, rightGridSize); - ui.rightGrid.RowHeight = rightRowHeight; - ui.rightGrid.ColumnWidth = {'1x'}; - ui.rightGrid.RowSpacing = rightRowSpacing; - ui.rightGrid.Padding = [8 8 8 8]; - - attachColumnResize(ui.fig, ui.main, 1, 2, ... - struct('minWidth', 260, 'rightReserve', 360, 'separatorWidth', 6, ... - 'onTrace', debugTrace(debug))); -end - -function height = utilityRowHeight(~) - height = 0; -end - -function mode = guiTestMode() - mode = lower(strtrim(string(getenv('LABKIT_GUI_TEST_MODE')))); - if ~any(mode == ["hidden", "minimized"]) - mode = "visible"; - end -end - -function applyGuiTestMode(fig) - if guiTestMode() == "minimized" && isprop(fig, 'WindowState') - try - fig.WindowState = 'minimized'; - catch - end - end -end - -function [tab, panel] = createScrollableTab(parent, titleText) - tab = uitab(parent, 'Title', titleText); - host = uigridlayout(tab, [1 1]); - host.RowHeight = {'1x'}; - host.ColumnWidth = {'1x'}; - host.Padding = [0 0 0 0]; - - panel = uipanel(host, ... - 'BorderType', 'none', ... - 'Scrollable', 'on'); - panel.Layout.Row = 1; - panel.Layout.Column = 1; -end - -function value = optionValue(s, name, defaultValue) - value = defaultValue; - if isfield(s, name) - value = s.(name); - end -end - -function enableScrollableGrid(grid) - try - grid.Scrollable = 'on'; - catch - end -end - -function [gridSize, rowHeight, rowMap] = expandedTabGridLayout(tabLayout) - logicalRows = tabLayout.gridSize(1); - resizeRows = validResizeRows(tabLayout, logicalRows); - rowMap = zeros(1, logicalRows); - rowHeight = {}; - handleHeight = 6; - if isfield(tabLayout, 'resizeOptions') && isfield(tabLayout.resizeOptions, 'handleHeight') - handleHeight = tabLayout.resizeOptions.handleHeight; - end - - for row = 1:logicalRows - rowMap(row) = numel(rowHeight) + 1; - rowHeight{end+1} = tabLayout.rowHeight{row}; - if any(resizeRows == row) - rowHeight{end+1} = handleHeight; - end - end - gridSize = [numel(rowHeight), tabLayout.gridSize(2)]; -end - -function rows = validResizeRows(tabLayout, logicalRows) - rows = []; - if isfield(tabLayout, 'resizeRows') && ~isempty(tabLayout.resizeRows) - rows = unique(tabLayout.resizeRows(:).'); - rows = rows(rows >= 1 & rows <= logicalRows & isfinite(rows)); - return; - end - - mode = optionValue(tabLayout, 'resize', 'betweenRows'); - if islogical(mode) - if mode - rows = 1:logicalRows; - end - return; - end - mode = lower(char(string(mode))); - switch mode - case {'betweenrows', 'auto', 'all'} - rows = 1:logicalRows; - case {'none', 'off', 'false'} - rows = []; - otherwise - error('labkit:ui:InvalidTabResizeMode', ... - 'Unsupported tab resize mode "%s".', char(string(mode))); - end - rows = rows(rows >= 1 & rows <= logicalRows & isfinite(rows)); -end - -function handles = attachTabRowResizeHandles(fig, grid, tabLayout, rowMap, traceCallback) - handles = gobjects(0); - resizeRows = validResizeRows(tabLayout, tabLayout.gridSize(1)); - if isempty(resizeRows) - return; - end - - handles = gobjects(1, numel(resizeRows)); - for k = 1:numel(resizeRows) - topRow = resizeRows(k); - opts = struct('minTopHeight', 80); - if isfield(tabLayout, 'resizeOptions') - opts = mergeStruct(opts, tabLayout.resizeOptions); - end - opts.topRow = rowMap(topRow); - opts.onTrace = traceCallback; - handles(k) = addRowResizeHandle(fig, grid, rowMap(topRow) + 1, opts); - end -end - -function callback = debugTrace(debug) - callback = []; - if isstruct(debug) && isfield(debug, 'trace') && ... - isa(debug.trace, 'function_handle') - callback = debug.trace; - end -end - -function installCloseKeyboardShortcut(fig) - if ~isprop(fig, 'WindowKeyPressFcn') - return; - end - previous = fig.WindowKeyPressFcn; - setappdata(fig, 'labkitUiCloseShortcutPreviousKeyFcn', previous); - fig.WindowKeyPressFcn = @(source, event) closeShortcutKeyPress(source, event); -end - -function closeShortcutKeyPress(fig, event) - if isCloseShortcut(event) - requestFigureClose(fig, event); - return; - end - - previous = []; - if isvalid(fig) && isappdata(fig, 'labkitUiCloseShortcutPreviousKeyFcn') - previous = getappdata(fig, 'labkitUiCloseShortcutPreviousKeyFcn'); - end - runPreviousKeyPress(previous, fig, event); -end - -function tf = isCloseShortcut(event) - tf = false; - if ~hasEventValue(event, 'Key') - return; - end - key = lower(string(eventValue(event, 'Key'))); - if key ~= "w" - return; - end - modifiers = strings(0, 1); - if hasEventValue(event, 'Modifier') - modifiers = lower(string(eventValue(event, 'Modifier'))); - end - tf = any(modifiers == "command") || any(modifiers == "control"); -end - -function tf = hasEventValue(event, name) - if isstruct(event) - tf = isfield(event, name); - else - tf = isprop(event, name); - end -end - -function value = eventValue(event, name) - value = event.(name); -end - -function requestFigureClose(fig, event) - if ~isvalid(fig) - return; - end - closeFcn = fig.CloseRequestFcn; - if isa(closeFcn, 'function_handle') - closeFcn(fig, event); - elseif ischar(closeFcn) || (isstring(closeFcn) && isscalar(closeFcn)) - eval(char(closeFcn)); - else - delete(fig); - end -end - -function runPreviousKeyPress(previous, fig, event) - if isempty(previous) - return; - end - if isa(previous, 'function_handle') - previous(fig, event); - elseif ischar(previous) || (isstring(previous) && isscalar(previous)) - eval(char(previous)); - end -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/+runtime/private/createUtilityBar.m b/+labkit/+ui/+runtime/private/createUtilityBar.m deleted file mode 100644 index e76d1d93f..000000000 --- a/+labkit/+ui/+runtime/private/createUtilityBar.m +++ /dev/null @@ -1,185 +0,0 @@ -% Private UI runtime helper. Expected caller: createTabbedWorkbenchShell. Inputs -% are the app figure, parent grid, and optional utility spec. Side effects: -% creates framework-owned top-level utility menus for plot actions, app -% screenshots, and project save/load. -function panel = createUtilityBar(fig, parent, utilities) - visible = utilityEnabled(utilities, 'Visible', true); - panel = uipanel(parent, ... - 'BorderType', 'none', ... - 'Tag', 'labkitUiUtilityBar', ... - 'Visible', 'off'); - if ~visible - return; - end - - plotMenu = uimenu(fig, 'Text', 'Plot', 'Tag', 'labkitUiUtilityPlotMenu', ... - 'Enable', matlabOnOff(utilityEnabled(utilities, 'Plot', true))); - addMenuItem(plotMenu, "Pop out all plots", "labkitUiUtilityPopout", ... - utilityEnabled(utilities, 'Plot', true), ... - @(~,~) runUtility(fig, @() popoutAllPlots(fig))); - addMenuItem(plotMenu, "Copy all plots", "labkitUiUtilityCopyPlot", ... - utilityEnabled(utilities, 'Plot', true), ... - @(~,~) runUtility(fig, @() copyAllPlots(fig))); - addMenuItem(plotMenu, "Save all plots", "labkitUiUtilitySavePlot", ... - utilityEnabled(utilities, 'Plot', true), ... - @(~,~) runUtility(fig, @() saveAllPlots(fig))); - - if utilityEnabled(utilities, 'Screenshot', true) - addTopLevelMenu(fig, "Screenshot", "labkitUiUtilityScreenshot", ... - @(~,~) runUtility(fig, @() saveAppScreenshot(fig))); - end - if stateUtilityEnabled(utilities) - addTopLevelMenu(fig, "Save State", "labkitUiUtilitySaveState", ... - @(~,~) runUtility(fig, @() saveAppState(fig))); - addTopLevelMenu(fig, "Load State", "labkitUiUtilityLoadState", ... - @(~,~) runUtility(fig, @() loadAppState(fig))); - end -end - -function item = addTopLevelMenu(fig, label, tag, callback) - item = uimenu(fig, ... - 'Text', char(label), ... - 'Tag', char(tag), ... - 'Enable', 'on', ... - 'MenuSelectedFcn', callback); -end - -function item = addMenuItem(parent, label, tag, enabled, callback) - item = uimenu(parent, ... - 'Text', char(label), ... - 'Tag', char(tag), ... - 'Enable', matlabOnOff(enabled), ... - 'MenuSelectedFcn', callback); -end - -function runUtility(fig, action) - try - action(); - catch ME - showAlert(fig, string(ME.message), "LabKit Utility"); - end -end - -function popoutAllPlots(fig) - axesHandles = allWorkbenchAxes(fig); - for k = 1:numel(axesHandles) - labkit.ui.interaction.enablePopout(axesHandles(k)); - menu = findall(axesHandles(k).ContextMenu, 'Type', 'uimenu', ... - 'Tag', 'labkitAxesPopoutMenu'); - menu(1).MenuSelectedFcn(menu(1), []); - end -end - -function copyAllPlots(fig) - axesHandles = allWorkbenchAxes(fig); - if numel(axesHandles) == 1 - copygraphics(axesHandles(1), 'ContentType', 'image'); - return; - end - copygraphics(fig, 'ContentType', 'image'); -end - -function saveAllPlots(fig) - axesHandles = allWorkbenchAxes(fig); - filepath = injectedPath(fig, 'labkitUiUtilityPlotFile'); - if strlength(filepath) == 0 - [file, path] = uiputfile( ... - {'*.png', 'PNG image (*.png)'; '*.pdf', 'PDF file (*.pdf)'}, ... - 'Save Plots'); - if isequal(file, 0) || isequal(path, 0) - return; - end - filepath = string(fullfile(path, file)); - end - for k = 1:numel(axesHandles) - exportgraphics(axesHandles(k), plotFilepath(filepath, axesHandles(k), ... - k, numel(axesHandles)), 'ContentType', 'image'); - end -end - -function axesHandles = allWorkbenchAxes(fig) - axesHandles = currentWorkbenchAxes(fig, "All", true); -end - -function filepath = plotFilepath(basePath, ax, index, count) - filepath = string(basePath); - if count == 1 - return; - end - [folder, name, ext] = fileparts(filepath); - label = axesFileLabel(ax, index); - filepath = string(fullfile(folder, sprintf('%s_%02d_%s%s', ... - char(name), index, char(label), char(ext)))); -end - -function label = axesFileLabel(ax, index) - raw = string(ax.Title.String); - raw = join(raw(:), " "); - label = string(matlab.lang.makeValidName(char(raw))); - if strlength(label) == 0 - label = "plot" + string(index); - end -end - -function saveAppScreenshot(fig) - filepath = injectedPath(fig, 'labkitUiUtilityScreenshotFile'); - if strlength(filepath) == 0 - [file, path] = uiputfile( ... - {'*.png', 'PNG image (*.png)'; '*.pdf', 'PDF file (*.pdf)'}, ... - 'Save App Screenshot'); - if isequal(file, 0) || isequal(path, 0) - return; - end - filepath = string(fullfile(path, file)); - end - exportapp(fig, filepath); -end - -function saveAppState(fig) - filepath = injectedPath(fig, 'labkitUiUtilityStateFile'); - if strlength(filepath) == 0 - labkit.ui.runtime.saveState(fig); - else - labkit.ui.runtime.saveState(fig, filepath); - end -end - -function loadAppState(fig) - filepath = injectedPath(fig, 'labkitUiUtilityStateFile'); - if strlength(filepath) == 0 - labkit.ui.runtime.loadState(fig); - else - labkit.ui.runtime.loadState(fig, filepath); - end -end - -function value = injectedPath(fig, key) - value = ""; - if isappdata(fig, key) - value = string(getappdata(fig, key)); - end -end - -function tf = stateUtilityEnabled(utilities) - if isstruct(utilities) && isfield(utilities, 'State') && ... - string(utilities.State) == "off" - tf = false; - else - tf = true; - end -end - -function tf = utilityEnabled(utilities, field, defaultValue) - tf = defaultValue; - if isstruct(utilities) && isfield(utilities, field) - tf = logical(utilities.(field)); - end -end - -function value = matlabOnOff(tf) - if tf - value = 'on'; - else - value = 'off'; - end -end diff --git a/+labkit/+ui/+runtime/private/createV2DocumentState.m b/+labkit/+ui/+runtime/private/createV2DocumentState.m deleted file mode 100644 index 42946c3aa..000000000 --- a/+labkit/+ui/+runtime/private/createV2DocumentState.m +++ /dev/null @@ -1,24 +0,0 @@ -% Private UI runtime helper. Expected caller: runV2App. Output is framework -% document metadata kept outside semantic app state for project persistence, -% dirty tracking, autosave, recovery, and explicit-file ownership. -function document = createV2DocumentState() - document = struct( ... - "id", newDocumentId(), ... - "createdAtUtc", utcNow(), ... - "modifiedAtUtc", utcNow(), ... - "revision", uint64(0), ... - "path", "", ... - "dirty", false, ... - "loading", false, ... - "exporting", false, ... - "envelope", struct()); -end - -function id = newDocumentId() - id = string(char(java.util.UUID.randomUUID())); -end - -function value = utcNow() - value = string(datetime("now", "TimeZone", "UTC", ... - "Format", "yyyy-MM-dd'T'HH:mm:ss'Z'")); -end diff --git a/+labkit/+ui/+runtime/private/createV2ProjectEnvelope.m b/+labkit/+ui/+runtime/private/createV2ProjectEnvelope.m deleted file mode 100644 index e58fcfd6f..000000000 --- a/+labkit/+ui/+runtime/private/createV2ProjectEnvelope.m +++ /dev/null @@ -1,87 +0,0 @@ -% Private UI runtime helper. Expected caller: v2 project storage. Inputs are -% the current runtime, optional resume data, and actual destination path. -% Output is a validated, serializable labkit.project envelope containing only -% durable app project data with references rebased for that destination. -function envelope = createV2ProjectEnvelope(runtime, resume, filepath) - if nargin < 2 || isempty(resume) - resume = createResume(runtime); - end - if nargin < 3 - filepath = ""; - end - project = rebaseProjectSources(runtime.state.project, filepath); - document = runtime.document; - document.modifiedAtUtc = utcNow(); - envelope = preservedEnvelope(runtime.document.envelope); - envelope.format = "labkit.project"; - envelope.formatVersion = struct("major", 1, "minor", 0); - envelope.app = struct("id", string(runtime.definition.id), ... - "payloadVersion", double(runtime.definition.project.Version)); - envelope.document = struct( ... - "id", document.id, ... - "createdAtUtc", document.createdAtUtc, ... - "modifiedAtUtc", document.modifiedAtUtc, ... - "revision", document.revision); - envelope.producer = producer(runtime); - envelope.sources = projectSources(project); - envelope.payload = project; - envelope.resume = resume; - if ~isfield(envelope, 'provenance') - envelope.provenance = struct(); - end - if ~isfield(envelope, 'extensions') - envelope.extensions = struct(); - end - validateSerializableState(envelope); -end - -function resume = createResume(runtime) - resume = struct(); - spec = runtime.definition.project; - if ~isfield(spec, 'CreateResume') || ... - ~isa(spec.CreateResume, 'function_handle') - return; - end - resume = spec.CreateResume(runtime.state.session, runtime.state.project); - if isempty(resume) - resume = struct(); - end -end - -function envelope = preservedEnvelope(value) - if isstruct(value) && isscalar(value) - envelope = value; - else - envelope = struct(); - end -end - -function info = producer(runtime) - appVersion = ""; - fig = runtime.ui.figure; - if isappdata(fig, 'labkitUiAppVersion') - versionInfo = getappdata(fig, 'labkitUiAppVersion'); - if isstruct(versionInfo) && isfield(versionInfo, 'version') - appVersion = string(versionInfo.version); - end - end - uiVersion = labkit.ui.version(); - info = struct( ... - "appVersion", appVersion, ... - "labkitUiVersion", string(uiVersion.current), ... - "matlabRelease", string(version("-release")), ... - "platform", string(computer)); -end - -function sources = projectSources(project) - sources = struct([]); - if isfield(project, 'inputs') && isstruct(project.inputs) && ... - isfield(project.inputs, 'sources') - sources = project.inputs.sources; - end -end - -function value = utcNow() - value = string(datetime("now", "TimeZone", "UTC", ... - "Format", "yyyy-MM-dd'T'HH:mm:ss'Z'")); -end diff --git a/+labkit/+ui/+runtime/private/createV2State.m b/+labkit/+ui/+runtime/private/createV2State.m deleted file mode 100644 index 88fca26b8..000000000 --- a/+labkit/+ui/+runtime/private/createV2State.m +++ /dev/null @@ -1,42 +0,0 @@ -% Private UI runtime helper. Expected caller: runV2App. Input is a validated -% v2 definition. Output is the canonical project/session semantic state. -function state = createV2State(def) - project = def.project.Create(); - project = addRequiredBuckets(project, ... - ["inputs", "parameters", "annotations", "results", "extensions"], ... - "project"); - session = createSession(def.createSession, project); - session = addRequiredBuckets(session, ... - ["selection", "workflow", "view", "cache"], "session"); - state = struct("project", project, "session", session); - validateV2State(state, def); -end - -function session = createSession(factory, project) - if isempty(factory) - session = struct(); - return; - end - count = nargin(factory); - if count == 0 - session = factory(); - else - session = factory(project); - end -end - -function value = addRequiredBuckets(value, names, label) - if isempty(value) - value = struct(); - end - if ~isstruct(value) || ~isscalar(value) - error('labkit:ui:runtime:InvalidState', ... - 'The %s factory must return a scalar struct.', label); - end - for k = 1:numel(names) - field = char(names(k)); - if ~isfield(value, field) - value.(field) = struct(); - end - end -end diff --git a/+labkit/+ui/+runtime/private/currentWorkbenchAxes.m b/+labkit/+ui/+runtime/private/currentWorkbenchAxes.m deleted file mode 100644 index 038c5e374..000000000 --- a/+labkit/+ui/+runtime/private/currentWorkbenchAxes.m +++ /dev/null @@ -1,46 +0,0 @@ -% Private UI runtime helper. Expected caller: utility bar commands. Input is an -% app figure and optional "All" logical flag. Output is the current valid -% visible preview axes, preferring the most recently interacted axes and -% falling back to a single or first registered visible axes. When All is true, -% output is every registered visible axes in registration order. -function ax = currentWorkbenchAxes(fig, varargin) - allAxes = false; - for k = 1:2:numel(varargin) - if string(varargin{k}) == "All" - allAxes = logical(varargin{k + 1}); - end - end - axesHandles = registeredAxes(fig); - if isempty(axesHandles) - error('labkit:ui:runtime:NoCurrentAxes', ... - 'No LabKit preview axes are available for this utility command.'); - end - if allAxes - ax = axesHandles; - return; - end - if isappdata(fig, 'labkitUiActiveAxes') - active = getappdata(fig, 'labkitUiActiveAxes'); - if ~isempty(active) && isvalid(active) && any(active == axesHandles) - ax = active; - return; - end - end - ax = axesHandles(1); -end - -function axesHandles = registeredAxes(fig) - axesHandles = gobjects(1, 0); - if ~isappdata(fig, 'labkitUiWorkbenchAxes') - return; - end - candidate = getappdata(fig, 'labkitUiWorkbenchAxes'); - for k = 1:numel(candidate) - ax = candidate(k); - if isempty(ax) || ~isvalid(ax) || ~isprop(ax, 'Visible') || ... - ~strcmp(ax.Visible, 'on') - continue; - end - axesHandles(end + 1) = ax; - end -end diff --git a/+labkit/+ui/+runtime/private/defaultDebugLogFile.m b/+labkit/+ui/+runtime/private/defaultDebugLogFile.m deleted file mode 100644 index 2d6a88fc8..000000000 --- a/+labkit/+ui/+runtime/private/defaultDebugLogFile.m +++ /dev/null @@ -1,73 +0,0 @@ -% Private debug-launch artifact helper. Expected caller: -% dispatchRequest. Input is an app entry-point name. Output is a -% writable debug log file path under the canonical LabKit artifact root. Side -% effect: creates one per-launch debug session directory. -function filepath = defaultDebugLogFile(appName) -%DEFAULTDEBUGLOGFILE Return the default app debug trace log path. - - appName = sanitizePathToken(appName, "app"); - artifactsRoot = defaultArtifactsRoot(); - runName = sanitizePathToken(getenv("LABKIT_RUN_NAME"), ""); - - [~, seed] = fileparts(tempname); - sessionName = sprintf('%s_%s', datestr(now, 'yyyymmdd_HHMMSS'), seed); - if strlength(runName) > 0 - logFolder = fullfile(artifactsRoot, "debug", char(runName), char(appName), sessionName); - else - logFolder = fullfile(artifactsRoot, "debug", char(appName), sessionName); - end - logFolder = ensureWritableFolder(logFolder, appName, sessionName); - - filepath = fullfile(logFolder, "trace.log"); -end - -function root = defaultArtifactsRoot() - envRoot = string(getenv("LABKIT_ARTIFACTS")); - if strlength(envRoot) > 0 - root = char(envRoot); - else - root = fullfile(repoRoot(), "artifacts"); - end -end - -function root = repoRoot() - thisFile = mfilename("fullpath"); - privateFolder = fileparts(thisFile); - appFolder = fileparts(privateFolder); - uiFolder = fileparts(appFolder); - labkitFolder = fileparts(uiFolder); - root = fileparts(labkitFolder); -end - -function folder = ensureWritableFolder(folder, appName, sessionName) - try - ensureDirectory(folder); - catch - fallbackRoot = fullfile(tempdir, "LabKit-MATLAB-Workbench", "artifacts"); - runName = sanitizePathToken(getenv("LABKIT_RUN_NAME"), ""); - if strlength(runName) > 0 - folder = fullfile(fallbackRoot, "debug", char(runName), char(appName), sessionName); - else - folder = fullfile(fallbackRoot, "debug", char(appName), sessionName); - end - ensureDirectory(folder); - end -end - -function ensureDirectory(folder) - if exist(folder, "dir") ~= 7 - mkdir(folder); - end -end - -function token = sanitizePathToken(value, fallback) - token = string(value); - if strlength(token) == 0 - token = string(fallback); - end - token = regexprep(token, "[^A-Za-z0-9_.-]+", "_"); - token = regexprep(token, "^_+|_+$", ""); - if strlength(token) == 0 - token = string(fallback); - end -end diff --git a/+labkit/+ui/+runtime/private/defaultDialogFolder.m b/+labkit/+ui/+runtime/private/defaultDialogFolder.m deleted file mode 100644 index 44056f092..000000000 --- a/+labkit/+ui/+runtime/private/defaultDialogFolder.m +++ /dev/null @@ -1,146 +0,0 @@ -% Private UI runtime helper. Returns a safe default folder for dialogs. -function folder = defaultDialogFolder(kind, proposedFolder) -% -% App-facing contract: -% folder = defaultDialogFolder(kind) -% folder = defaultDialogFolder(kind, proposedFolder) -% -% Inputs: -% kind - "input" or "output". Defaults to "input". -% proposedFolder - optional folder preferred by the caller. -% -% Outputs: -% folder - existing folder path outside the LabKit install root. The helper -% uses remembered input/output folders when available, then falls back to -% the user profile, userpath, or tempdir. - - if nargin < 1 || isempty(kind) - kind = "input"; - end - if nargin < 2 - proposedFolder = ""; - end - - kind = normalizeKind(kind); - proposed = existingSafeFolder(proposedFolder); - if strlength(proposed) > 0 - folder = char(proposed); - return; - end - - remembered = existingSafeFolder(rememberedFolder(kind)); - if strlength(remembered) > 0 - folder = char(remembered); - return; - end - - fallback = existingSafeFolder(userFolder()); - if strlength(fallback) == 0 - fallback = string(tempdir); - end - folder = char(fallback); -end - -function kind = normalizeKind(kind) - if ~(ischar(kind) || (isstring(kind) && isscalar(kind))) - error('labkit:ui:runtime:InvalidDialogKind', ... - 'Dialog kind must be "input" or "output".'); - end - kind = lower(strtrim(string(kind))); - if ~any(kind == ["input", "output"]) - error('labkit:ui:runtime:InvalidDialogKind', ... - 'Dialog kind must be "input" or "output".'); - end -end - -function folder = rememberedFolder(kind) - if kind == "output" - prefName = 'LastOutputFolder'; - else - prefName = 'LastInputFolder'; - end - if ispref('LabKit', prefName) - folder = string(getpref('LabKit', prefName)); - else - folder = ""; - end -end - -function folder = userFolder() - folder = string(getenv('USERPROFILE')); - if strlength(folder) > 0 - return; - end - folder = string(getenv('HOME')); - if strlength(folder) > 0 - return; - end - rawUserPath = string(userpath); - if strlength(rawUserPath) == 0 - folder = ""; - return; - end - parts = split(rawUserPath, pathsep); - parts = parts(strlength(parts) > 0); - if isempty(parts) - folder = ""; - else - folder = parts(1); - end -end - -function folder = existingSafeFolder(value) - folder = ""; - if isempty(value) - return; - end - value = string(value); - if isempty(value) - return; - end - value = strtrim(value(1)); - if strlength(value) == 0 || exist(char(value), 'dir') ~= 7 - return; - end - if isInsideLabKitRoot(value) - return; - end - folder = value; -end - -function tf = isInsideLabKitRoot(folder) - root = labkitRoot(); - if strlength(root) == 0 - tf = false; - return; - end - folder = normalizedFolder(folder); - root = normalizedFolder(root); - if ispc - folder = lower(folder); - root = lower(root); - end - tf = folder == root || startsWith(folder, root + filesep); -end - -function root = labkitRoot() - current = string(mfilename('fullpath')); - root = string(fileparts(fileparts(fileparts(fileparts(char(current)))))); -end - -function value = normalizedFolder(value) - value = string(value); - try - value = string(char(java.io.File(char(value)).getCanonicalPath())); - catch - value = string(char(value)); - end - value = eraseTrailingFilesep(value); -end - -function value = eraseTrailingFilesep(value) - value = string(value); - while strlength(value) > 1 && endsWith(value, filesep) - value = extractBefore(value, strlength(value)); - end -end diff --git a/+labkit/+ui/+runtime/private/discoverV2RecoveryFile.m b/+labkit/+ui/+runtime/private/discoverV2RecoveryFile.m deleted file mode 100644 index 30c21cf8b..000000000 --- a/+labkit/+ui/+runtime/private/discoverV2RecoveryFile.m +++ /dev/null @@ -1,31 +0,0 @@ -% Private UI runtime helper. Expected caller: runV2App. Inputs are a v2 -% definition and request. Output is the newest recovery document under the -% app-owned recovery root, or empty text. Discovery never opens a document; -% an explicit recoveryFile or confirmed recover request performs the load. -function filepath = discoverV2RecoveryFile(def, request) - root = ""; - if isstruct(request) && isfield(request, 'recoveryRoot') - root = string(request.recoveryRoot); - end - if strlength(root) == 0 - root = fullfile(prefdir, "LabKit", "recovery"); - end - filepath = ""; - keys = [appStorageKey(def.id), ... - string(matlab.lang.makeValidName(char(def.id)))]; - keys = unique(keys, 'stable'); - candidates = dir(fullfile(root, "__labkit_missing__", "*")); - for k = 1:numel(keys) - appFolder = fullfile(root, keys(k)); - if isfolder(appFolder) - found = dir(fullfile(appFolder, "*", "recovery.mat")); - candidates = [candidates; found(:)]; - end - end - if isempty(candidates) - return; - end - [~, index] = max([candidates.datenum]); - filepath = string(fullfile(candidates(index).folder, ... - candidates(index).name)); -end diff --git a/+labkit/+ui/+runtime/private/dispatchRequest.m b/+labkit/+ui/+runtime/private/dispatchRequest.m deleted file mode 100644 index b0ad1814b..000000000 --- a/+labkit/+ui/+runtime/private/dispatchRequest.m +++ /dev/null @@ -1,130 +0,0 @@ -% Private UI runtime helper. Dispatches launch requests and contract checks. -function [handled, outputs, debugContext] = dispatchRequest(appName, args, nout, varargin) -% -% Internal usage: -% [handled, outputs, debug] = dispatchRequest( ... -% "labkit_Example_app", varargin, nargout); -% [handled, outputs, debug] = dispatchRequest( ... -% "labkit_Example_app", varargin, nargout, "Requirements", req); -% [handled, outputs, debug] = dispatchRequest( ... -% "labkit_Example_app", varargin, nargout, ... -% "Requirements", req, "Version", info); -% -% Inputs: -% appName - app entry-point name used to build app-scoped error IDs. -% args - input argument cell from the app entry point. -% nout - requested output count from the app entry point. -% Name-value options: -% Requirements - optional struct returned by app requirements(). -% Version - optional struct returned by app version(). -% -% Outputs: -% handled - true only when a lightweight request such as "requirements" or -% "version" is consumed; false for normal and debug launches. -% outputs - one-cell output containing the requested app struct for handled -% lightweight requests; empty otherwise. -% debugContext - disabled for normal launches; enabled for "debug" launches. -% Debug launch requests do not consume app launch. Public app debug -% launches write a trace log under artifacts/debug so the last event is -% still available if the GUI freezes before the Log tab can be inspected. - - appName = char(appName); - options = parseOptions(varargin{:}); - handled = false; - outputs = {}; - debugContext = labkit.ui.debug.context(appName, struct('enabled', false)); - - if isempty(args) - assertCompatible(appName, options.Requirements); - return; - end - if ~(ischar(args{1}) || (isstring(args{1}) && isscalar(args{1}))) - error(errorId(appName, 'UnsupportedInput'), ... - '%s does not accept input arguments.', appName); - end - - request = string(args{1}); - if request == "requirements" - if nout > 1 - error(errorId(appName, 'TooManyOutputs'), ... - '%s requirements request returns at most one output.', appName); - end - if numel(args) > 1 - error(errorId(appName, 'UnsupportedInput'), ... - '%s requirements request does not accept options.', appName); - end - handled = true; - outputs = {options.Requirements}; - return; - end - - if request == "version" - if nout > 1 - error(errorId(appName, 'TooManyOutputs'), ... - '%s version request returns at most one output.', appName); - end - if numel(args) > 1 - error(errorId(appName, 'UnsupportedInput'), ... - '%s version request does not accept options.', appName); - end - handled = true; - outputs = {options.Version}; - return; - end - - if isDebugRequest(request) - if nout > 2 - error(errorId(appName, 'TooManyOutputs'), ... - '%s debug mode returns at most the app figure and debug log.', appName); - end - if numel(args) > 1 - error(errorId(appName, 'UnsupportedInput'), ... - '%s debug launch does not accept options.', appName); - end - assertCompatible(appName, options.Requirements); - debugContext = labkit.ui.debug.context(appName, struct( ... - 'enabled', true, ... - 'logFile', defaultDebugLogFile(appName))); - return; - end - - error(errorId(appName, 'UnsupportedInput'), ... - '%s does not accept input arguments.', appName); -end - -function tf = isDebugRequest(request) - tf = request == "debug"; -end - -function options = parseOptions(varargin) - options = struct('Requirements', [], 'Version', []); - if isempty(varargin) - return; - end - if mod(numel(varargin), 2) ~= 0 - error('labkit:ui:runtime:InvalidDispatchOptions', ... - 'Dispatch options must be name-value pairs.'); - end - for k = 1:2:numel(varargin) - name = string(varargin{k}); - switch name - case "Requirements" - options.Requirements = varargin{k + 1}; - case "Version" - options.Version = varargin{k + 1}; - otherwise - error('labkit:ui:runtime:InvalidDispatchOptions', ... - 'Unsupported dispatch option "%s".', name); - end - end -end - -function assertCompatible(appName, requirements) - if ~isempty(requirements) - labkit.contract.assertRequirements(appName, requirements); - end -end - -function id = errorId(appName, suffix) - id = sprintf('%s:%s', appName, suffix); -end diff --git a/+labkit/+ui/+runtime/private/filePanelFilterPatterns.m b/+labkit/+ui/+runtime/private/filePanelFilterPatterns.m deleted file mode 100644 index 4dfebe856..000000000 --- a/+labkit/+ui/+runtime/private/filePanelFilterPatterns.m +++ /dev/null @@ -1,28 +0,0 @@ -% Private filePanel helper. Expected caller: buildFilePanelControl recursive -% expansion. Input is a normalized or raw file filter. Output is a stable string -% column of concrete glob patterns for dir. -function patterns = filePanelFilterPatterns(filters) - if ischar(filters) || isstring(filters) - raw = string(filters); - elseif iscell(filters) - raw = string(filters(:, 1)); - else - raw = "*.*"; - end - patternParts = cell(numel(raw), 1); - for k = 1:numel(raw) - tokens = split(raw(k), ';'); - tokens = strtrim(tokens); - tokens = tokens(strlength(tokens) > 0); - patternParts{k} = tokens(:); - end - patterns = vertcat(patternParts{:}); - if isempty(patterns) - patterns = "*.*"; - end - patterns = unique(patterns, 'stable'); - concretePatterns = patterns(patterns ~= "*.*" & patterns ~= "*"); - if ~isempty(concretePatterns) - patterns = concretePatterns; - end -end diff --git a/+labkit/+ui/+runtime/private/filePanelNormalizePathList.m b/+labkit/+ui/+runtime/private/filePanelNormalizePathList.m deleted file mode 100644 index cefbe96b1..000000000 --- a/+labkit/+ui/+runtime/private/filePanelNormalizePathList.m +++ /dev/null @@ -1,19 +0,0 @@ -% Private filePanel helper. Expected caller: buildFilePanelControl and its -% adapter callbacks. Input is a user or test supplied path list. Output is a -% nonempty-column string array. Side effects: none. -function paths = filePanelNormalizePathList(value) - if isempty(value) - paths = strings(0, 1); - elseif ischar(value) - paths = string({value}); - elseif isstring(value) - paths = value; - elseif iscell(value) - paths = string(value); - else - error('labkit:ui:runtime:InvalidFilePathList', ... - 'filePanel values must be char, string, or a cell array.'); - end - paths = paths(:); - paths = paths(strlength(paths) > 0); -end diff --git a/+labkit/+ui/+runtime/private/filePanelScalarText.m b/+labkit/+ui/+runtime/private/filePanelScalarText.m deleted file mode 100644 index 338949cc0..000000000 --- a/+labkit/+ui/+runtime/private/filePanelScalarText.m +++ /dev/null @@ -1,11 +0,0 @@ -% Private filePanel helper. Expected caller: buildFilePanelControl. Inputs -% are file-entry text values that may be empty, scalar, or non-scalar. Output -% is a scalar string fallback or the first supplied text value. -function value = filePanelScalarText(rawValue, fallback) - value = string(rawValue); - if isempty(value) - value = string(fallback); - return; - end - value = value(1); -end diff --git a/+labkit/+ui/+runtime/private/formatFileLabels.m b/+labkit/+ui/+runtime/private/formatFileLabels.m deleted file mode 100644 index 8a0a68bdf..000000000 --- a/+labkit/+ui/+runtime/private/formatFileLabels.m +++ /dev/null @@ -1,162 +0,0 @@ -% Private UI runtime helper. Expected caller: filePanel construction. Inputs -% are source paths and optional status labels; output is stable short labels. -function labels = formatFileLabels(paths, varargin) -% -% Internal contract: -% labels = formatFileLabels(paths, "status", status) -% -% Inputs: -% paths - char, string array, or cell array of file paths. -% status - optional status label per path. Empty status values are omitted. -% -% Output: -% labels - cell column of labels in the form "01 name.ext", -% "02 name.ext (parent)", or "03 name.ext [status]". - - opts = parseOptions(varargin); - paths = normalizePaths(paths); - status = normalizeStatus(optionValue(opts, 'status', strings(0, 1)), numel(paths)); - names = baseNames(paths); - disambiguators = disambiguatorsFor(paths, names); - width = max(2, numel(char(string(numel(paths))))); - labels = cell(numel(paths), 1); - for k = 1:numel(paths) - label = sprintf(['%0' num2str(width) 'd %s'], k, char(names(k))); - if strlength(disambiguators(k)) > 0 - label = sprintf('%s (%s)', label, char(disambiguators(k))); - end - if strlength(status(k)) > 0 - label = sprintf('%s [%s]', label, char(status(k))); - end - labels{k} = label; - end -end - -function opts = parseOptions(args) - if mod(numel(args), 2) ~= 0 - error('labkit:ui:control:InvalidFileLabelOptions', ... - 'fileLabels 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 paths = normalizePaths(paths) - if isempty(paths) - paths = strings(0, 1); - elseif ischar(paths) - paths = string({paths}); - elseif isstring(paths) - paths = paths(:); - elseif iscell(paths) - paths = string(paths(:)); - else - error('labkit:ui:control:InvalidFileLabelPaths', ... - 'fileLabels paths must be char, string, or a cell array.'); - end - paths = paths(strlength(paths) > 0); -end - -function status = normalizeStatus(status, count) - if isempty(status) - status = strings(count, 1); - elseif ischar(status) - status = repmat(string(status), count, 1); - elseif isstring(status) - status = status(:); - elseif iscell(status) - status = string(status(:)); - else - status = strings(count, 1); - end - if numel(status) == 1 && count > 1 - status = repmat(status, count, 1); - end - if numel(status) ~= count - error('labkit:ui:control:InvalidFileLabelStatus', ... - 'fileLabels status must be empty, scalar, or match paths.'); - end -end - -function names = baseNames(paths) - names = strings(numel(paths), 1); - for k = 1:numel(paths) - [~, base, ext] = fileparts(char(paths(k))); - names(k) = string([base ext]); - if strlength(names(k)) == 0 - names(k) = paths(k); - end - end -end - -function labels = disambiguatorsFor(paths, names) - labels = strings(numel(paths), 1); - for k = 1:numel(paths) - same = find(names == names(k)); - if numel(same) < 2 - continue; - end - labels(k) = shortestUniqueParent(paths(k), paths(same)); - end -end - -function label = shortestUniqueParent(pathValue, peerPaths) - parts = parentParts(pathValue); - peerParts = cell(numel(peerPaths), 1); - for k = 1:numel(peerPaths) - peerParts{k} = parentParts(peerPaths(k)); - end - - label = ""; - for depth = 1:max(1, numel(parts)) - candidate = suffixParts(parts, depth); - uniqueCandidate = true; - for k = 1:numel(peerParts) - if string(peerPaths(k)) == string(pathValue) - continue; - end - if candidate == suffixParts(peerParts{k}, depth) - uniqueCandidate = false; - break; - end - end - if uniqueCandidate || depth == numel(parts) - label = candidate; - return; - end - end -end - -function parts = parentParts(pathValue) - [folder, ~, ~] = fileparts(char(pathValue)); - if isempty(folder) - parts = strings(0, 1); - return; - end - folder = strrep(folder, '\', filesep); - raw = split(string(folder), filesep); - raw = raw(strlength(raw) > 0); - if isempty(raw) - parts = string(folder); - else - parts = raw(:); - end -end - -function text = suffixParts(parts, depth) - if isempty(parts) - text = ""; - return; - end - depth = min(depth, numel(parts)); - text = strjoin(parts(end-depth+1:end), filesep); -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/+runtime/private/getAppRuntime.m b/+labkit/+ui/+runtime/private/getAppRuntime.m deleted file mode 100644 index 42a0e5629..000000000 --- a/+labkit/+ui/+runtime/private/getAppRuntime.m +++ /dev/null @@ -1,10 +0,0 @@ -% Private UI runtime helper. Expected caller: app runtime services that need the -% stored LabKit runtime from a figure. Input is a figure handle. Output is the -% runtime struct installed by the Runtime V2 launcher. -function runtime = getAppRuntime(fig) - if isempty(fig) || ~isvalid(fig) || ~isappdata(fig, appRuntimeKey()) - error('labkit:ui:runtime:MissingRuntime', ... - 'The figure does not have a LabKit app runtime.'); - end - runtime = getappdata(fig, appRuntimeKey()); -end diff --git a/+labkit/+ui/+runtime/private/getReadonlyText.m b/+labkit/+ui/+runtime/private/getReadonlyText.m deleted file mode 100644 index 6c27aec99..000000000 --- a/+labkit/+ui/+runtime/private/getReadonlyText.m +++ /dev/null @@ -1,7 +0,0 @@ -% Private UI runtime helper. Expected caller: readonly field adapters. Input is a -% MATLAB UI handle with a Value property. Output is the display text as a char -% row, preserving multi-line text entries. -function text = getReadonlyText(control) - value = control.Value; - text = char(join(string(value(:)), newline)); -end diff --git a/+labkit/+ui/+runtime/private/installCloseGuard.m b/+labkit/+ui/+runtime/private/installCloseGuard.m deleted file mode 100644 index 248aff2a3..000000000 --- a/+labkit/+ui/+runtime/private/installCloseGuard.m +++ /dev/null @@ -1,195 +0,0 @@ -% Private UI runtime helper. Expected caller: labkit.ui.runtime.create. Input is the -% app figure. Side effects: installs a close-request guard that confirms before -% closing LabKit apps, with stronger wording when the app is busy. -function installCloseGuard(fig) - if ~isLiveFigure(fig) - return; - end - - previous = fig.CloseRequestFcn; - setappdata(fig, 'labkitUiClosePreviousFcn', previous); - fig.CloseRequestFcn = @(source, event) onCloseRequest(source, event); -end - -function onCloseRequest(fig, event) - if shouldConfirmClose(fig) && ~confirmClose(fig) - return; - end - - closeWithoutConfirm(fig, event); -end - -function closeWithoutConfirm(fig, event) - clearClosePrompt(fig); - previous = []; - if isLiveFigure(fig) && isappdata(fig, 'labkitUiClosePreviousFcn') - previous = getappdata(fig, 'labkitUiClosePreviousFcn'); - end - if ~isempty(previous) - runPreviousCloseRequest(previous, fig, event); - elseif isLiveFigure(fig) - delete(fig); - end -end - -function tf = shouldConfirmClose(fig) - tf = isLiveFigure(fig); -end - -function tf = isBusy(fig) - tf = false; - try - tf = isappdata(fig, 'labkitUiBusy') && logical(getappdata(fig, 'labkitUiBusy')); - catch - tf = false; - end -end - -function tf = confirmClose(fig) - message = closeMessage(fig); - if isappdata(fig, 'labkitUiCloseConfirmFcn') - tf = normalizeResponse(getappdata(fig, 'labkitUiCloseConfirmFcn'), ... - fig, message); - return; - end - - if closePromptArmed(fig) - tf = true; - return; - end - - showClosePrompt(fig, message); - tf = false; -end - -function message = closeMessage(fig) - if isBusy(fig) - message = "LabKit is still working. Close anyway?"; - return; - end - if isDirtyProject(fig) - message = "This project has unsaved changes. Close anyway?"; - return; - end - message = "Close this LabKit app?"; -end - -function tf = isDirtyProject(fig) - tf = false; - if ~isappdata(fig, 'labkitUiAppRuntime') - return; - end - runtime = getappdata(fig, 'labkitUiAppRuntime'); - tf = isstruct(runtime) && isfield(runtime, 'document') && ... - logical(runtime.document.dirty); -end - -function tf = normalizeResponse(confirmFcn, fig, message) - try - response = confirmFcn(fig, message); - catch - tf = false; - return; - end - if islogical(response) - tf = isscalar(response) && response; - else - tf = any(strcmpi(string(response), ["close", "yes", "true", "ok"])); - end -end - -function showClosePrompt(fig, message) - clearClosePrompt(fig); - setappdata(fig, 'labkitUiClosePromptArmed', true); - - panel = uipanel(fig, ... - 'Title', 'Close LabKit app?', ... - 'Tag', 'labkitUiClosePrompt', ... - 'Position', promptPosition(fig)); - setappdata(fig, 'labkitUiClosePromptPanel', panel); - - grid = uigridlayout(panel, [2 3]); - grid.RowHeight = {'1x', 34}; - grid.ColumnWidth = {'1x', 86, 86}; - grid.Padding = [10 8 10 8]; - grid.RowSpacing = 6; - grid.ColumnSpacing = 8; - - label = uilabel(grid, ... - 'Text', char(string(message) + " Close again to confirm."), ... - 'WordWrap', 'on', ... - 'FontWeight', 'bold'); - label.Layout.Row = 1; - label.Layout.Column = [1 3]; - - closeButton = uibutton(grid, ... - 'Text', 'Close', ... - 'ButtonPushedFcn', @(~, event) closeWithoutConfirm(fig, event)); - closeButton.Layout.Row = 2; - closeButton.Layout.Column = 2; - - cancelButton = uibutton(grid, ... - 'Text', 'Cancel', ... - 'ButtonPushedFcn', @(~, ~) clearClosePrompt(fig)); - cancelButton.Layout.Row = 2; - cancelButton.Layout.Column = 3; - drawnow; -end - -function tf = closePromptArmed(fig) - tf = false; - try - tf = isLiveFigure(fig) && isappdata(fig, 'labkitUiClosePromptArmed') && ... - logical(getappdata(fig, 'labkitUiClosePromptArmed')); - catch - tf = false; - end -end - -function clearClosePrompt(fig) - if ~isLiveFigure(fig) - return; - end - if isappdata(fig, 'labkitUiClosePromptArmed') - rmappdata(fig, 'labkitUiClosePromptArmed'); - end - if isappdata(fig, 'labkitUiClosePromptPanel') - panel = getappdata(fig, 'labkitUiClosePromptPanel'); - if isvalid(panel) - delete(panel); - end - rmappdata(fig, 'labkitUiClosePromptPanel'); - end -end - -function pos = promptPosition(fig) - width = 430; - height = 118; - figPos = fig.Position; - promptWidth = min(width, max(160, figPos(3) - 24)); - x = max(12, (figPos(3) - promptWidth) / 2); - y = max(12, figPos(4) - height - 44); - pos = [x y promptWidth height]; -end - -function runPreviousCloseRequest(previous, fig, event) - if isa(previous, 'function_handle') - previous(fig, event); - elseif ischar(previous) || (isstring(previous) && isscalar(previous)) - eval(char(previous)); - elseif isLiveFigure(fig) - delete(fig); - end -end - -function tf = isLiveFigure(fig) - tf = ~isempty(fig); - if ~tf - return; - end - try - tf = all(isvalid(fig)) && isprop(fig, 'CloseRequestFcn'); - catch - tf = false; - end -end diff --git a/+labkit/+ui/+runtime/private/installPreviewScrollNavigation.m b/+labkit/+ui/+runtime/private/installPreviewScrollNavigation.m deleted file mode 100644 index 734623fb6..000000000 --- a/+labkit/+ui/+runtime/private/installPreviewScrollNavigation.m +++ /dev/null @@ -1,240 +0,0 @@ -% Private UI runtime helper. Expected caller: buildWorkspace preview-area setup. -% Inputs are the app figure and preview axes. Output mutates the figure by -% installing one target-gated scroll dispatcher for all previewArea axes. -function installPreviewScrollNavigation(fig, axesHandles) - if ~isValidHandle(fig) - return; - end - axesHandles = normalizeAxes(axesHandles); - if isempty(axesHandles) - return; - end - - key = 'labkitPreviewScrollNavigation'; - if isappdata(fig, key) - state = getappdata(fig, key); - else - state = struct(); - state.axes = gobjects(1, 0); - state.preparedAxes = gobjects(1, 0); - state.fallbackScrollFcn = fig.WindowScrollWheelFcn; - state.callback = @onPreviewScroll; - end - if ~isfield(state, 'preparedAxes') - state.preparedAxes = gobjects(1, 0); - end - - state.axes = uniqueAxes([normalizeAxes(state.axes), axesHandles]); - state.preparedAxes = axesInSet(normalizeAxes(state.preparedAxes), state.axes); - setappdata(fig, key, state); - - current = fig.WindowScrollWheelFcn; - if isempty(current) || isequal(current, state.fallbackScrollFcn) || ... - isequal(current, state.callback) - fig.WindowScrollWheelFcn = state.callback; - end - - function onPreviewScroll(src, event) - if ~isappdata(src, key) - return; - end - navState = getappdata(src, key); - ax = axesUnderPointer(src, navState.axes); - if isempty(ax) - callFallback(navState.fallbackScrollFcn, src, event); - return; - end - scrollCount = scrollCountFromEvent(event); - if scrollCount == 0 - return; - end - navState = prepareAxesForWheelNavigation(src, key, navState, ax); - point = ax.CurrentPoint; - zoomAxesAtPoint(ax, point(1, 1:2), scrollCount, ... - "ZoomAxes", scrollZoomAxes(ax)); - end -end - -function zoomAxes = scrollZoomAxes(ax) - zoomAxes = "xy"; - try - if isappdata(ax, 'labkitPreviewScrollZoomAxes') - zoomAxes = string(getappdata(ax, 'labkitPreviewScrollZoomAxes')); - end - catch - end -end - -function ax = axesUnderPointer(fig, axesHandles) - ax = []; - try - hit = hittest(fig); - hitAxes = uiAxesAncestor(hit); - catch - hitAxes = []; - end - if isempty(hitAxes) || ~isValidHandle(hitAxes) - ax = axesUnderFigurePoint(fig, axesHandles); - return; - end - axesHandles = normalizeAxes(axesHandles); - for k = 1:numel(axesHandles) - if isequal(hitAxes, axesHandles(k)) - ax = hitAxes; - return; - end - end -end - -function ax = axesUnderFigurePoint(fig, axesHandles) - ax = []; - axesHandles = normalizeAxes(axesHandles); - if isempty(axesHandles) - return; - end - try - point = fig.CurrentPoint; - catch - return; - end - if isempty(point) || numel(point) < 2 || any(~isfinite(point(1, 1:2))) - return; - end - x = point(1, 1); - y = point(1, 2); - for k = 1:numel(axesHandles) - try - pos = getpixelposition(axesHandles(k), true); - catch - continue; - end - if x >= pos(1) && x <= pos(1) + pos(3) && ... - y >= pos(2) && y <= pos(2) + pos(4) - ax = axesHandles(k); - return; - end - end -end - -function ax = uiAxesAncestor(handle) - ax = []; - current = handle; - while ~isempty(current) && isValidHandle(current) - if isa(current, 'matlab.ui.control.UIAxes') - ax = current; - return; - end - if ~isprop(current, 'Parent') - return; - end - current = current.Parent; - end -end - -function count = scrollCountFromEvent(event) - count = 0; - if isstruct(event) && isfield(event, 'VerticalScrollCount') - count = event.VerticalScrollCount; - elseif isobject(event) && isprop(event, 'VerticalScrollCount') - count = event.VerticalScrollCount; - end - if isempty(count) || ~isnumeric(count) || ~isscalar(count) || ~isfinite(count) - count = 0; - end -end - -function callFallback(fcn, src, event) - if isempty(fcn) - return; - end - if isa(fcn, 'function_handle') - fcn(src, event); - elseif iscell(fcn) && ~isempty(fcn) - feval(fcn{1}, src, event, fcn{2:end}); - elseif ischar(fcn) || isstring(fcn) - feval(char(fcn), src, event); - end -end - -function axesHandles = normalizeAxes(axesHandles) - if isempty(axesHandles) - axesHandles = gobjects(1, 0); - return; - end - axesHandles = axesHandles(:).'; - keep = false(size(axesHandles)); - for k = 1:numel(axesHandles) - keep(k) = isValidHandle(axesHandles(k)) && ... - isa(axesHandles(k), 'matlab.ui.control.UIAxes'); - end - axesHandles = axesHandles(keep); -end - -function axesHandles = uniqueAxes(axesHandles) - keep = true(size(axesHandles)); - for k = 1:numel(axesHandles) - if ~keep(k) - continue; - end - for j = k + 1:numel(axesHandles) - if isequal(axesHandles(k), axesHandles(j)) - keep(j) = false; - end - end - end - axesHandles = axesHandles(keep); -end - -function state = prepareAxesForWheelNavigation(fig, key, state, ax) - if axesContains(state.preparedAxes, ax) - return; - end - disableBuiltInWheelNavigation(ax); - state.preparedAxes = uniqueAxes([normalizeAxes(state.preparedAxes), ax]); - if isValidHandle(fig) - setappdata(fig, key, state); - end -end - -function disableBuiltInWheelNavigation(ax) - try - disableDefaultInteractivity(ax); - catch - end - try - ax.Interactions = []; - catch - end - try - if ~strcmp(ax.Toolbar.Visible, 'on') - ax.Toolbar.Visible = 'on'; - end - catch - end -end - -function axesHandles = axesInSet(axesHandles, allowedAxes) - keep = false(size(axesHandles)); - for k = 1:numel(axesHandles) - keep(k) = axesContains(allowedAxes, axesHandles(k)); - end - axesHandles = axesHandles(keep); -end - -function tf = axesContains(axesHandles, ax) - tf = false; - axesHandles = normalizeAxes(axesHandles); - if ~isValidHandle(ax) - return; - end - for k = 1:numel(axesHandles) - if isequal(axesHandles(k), ax) - tf = true; - return; - end - end -end - -function tf = isValidHandle(h) - tf = ~isempty(h) && all(isvalid(h)); -end diff --git a/+labkit/+ui/+runtime/private/layoutRowHeight.m b/+labkit/+ui/+runtime/private/layoutRowHeight.m deleted file mode 100644 index 54dc0d486..000000000 --- a/+labkit/+ui/+runtime/private/layoutRowHeight.m +++ /dev/null @@ -1,224 +0,0 @@ -% Private UI runtime layout helper. Expected caller: buildShellFromLayout and -% buildSection. Inputs are one validated declarative spec and an optional default -% row height. Output is a MATLAB uigridlayout RowHeight value. -function value = layoutRowHeight(spec, defaultValue) - if nargin < 2 - defaultValue = 'fit'; - end - - switch spec.kind - case 'section' - value = sectionHeight(spec, defaultValue); - case 'statusPanel' - value = textPanelHeight(4, 120); - case 'usagePanel' - value = textPanelHeight(3, 105); - case 'logPanel' - value = textPanelHeight(8, 240); - case 'filePanel' - value = filePanelHeight(spec); - case 'field' - value = fieldHeight(spec); - case 'action' - value = actionHeight(spec); - case 'resultTable' - value = tablePanelHeight(); - case 'group' - value = groupHeight(spec); - otherwise - value = normalizeHeight(defaultValue); - end -end - -function value = sectionHeight(sectionSpec, defaultValue) - if isempty(sectionSpec.children) - value = normalizeHeight(defaultValue); - return; - end - - rowHeights = zeros(1, numel(sectionSpec.children)); - for k = 1:numel(sectionSpec.children) - childHeight = layoutRowHeight(sectionSpec.children{k}, 'fit'); - rowHeights(k) = numericRowHeight(childHeight, defaultControlHeight()); - end - - titleAllowance = sectionTitleAllowance(sectionSpec); - value = sum(rowHeights) + 8 * max(0, numel(rowHeights) - 1) + ... - 16 + titleAllowance; -end - -function value = textPanelHeight(defaultRows, defaultMinHeight) - value = max(defaultMinHeight, 22 * max(1, double(defaultRows)) + 58); -end - -function value = filePanelHeight(spec) - if strcmp(char(string(optionValue(spec.props, 'mode', 'multi'))), 'single') - value = 72; - return; - end - if ~logical(optionValue(spec.props, 'showStatus', true)) - rows = 5; - value = max(170, 22 * max(1, double(rows)) + 64); - return; - end - rows = 6; - value = max(185, 22 * max(1, double(rows)) + 104); -end - -function value = tablePanelHeight() - rows = 6; - value = max(185, 24 * max(1, double(rows)) + 58); -end - -function value = groupHeight(groupSpec) - if ~usesActionLayout(groupSpec) - value = formGroupHeight(groupSpec); - return; - end - value = actionLayoutHeight(groupSpec); -end - -function tf = usesActionLayout(groupSpec) - layout = lower(char(string(optionValue(groupSpec.props, 'layout', 'auto')))); - childKinds = string(cellfun(@(child) child.kind, groupSpec.children, ... - 'UniformOutput', false)); - tf = strcmp(layout, 'actions') || ... - (strcmp(layout, 'auto') && all(childKinds == "action")); -end - -function value = actionLayoutHeight(groupSpec) - count = numel(groupSpec.children); - if count == 0 - value = defaultControlHeight(); - return; - end - maxColumns = actionLayoutMaxColumns(groupSpec); - columnCount = min(count, maxColumns); - rowCount = max(1, ceil(count / columnCount)); - rowHeight = max(defaultControlHeight(), max(actionHeights(groupSpec.children))); - value = rowCount * rowHeight + ... - max(0, rowCount - 1) * 6; -end - -function value = formGroupHeight(groupSpec) - if isempty(groupSpec.children) - value = defaultControlHeight(); - return; - end - rowHeights = zeros(1, numel(groupSpec.children)); - for k = 1:numel(groupSpec.children) - childHeight = layoutRowHeight(groupSpec.children{k}, 'fit'); - rowHeights(k) = numericRowHeight(childHeight, defaultControlHeight()); - end - titleAllowance = 0; - if strlength(string(optionValue(groupSpec.props, 'title', ''))) > 0 - titleAllowance = 24; - end - value = sum(rowHeights) + 6 * max(0, numel(rowHeights) - 1) + ... - titleAllowance; -end - -function value = fieldHeight(fieldSpec) - props = fieldSpec.props; - kind = lower(char(string(optionValue(props, 'kind', 'text')))); - label = string(optionValue(props, 'label', fieldSpec.id)); - if strcmp(kind, 'readonly') - valueText = string(optionValue(props, 'value', '')); - value = estimatedTextHeight([label valueText], 34, 3); - elseif strcmp(kind, 'checkbox') - value = estimatedTextHeight(label, 42, 2); - else - value = estimatedTextHeight(label, 30, 2); - end -end - -function value = actionHeight(actionSpec) - label = string(optionValue(actionSpec.props, 'label', actionSpec.id)); - value = estimatedTextHeight(label, 22, 2); -end - -function values = actionHeights(actions) - values = zeros(1, max(1, numel(actions))); - for k = 1:numel(actions) - values(k) = actionHeight(actions{k}); - end -end - -function value = estimatedTextHeight(texts, charsPerLine, maxLines) - text = join(string(texts(:)), " "); - lineCount = max(1, ceil(double(max(strlength(splitlines(text)))) ./ charsPerLine)); - lineCount = min(maxLines, lineCount); - value = max(defaultControlHeight(), 20 * lineCount + 6); -end - -function maxColumns = actionLayoutMaxColumns(groupSpec) - maxColumns = 2; - labels = actionLabels(groupSpec.children); - if any(strlength(labels) > 28) - maxColumns = 1; - end -end - -function labels = actionLabels(actions) - labels = strings(1, numel(actions)); - for k = 1:numel(actions) - labels(k) = string(optionValue(actions{k}.props, ... - 'label', actions{k}.id)); - end -end - -function height = numericRowHeight(value, fallback) - if isnumeric(value) && isscalar(value) && isfinite(value) - height = max(1, double(value)); - return; - end - - text = lower(char(string(value))); - switch text - case {'fit'} - height = fallback; - otherwise - height = fallback; - end -end - -function value = normalizeHeight(value) - if ischar(value) || isstring(value) - text = char(string(value)); - switch lower(text) - case {'flex', 'fill', 'grow'} - value = '1x'; - otherwise - value = text; - end - end -end - -function height = defaultControlHeight() - height = 26; -end - -function value = sectionTitleAllowance(sectionSpec) - value = 0; - if sectionDrawsOwnTitle(sectionSpec) - value = 28; - end -end - -function tf = sectionDrawsOwnTitle(sectionSpec) - tf = true; - if numel(sectionSpec.children) ~= 1 - return; - end - child = sectionSpec.children{1}; - tf = ~ismember(child.kind, ... - {'previewArea', 'resultTable', 'logPanel', 'statusPanel', ... - 'usagePanel', 'filePanel'}); -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/+runtime/private/normalizeFilePanelFilters.m b/+labkit/+ui/+runtime/private/normalizeFilePanelFilters.m deleted file mode 100644 index 83eb3fa62..000000000 --- a/+labkit/+ui/+runtime/private/normalizeFilePanelFilters.m +++ /dev/null @@ -1,22 +0,0 @@ -% Private filePanel helper. Expected caller: buildFilePanelControl. Input is a -% uigetfile-style filter spec. Output is a normalized filter accepted by -% uigetfile and recursive file-pattern expansion. -function filters = normalizeFilePanelFilters(filters) - if iscell(filters) && numel(filters) == 1 && iscell(filters{1}) - filters = filters{1}; - end - if ischar(filters) - return; - end - if isstring(filters) - if isscalar(filters) - filters = char(filters); - return; - end - filters = cellstr(filters); - end - if iscell(filters) - filters = cellfun(@(value) char(string(value)), filters, ... - 'UniformOutput', false); - end -end diff --git a/+labkit/+ui/+runtime/private/prepareV2Layout.m b/+labkit/+ui/+runtime/private/prepareV2Layout.m deleted file mode 100644 index 2d6f1fbd1..000000000 --- a/+labkit/+ui/+runtime/private/prepareV2Layout.m +++ /dev/null @@ -1,179 +0,0 @@ -% Private UI runtime helper. Expected caller: runV2App. Inputs are a v2 -% definition, generated action callbacks, and canonical initial state. Outputs -% are the data-only layout with binding callbacks installed and the binding -% inventory used during presentation commits. -function [layout, bindings] = prepareV2Layout(def, callbacks, state, bindingCallback) - layout = invokeLayout(def.layout, callbacks, state); - bindings = emptyBindings(); - actionIds = string(fieldnames(def.actions)); - [layout, bindings] = visitValue( ... - layout, state, bindingCallback, bindings, actionIds); - layout.props.utilities = v2Utilities(def.utilities, layout); -end - -function utilities = v2Utilities(overrides, layout) - utilities = struct( ... - "Visible", true, ... - "Plot", containsLayoutKind(layout, "previewArea"), ... - "Screenshot", true, ... - "State", "on"); - if isempty(overrides) - return; - end - names = fieldnames(overrides); - for k = 1:numel(names) - utilities.(names{k}) = overrides.(names{k}); - end -end - -function tf = containsLayoutKind(value, target) - tf = false; - if iscell(value) - for k = 1:numel(value) - if containsLayoutKind(value{k}, target) - tf = true; - return; - end - end - return; - end - if ~isstruct(value) - return; - end - for element = 1:numel(value) - if isfield(value(element), 'kind') && ... - string(value(element).kind) == target - tf = true; - return; - end - names = fieldnames(value(element)); - for k = 1:numel(names) - if containsLayoutKind(value(element).(names{k}), target) - tf = true; - return; - end - end - end -end - -function layout = invokeLayout(factory, callbacks, state) - count = nargin(factory); - if count == 0 - layout = factory(); - elseif count == 1 - layout = factory(callbacks); - else - layout = factory(callbacks, state); - end -end - -function [value, bindings] = visitValue( ... - value, state, callback, bindings, actionIds) - if iscell(value) - for k = 1:numel(value) - [value{k}, bindings] = visitValue( ... - value{k}, state, callback, bindings, actionIds); - end - return; - end - if ~isstruct(value) - return; - end - for element = 1:numel(value) - if isLayoutNode(value(element)) - [value(element), bindings] = bindNode( ... - value(element), state, callback, bindings, actionIds); - end - fields = fieldnames(value(element)); - for k = 1:numel(fields) - field = fields{k}; - if strcmp(field, 'props') && isLayoutNode(value(element)) - continue; - end - [value(element).(field), bindings] = visitValue( ... - value(element).(field), state, callback, bindings, actionIds); - end - if isLayoutNode(value(element)) - [value(element).props, bindings] = visitValue( ... - value(element).props, state, callback, bindings, actionIds); - end - end -end - -function tf = isLayoutNode(value) - tf = isfield(value, 'kind') && isfield(value, 'id') && ... - isfield(value, 'props') && isfield(value, 'children'); -end - -function [node, bindings] = bindNode( ... - node, state, callback, bindings, actionIds) - [hasBinding, path] = propertyValue(node.props, "Bind"); - [hasEvent, eventId] = propertyValue(node.props, "Event"); - if ~hasBinding - if hasEvent - error('labkit:ui:runtime:UnboundLayoutEvent', ... - ['Layout control "%s" declares Event "%s" without Bind. ' ... - 'Add Bind or wire an explicit onChange callback.'], ... - string(node.id), string(eventId)); - end - return; - end - path = string(path); - assertBindingPath(path); - if ~hasEvent - eventId = ""; - else - eventId = string(eventId); - if ~isscalar(eventId) || strlength(eventId) == 0 || ... - ~any(actionIds == eventId) - error('labkit:ui:runtime:UnknownLayoutAction', ... - 'Layout control "%s" references unknown action "%s".', ... - string(node.id), join(eventId, ", ")); - end - end - current = valueAtPath(state, path); - node.props.value = current; - node.props.onChange = @(control, event) callback( ... - control, event, path, string(eventId)); - binding = struct("id", string(node.id), "path", path, ... - "eventId", string(eventId)); - bindings(end + 1) = binding; -end - -function assertBindingPath(path) - parts = split(path, "."); - if ~isscalar(path) || strlength(path) == 0 || numel(parts) < 3 || ... - ~any(parts(1) == ["project", "session"]) || ... - any(strlength(parts) == 0) - error('labkit:ui:runtime:InvalidBinding', ... - 'Bind must name a project or session state path.'); - end -end - -function value = valueAtPath(state, path) - parts = split(path, "."); - value = state; - for k = 1:numel(parts) - field = char(parts(k)); - if ~isstruct(value) || ~isscalar(value) || ~isfield(value, field) - error('labkit:ui:runtime:InvalidBinding', ... - 'Bind path "%s" does not exist in initial state.', path); - end - value = value.(field); - end -end - -function [found, value] = propertyValue(props, name) - found = false; - value = []; - names = string(fieldnames(props)); - index = find(strcmpi(names, name), 1, 'first'); - if ~isempty(index) - found = true; - value = props.(char(names(index))); - end -end - -function bindings = emptyBindings() - bindings = struct("id", {}, "path", {}, "eventId", {}); -end diff --git a/+labkit/+ui/+runtime/private/promptOutputFile.m b/+labkit/+ui/+runtime/private/promptOutputFile.m deleted file mode 100644 index d3092b6ca..000000000 --- a/+labkit/+ui/+runtime/private/promptOutputFile.m +++ /dev/null @@ -1,105 +0,0 @@ -% Private UI runtime helper. Prompts for an output file with safe defaults. -function [filepath, cancelled, file, folder] = promptOutputFile(filterSpec, titleText, defaultPath, varargin) -% -% App-facing contract: -% filepath = promptOutputFile(filterSpec, title, defaultPath) -% [filepath, cancelled] = promptOutputFile(...) -% [...] = promptOutputFile(..., "Chooser", chooserFcn) -% -% Inputs: -% filterSpec - file filter or default filename accepted by uiputfile. -% titleText - dialog title. Defaults to "Save output file". -% defaultPath - preferred output path or filename. Its folder is passed -% through defaultDialogFolder("output", folder). -% Chooser - optional function handle for tests. It receives -% (filterSpec, titleText, safeDefaultPath) and returns file, folder. -% -% Outputs: -% filepath - selected full output path as a string scalar, or "" on cancel. -% cancelled - true when the chooser was canceled. -% file, folder - raw chooser file and folder outputs for callers that need -% compatibility with uiputfile-style branching. - - if nargin < 1 || isempty(filterSpec) - filterSpec = '*.*'; - end - if nargin < 2 || isempty(titleText) - titleText = "Save output file"; - end - if nargin < 3 - defaultPath = ""; - end - - opts = parseOptions(varargin{:}); - safeDefaultPath = outputDefaultPath(defaultPath); - chooserFilter = normalizeFilterSpec(filterSpec); - - [file, folder] = opts.chooser(chooserFilter, char(string(titleText)), ... - char(safeDefaultPath)); - cancelled = isequal(file, 0) || isequal(folder, 0); - if cancelled - filepath = ""; - return; - end - - filepath = string(fullfile(char(folder), char(file))); - rememberOutputFolder(folder); -end - -function opts = parseOptions(varargin) - opts = struct('chooser', @uiputfile); - if mod(numel(varargin), 2) ~= 0 - error('labkit:ui:runtime:InvalidPromptOutputFileOptions', ... - 'Options must be name-value pairs.'); - end - for k = 1:2:numel(varargin) - name = lower(strtrim(string(varargin{k}))); - value = varargin{k + 1}; - switch name - case "chooser" - if ~isa(value, 'function_handle') - error('labkit:ui:runtime:InvalidPromptOutputFileOptions', ... - 'Chooser must be a function handle.'); - end - opts.chooser = value; - otherwise - error('labkit:ui:runtime:InvalidPromptOutputFileOptions', ... - 'Unsupported option "%s".', name); - end - end -end - -function filterSpec = normalizeFilterSpec(filterSpec) - if isstring(filterSpec) && isscalar(filterSpec) - filterSpec = char(filterSpec); - end -end - -function pathValue = outputDefaultPath(defaultPath) - defaultPath = string(defaultPath); - if isempty(defaultPath) || strlength(strtrim(defaultPath(1))) == 0 - folder = defaultDialogFolder("output"); - pathValue = string(fullfile(folder, 'output')); - return; - end - - defaultPath = strtrim(defaultPath(1)); - [folder, name, ext] = fileparts(char(defaultPath)); - filename = string(name) + string(ext); - if strlength(filename) == 0 - filename = "output"; - end - safeFolder = defaultDialogFolder("output", folder); - pathValue = string(fullfile(safeFolder, char(filename))); -end - -function rememberOutputFolder(folder) - if isempty(folder) || isequal(folder, 0) - return; - end - folder = string(folder); - if isempty(folder) || strlength(folder(1)) == 0 || exist(char(folder(1)), 'dir') ~= 7 - return; - end - setpref('LabKit', 'LastOutputFolder', char(folder(1))); -end diff --git a/+labkit/+ui/+runtime/private/promptOutputFolder.m b/+labkit/+ui/+runtime/private/promptOutputFolder.m deleted file mode 100644 index ba07914fa..000000000 --- a/+labkit/+ui/+runtime/private/promptOutputFolder.m +++ /dev/null @@ -1,78 +0,0 @@ -% Private UI runtime helper. Prompts for an output folder with safe defaults. -function [folder, cancelled] = promptOutputFolder(titleText, defaultFolder, varargin) -% -% App-facing contract: -% folder = promptOutputFolder(titleText, defaultFolder) -% [folder, cancelled] = promptOutputFolder(...) -% [...] = promptOutputFolder(..., "Chooser", chooserFcn) -% -% Inputs: -% titleText - dialog title. Defaults to "Select output folder". -% defaultFolder - preferred output folder. It is passed through -% defaultDialogFolder("output", defaultFolder). -% Chooser - optional function handle for tests. It receives -% (safeDefaultFolder, titleText) and returns a folder path or 0. -% -% Outputs: -% folder - selected folder as a string scalar, or "" on cancel. -% cancelled - true when the chooser was canceled. - - if nargin < 1 || isempty(titleText) - titleText = "Select output folder"; - end - if nargin < 2 - defaultFolder = ""; - end - - opts = parseOptions(varargin{:}); - safeDefaultFolder = defaultDialogFolder("output", defaultFolder); - rawFolder = opts.chooser(safeDefaultFolder, char(string(titleText))); - cancelled = isequal(rawFolder, 0); - if cancelled - folder = ""; - return; - end - - folder = string(rawFolder); - if isempty(folder) || strlength(strtrim(folder(1))) == 0 - folder = ""; - cancelled = true; - return; - end - folder = strtrim(folder(1)); - rememberOutputFolder(folder); -end - -function opts = parseOptions(varargin) - opts = struct('chooser', @uigetdir); - if mod(numel(varargin), 2) ~= 0 - error('labkit:ui:runtime:InvalidPromptOutputFolderOptions', ... - 'Options must be name-value pairs.'); - end - for k = 1:2:numel(varargin) - name = lower(strtrim(string(varargin{k}))); - value = varargin{k + 1}; - switch name - case "chooser" - if ~isa(value, 'function_handle') - error('labkit:ui:runtime:InvalidPromptOutputFolderOptions', ... - 'Chooser must be a function handle.'); - end - opts.chooser = value; - otherwise - error('labkit:ui:runtime:InvalidPromptOutputFolderOptions', ... - 'Unsupported option "%s".', name); - end - end -end - -function rememberOutputFolder(folder) - if isempty(folder) || isequal(folder, 0) - return; - end - folder = string(folder); - if isempty(folder) || strlength(folder(1)) == 0 || exist(char(folder(1)), 'dir') ~= 7 - return; - end - setpref('LabKit', 'LastOutputFolder', char(folder(1))); -end diff --git a/+labkit/+ui/+runtime/private/readV2ProjectFile.m b/+labkit/+ui/+runtime/private/readV2ProjectFile.m deleted file mode 100644 index 44b5a2409..000000000 --- a/+labkit/+ui/+runtime/private/readV2ProjectFile.m +++ /dev/null @@ -1,143 +0,0 @@ -% Private UI runtime helper. Expected caller: loadState. Inputs are a MAT-file -% path and current v2 definition. Outputs are a migrated complete project, -% optional resume data, the preserved envelope, and whether saving must upgrade -% the opened file. The reader inventories the file first and loads only one -% recognized trusted top-level variable. -function [project, resume, envelope, needsUpgrade] = readV2ProjectFile(filepath, def) - details = whos('-file', filepath); - inventory = string({details.name}); - recognized = intersect(inventory, ... - ["labkitProject", "snapshot", legacyNames(def.project)]); - if numel(recognized) ~= 1 - error('labkit:ui:runtime:UnknownProjectFormat', ... - 'Project file must contain exactly one recognized state variable.'); - end - variable = recognized(1); - loaded = load(filepath, variable); - if variable == "labkitProject" - envelope = loaded.labkitProject; - [project, resume, needsUpgrade] = ... - decodeCurrentEnvelope(envelope, def); - elseif variable == "snapshot" - [project, resume, envelope] = importSnapshot(loaded.snapshot, def); - needsUpgrade = true; - else - [project, resume, envelope] = importLegacy( ... - variable, loaded.(char(variable)), def); - needsUpgrade = true; - end -end - -function [project, resume, needsUpgrade] = decodeCurrentEnvelope(envelope, def) - requireScalarStruct(envelope, 'project envelope'); - required = ["format", "formatVersion", "app", "document", ... - "producer", "sources", "payload"]; - requireFields(envelope, required, 'project envelope'); - if string(envelope.format) ~= "labkit.project" - invalid('Unsupported project format.'); - end - requireFields(envelope.formatVersion, ["major", "minor"], ... - 'formatVersion'); - if double(envelope.formatVersion.major) > 1 - error('labkit:ui:runtime:NewerProjectFormat', ... - 'Project format major version is newer than this LabKit reader.'); - end - requireFields(envelope.app, ["id", "payloadVersion"], 'app'); - if string(envelope.app.id) ~= string(def.id) - error('labkit:ui:runtime:WrongProjectApp', ... - 'Project app id "%s" does not match "%s".', ... - string(envelope.app.id), string(def.id)); - end - payloadVersion = double(envelope.app.payloadVersion); - project = migratePayload(envelope.payload, payloadVersion, def.project); - needsUpgrade = payloadVersion < double(def.project.Version); - resume = struct(); - if isfield(envelope, 'resume') && isstruct(envelope.resume) - resume = envelope.resume; - end -end - -function project = migratePayload(project, versionValue, spec) - current = double(spec.Version); - if ~isscalar(versionValue) || ~isfinite(versionValue) || ... - versionValue < 1 || versionValue ~= fix(versionValue) - invalid('Project payload version must be a positive integer.'); - end - if versionValue > current - error('labkit:ui:runtime:NewerProjectPayload', ... - 'Project payload version %d is newer than supported version %d.', ... - versionValue, current); - end - for version = versionValue:current - 1 - project = migrateOneVersion(project, version, spec); - validateSerializableState(project); - end -end - -function project = migrateOneVersion(project, fromVersion, spec) - project = spec.Migrate(project, fromVersion); -end - -function [project, resume, envelope] = importSnapshot(snapshot, def) - requireScalarStruct(snapshot, 'snapshot'); - requireFields(snapshot, ["app", "state"], 'snapshot'); - if string(snapshot.app.id) ~= string(def.id) - error('labkit:ui:runtime:WrongProjectApp', ... - 'Snapshot app id does not match the running app.'); - end - state = snapshot.state; - if isstruct(state) && isfield(state, 'project') - project = state.project; - resume = optionValue(state, 'session', struct()); - else - project = state; - resume = struct(); - end - envelope = struct(); -end - -function [project, resume, envelope] = importLegacy(name, value, def) - imports = def.project.LegacyImports; - importer = imports.(char(name)); - outputCount = nargout(importer); - if outputCount >= 2 - [project, resume] = importer(value); - else - project = importer(value); - resume = struct(); - end - envelope = struct(); -end - -function names = legacyNames(spec) - names = strings(1, 0); - if isfield(spec, 'LegacyImports') && isstruct(spec.LegacyImports) - names = string(fieldnames(spec.LegacyImports)).'; - end -end - -function requireScalarStruct(value, label) - if ~isstruct(value) || ~isscalar(value) - invalid('%s must be a scalar struct.', label); - end -end - -function requireFields(value, fields, label) - requireScalarStruct(value, label); - for k = 1:numel(fields) - if ~isfield(value, fields(k)) - invalid('%s is missing field "%s".', label, fields(k)); - end - end -end - -function value = optionValue(spec, name, defaultValue) - value = defaultValue; - if isstruct(spec) && isfield(spec, name) - value = spec.(name); - end -end - -function invalid(message, varargin) - error('labkit:ui:runtime:InvalidProject', message, varargin{:}); -end diff --git a/+labkit/+ui/+runtime/private/rebaseProjectSources.m b/+labkit/+ui/+runtime/private/rebaseProjectSources.m deleted file mode 100644 index c31881668..000000000 --- a/+labkit/+ui/+runtime/private/rebaseProjectSources.m +++ /dev/null @@ -1,30 +0,0 @@ -% Private Runtime V2 serialization helper. Expected caller: project-envelope -% creation. Inputs are durable project data and the actual destination MAT -% path. Output copies the project and refreshes standard portable-reference -% fields while preserving app-owned additive reference fields. -function project = rebaseProjectSources(project, filepath) - if strlength(string(filepath)) == 0 || ... - ~isfield(project, 'inputs') || ~isstruct(project.inputs) || ... - ~isfield(project.inputs, 'sources') || isempty(project.inputs.sources) - return; - end - sources = project.inputs.sources; - for k = 1:numel(sources) - if ~isfield(sources(k), 'reference') || ... - ~isstruct(sources(k).reference) || ... - ~isscalar(sources(k).reference) || ... - ~isfield(sources(k).reference, 'originalPath') - continue; - end - target = string(sources(k).reference.originalPath); - portable = createPortableFileReference(filepath, target); - reference = sources(k).reference; - standardFields = fieldnames(portable); - for fieldIndex = 1:numel(standardFields) - name = standardFields{fieldIndex}; - reference.(name) = portable.(name); - end - sources(k).reference = reference; - end - project.inputs.sources = sources; -end diff --git a/+labkit/+ui/+runtime/private/reconcileV2Interactions.m b/+labkit/+ui/+runtime/private/reconcileV2Interactions.m deleted file mode 100644 index d7138db6e..000000000 --- a/+labkit/+ui/+runtime/private/reconcileV2Interactions.m +++ /dev/null @@ -1,590 +0,0 @@ -% Private UI runtime helper. Expected caller: commitV2Presentation. Inputs are -% the current v2 runtime and a scalar semantic interaction-spec struct. Side -% effects create, update, replace, and dispose controlled interaction resources -% without exposing editor objects or UI handles to app state. -function reconcileV2Interactions(runtime, interactions) - if isempty(interactions) - interactions = struct(); - end - if ~isstruct(interactions) || ~isscalar(interactions) - error('labkit:ui:runtime:InvalidPresentation', ... - 'Presentation interactions must be a scalar struct.'); - end - hub = runtime.interactionHub; - hub.setSuppressed(true); - cleanup = onCleanup(@() hub.setSuppressed(false)); - desiredIds = string(fieldnames(interactions)); - specs = cell(numel(desiredIds), 1); - actionIds = string(fieldnames(runtime.actions)); - targetIds = hub.targetIds(); - for k = 1:numel(desiredIds) - id = desiredIds(k); - specs{k} = normalizeSpec( ... - id, interactions.(char(id)), actionIds, targetIds); - end - currentIds = v2ResourceRegistry(runtime.ui.figure, ... - "listIds", "interaction"); - removedIds = setdiff(currentIds, desiredIds, 'stable'); - for k = 1:numel(removedIds) - v2ResourceRegistry(runtime.ui.figure, "remove", ... - "interaction", removedIds(k)); - end - for k = 1:numel(desiredIds) - id = desiredIds(k); - spec = specs{k}; - current = v2ResourceRegistry(runtime.ui.figure, ... - "get", "interaction", id); - if isempty(current) || ~sameIdentity(current.spec, spec) - v2ResourceRegistry(runtime.ui.figure, "remove", ... - "interaction", id); - current = createControlledInteraction(hub, id, spec); - v2ResourceRegistry(runtime.ui.figure, "set", ... - "interaction", id, current, @(value) value.delete()); - end - current.update(spec.Value); - end - clear cleanup; -end - -function controlled = createControlledInteraction(hub, id, spec) - kind = lower(spec.Kind); - group = "interaction:" + id; - suppressed = false; - switch kind - case {"anchors", "scalebarreference", "scalebar"} - options = anchorOptions(spec, @emitValue); - editor = createAnchorEditor( ... - hub.adapter(spec.Targets(1), group), ... - spec.ImageSize, options); - editors = {editor}; - update = @updateSingleAnchors; - case "pairedanchors" - editors = cell(1, numel(spec.Targets)); - for k = 1:numel(spec.Targets) - options = anchorOptions(spec, @emitPairedValue); - editors{k} = createAnchorEditor( ... - hub.adapter(spec.Targets(k), group), ... - imageSizeAt(spec.ImageSize, k), options); - end - update = @updatePairedAnchors; - case "rectangle" - options = rectangleOptions(spec, @emitValue, @emitBackground); - editor = createRectangleEditor( ... - hub.adapter(spec.Targets(1), group), spec.ImageSize, ... - spec.Value, options); - editors = {editor}; - update = @updateRectangle; - case "regionselection" - editor = createRegionSelection( ... - hub.adapter(spec.Targets(1), group), spec, ... - @emitValue, @emitBackground); - editors = {editor}; - update = @updateRegionSelection; - case "interval" - editor = createIntervalEditor( ... - hub.adapter(spec.Targets(1), group), spec, ... - @emitValue, @emitScroll); - editors = {editor}; - update = @updateInterval; - case "pointslots" - editor = createPointSlotsEditor( ... - hub.adapter(spec.Targets(1), group), spec.ImageSize, ... - spec.Options, @emitValue); - editors = {editor}; - update = @updatePointSlots; - otherwise - error('labkit:ui:runtime:UnknownInteractionKind', ... - 'Interaction "%s" has unsupported Kind "%s".', id, spec.Kind); - end - baseUpdate = update; - instruction = interactionInstruction(spec); - update = @updateWithInstruction; - controlled = struct("spec", spec, "update", update, ... - "delete", @deleteEditors, "editors", {editors}); - - function updateWithInstruction(value) - baseUpdate(value); - applyInstruction(); - end - - function updateSingleAnchors(value) - withSuppression(@() setAnchorValue(editors{1}, value)); - end - - function updatePairedAnchors(value) - values = pairedValues(value, spec.Targets); - withSuppression(@setValues); - - function setValues() - for index = 1:numel(editors) - setAnchorValue(editors{index}, values{index}); - end - end - end - - function updateRectangle(value) - withSuppression(@() editors{1}.setPosition(value)); - end - - function updateRegionSelection(~) - % Region selection is a transient gesture and has no durable overlay. - end - - function updateInterval(value) - withSuppression(@() editors{1}.setRange(value)); - end - - function updatePointSlots(value) - withSuppression(@() editors{1}.setValue(value)); - end - - function setAnchorValue(targetEditor, value) - targetEditor.setPoints(value); - targetEditor.setActive(true); - end - - function emitValue(value, varargin) - if ~suppressed - hub.dispatch(spec.Event, id, value, spec.ChangePolicy); - end - end - - function emitPairedValue(varargin) - if suppressed - return; - end - values = cell(1, numel(editors)); - for index = 1:numel(editors) - values{index} = editors{index}.getPoints(); - end - hub.dispatch(spec.Event, id, values, spec.ChangePolicy); - end - - function emitBackground(varargin) - if suppressed || strlength(spec.BackgroundEvent) == 0 - return; - end - hub.dispatch(spec.BackgroundEvent, id, ... - hub.point(spec.Targets(1)), "commit"); - end - - function emitScroll(value) - if ~suppressed && strlength(spec.ScrollEvent) > 0 - hub.dispatch(spec.ScrollEvent, id, value, "commit"); - end - end - - function withSuppression(callback) - suppressed = true; - cleanupSuppression = onCleanup(@() clearSuppression()); - callback(); - clear cleanupSuppression; - end - - function clearSuppression() - suppressed = false; - end - - function deleteEditors() - clearInstruction(); - for index = 1:numel(editors) - editors{index}.delete(); - end - end - - function applyInstruction() - if strlength(instruction) == 0 - return; - end - for index = 1:numel(spec.Targets) - adapter = hub.adapter(spec.Targets(index), group); - ax = adapter.axes(); - if isempty(ax) || ~isvalid(ax) - continue; - end - try - subtitle(ax, instruction, 'Interpreter', 'none'); - catch - end - end - end - - function clearInstruction() - if strlength(instruction) == 0 - return; - end - for index = 1:numel(spec.Targets) - adapter = hub.adapter(spec.Targets(index), group); - ax = adapter.axes(); - if isempty(ax) || ~isvalid(ax) - continue; - end - try - if join(string(ax.Subtitle.String), newline) == instruction - subtitle(ax, ""); - end - catch - end - end - end -end - -function editor = createRegionSelection(runtime, spec, onSelected, onPoint) - ax = runtime.axes(); - imageSize = normalizeRegionImageSize(spec.ImageSize); - color = optionValue(spec.Options, 'color', [1 1 1]); - lineWidth = optionValue(spec.Options, 'lineWidth', 1.2); - lineStyle = optionValue(spec.Options, 'lineStyle', '--'); - threshold = optionValue(spec.Options, 'pointThreshold', 2); - startPoint = [NaN NaN]; - overlay = gobjects(1, 0); - session = runtime.createSession(struct( ... - "name", "regionSelection", ... - "onPointerDown", @pointerDown, ... - "installScrollWheel", false)); - session.activate(); - editor = struct("delete", @deleteEditor); - - function pointerDown(~, ~) - startPoint = clampedPoint(); - if ~all(isfinite(startPoint)) - return; - end - deleteOverlay(); - session.captureDrag(@drag, @release); - end - - function drag(~, ~) - point = clampedPoint(); - if ~all(isfinite(point)) - return; - end - position = rectangleFromPoints(startPoint, point); - if isempty(overlay) || ~isgraphics(overlay) - overlay = rectangle(ax, "Position", position, ... - "EdgeColor", color, "LineWidth", lineWidth, ... - "LineStyle", lineStyle, "HitTest", "off", ... - "PickableParts", "none"); - else - overlay.Position = position; - end - session.setGraphics(overlay); - end - - function release(~, ~) - point = clampedPoint(); - position = rectangleFromPoints(startPoint, point); - deleteOverlay(); - if all(isfinite(position)) && max(position(3:4)) > threshold - onSelected(position); - elseif all(isfinite(point)) - onPoint(); - end - startPoint = [NaN NaN]; - end - - function point = clampedPoint() - current = double(ax.CurrentPoint); - point = current(1, 1:2); - if numel(imageSize) >= 2 && all(isfinite(imageSize)) - point = min([imageSize(2), imageSize(1)], max([1 1], point)); - end - end - - function deleteEditor() - session.delete(); - deleteOverlay(); - end - - function deleteOverlay() - if ~isempty(overlay) && all(isgraphics(overlay)) - delete(overlay); - end - overlay = gobjects(1, 0); - end -end - -function imageSize = normalizeRegionImageSize(value) - imageSize = double(value(:).'); - if numel(imageSize) < 2 || ~all(isfinite(imageSize(1:2))) || ... - any(imageSize(1:2) < 1) - imageSize = [NaN NaN]; - else - imageSize = imageSize(1:2); - end -end - -function position = rectangleFromPoints(a, b) - if numel(a) ~= 2 || numel(b) ~= 2 || ... - ~all(isfinite([a(:); b(:)])) - position = [NaN NaN NaN NaN]; - return; - end - origin = min(a, b); - position = [origin, abs(b - a)]; -end - -function spec = normalizeSpec(id, value, actionIds, targetIds) - if ~isstruct(value) || ~isscalar(value) - error('labkit:ui:runtime:InvalidInteractionSpec', ... - 'Interaction "%s" must be a scalar struct.', id); - end - spec = struct(); - spec.Kind = string(requiredValue(value, 'Kind', id)); - spec.Targets = string(requiredValue(value, 'Targets', id)); - spec.Value = requiredValue(value, 'Value', id); - spec.Event = string(requiredValue(value, 'Event', id)); - spec.BackgroundEvent = string(optionValue( ... - value, 'BackgroundEvent', "")); - spec.ScrollEvent = string(optionValue(value, 'ScrollEvent', "")); - spec.ChangePolicy = string(optionValue( ... - value, 'ChangePolicy', 'commit')); - spec.ImageSize = optionValue(value, 'ImageSize', []); - spec.Options = optionValue(value, 'Options', struct()); - spec.Instruction = string(optionValue(value, 'Instruction', "")); - spec.Targets = spec.Targets(:).'; - if ~isscalar(spec.Kind) || strlength(spec.Kind) == 0 || ... - isempty(spec.Targets) || any(strlength(spec.Targets) == 0) - error('labkit:ui:runtime:InvalidInteractionSpec', ... - 'Interaction "%s" requires Kind and semantic Targets.', id); - end - if ~isscalar(spec.Instruction) - error('labkit:ui:runtime:InvalidInteractionSpec', ... - 'Interaction "%s" Instruction must be scalar text.', id); - end - if ~isscalar(spec.Event) || strlength(spec.Event) == 0 - error('labkit:ui:runtime:InvalidInteractionSpec', ... - 'Interaction "%s" requires one semantic Event.', id); - end - if ~isscalar(spec.BackgroundEvent) || ~isscalar(spec.ScrollEvent) - error('labkit:ui:runtime:InvalidInteractionSpec', ... - 'Interaction "%s" optional events must be scalar text.', id); - end - referencedEvents = [spec.Event, spec.BackgroundEvent, spec.ScrollEvent]; - referencedEvents = referencedEvents(strlength(referencedEvents) > 0); - if any(~ismember(referencedEvents, actionIds)) - unknown = referencedEvents(~ismember(referencedEvents, actionIds)); - error('labkit:ui:runtime:UnknownInteractionAction', ... - 'Interaction "%s" references unknown action "%s".', ... - id, unknown(1)); - end - unknownTargets = setdiff(spec.Targets, targetIds, 'stable'); - if ~isempty(unknownTargets) - error('labkit:ui:runtime:UnknownInteractionTarget', ... - 'Interaction "%s" references unknown target "%s".', ... - id, unknownTargets(1)); - end - if lower(spec.Kind) == "pairedanchors" && numel(spec.Targets) < 2 - error('labkit:ui:runtime:InvalidInteractionSpec', ... - 'pairedAnchors interaction "%s" requires at least two Targets.', id); - elseif lower(spec.Kind) ~= "pairedanchors" && numel(spec.Targets) ~= 1 - error('labkit:ui:runtime:InvalidInteractionSpec', ... - 'Interaction "%s" requires exactly one Target.', id); - end -end - -function tf = sameIdentity(left, right) - tf = lower(left.Kind) == lower(right.Kind) && ... - isequal(left.Targets, right.Targets) && ... - isequaln(left.ImageSize, right.ImageSize) && ... - isequaln(left.Options, right.Options) && ... - left.Event == right.Event && ... - left.BackgroundEvent == right.BackgroundEvent && ... - left.ScrollEvent == right.ScrollEvent && ... - left.Instruction == right.Instruction && ... - left.ChangePolicy == right.ChangePolicy; -end - -function value = interactionInstruction(spec) - value = spec.Instruction; - if strlength(value) > 0 - return; - end - kind = lower(spec.Kind); - mode = lower(string(optionValue(spec.Options, 'mode', 'curve'))); - if kind == "pairedanchors" - value = "Click each preview in matching order; drag points to refine; use Undo to remove a pair."; - elseif any(kind == ["scalebarreference", "scalebar"]) - value = "Double-click two reference endpoints; drag either endpoint to refine."; - elseif kind == "anchors" && mode == "points" - value = "Click blank image space to add points; drag points to refine; use Undo or Clear to remove."; - elseif kind == "anchors" - value = "Double-click blank image space to add; drag a point to move; double-click a point to delete."; - elseif kind == "pointslots" - value = "Click blank image space to place the selected marker; drag a marker to refine its position."; - end -end - -function editor = createIntervalEditor(runtime, spec, onChanged, onScroll) - ax = runtime.axes(); - range = normalizeInterval(spec.Value); - color = optionValue(spec.Options, 'color', [0.2 0.55 1]); - faceColor = optionValue(spec.Options, 'faceColor', color); - faceAlpha = optionValue(spec.Options, 'faceAlpha', 0.12); - overlay = gobjects(1, 0); - startX = NaN; - options = struct("name", "intervalEditor", ... - "onPointerDown", @pointerDown, ... - "onScroll", @scroll, "installScrollWheel", true); - session = runtime.createSession(options); - session.activate(); - editor = struct("setRange", @setRange, "delete", @deleteEditor); - refresh(); - - function setRange(value) - range = normalizeInterval(value); - refresh(); - end - - function pointerDown(~, ~) - point = axesPoint(); - if ~isfinite(point) - return; - end - startX = point; - range = [point point]; - refresh(); - session.captureDrag(@drag, @release); - end - - function drag(~, ~) - point = axesPoint(); - if isfinite(point) - range = sort([startX point]); - refresh(); - end - end - - function release(~, ~) - if all(isfinite(range)) && diff(range) > 0 - onChanged(range); - end - end - - function scroll(~, event) - point = axesPoint(); - count = scrollCount(event); - if count ~= 0 - onScroll(struct("anchor", point, "count", count)); - end - end - - function refresh() - if ~isgraphics(ax) - return; - end - if isempty(overlay) || ~all(isgraphics(overlay)) - overlay = patch(ax, NaN, NaN, faceColor, ... - 'FaceAlpha', faceAlpha, 'EdgeColor', color, ... - 'LineStyle', ':', 'LineWidth', 1, 'HitTest', 'off', ... - 'PickableParts', 'none'); - end - if all(isfinite(range)) - limits = ylim(ax); - overlay.XData = [range(1) range(2) range(2) range(1)]; - overlay.YData = [limits(1) limits(1) limits(2) limits(2)]; - else - overlay.XData = NaN; - overlay.YData = NaN; - end - session.setGraphics(overlay); - session.refresh(); - end - - function value = axesPoint() - current = double(ax.CurrentPoint); - value = current(1, 1); - end - - function deleteEditor() - session.delete(); - if ~isempty(overlay) && all(isgraphics(overlay)) - delete(overlay); - end - overlay = gobjects(1, 0); - end -end - -function value = normalizeInterval(value) - value = double(value(:).'); - if numel(value) ~= 2 || ~all(isfinite(value)) - value = [NaN NaN]; - else - value = sort(value); - end -end - -function count = scrollCount(event) - count = 0; - if isstruct(event) && isfield(event, 'VerticalScrollCount') - count = double(event.VerticalScrollCount); - elseif isobject(event) && isprop(event, 'VerticalScrollCount') - count = double(event.VerticalScrollCount); - end - if ~isscalar(count) || ~isfinite(count) - count = 0; - end -end - -function options = anchorOptions(spec, callback) - options = spec.Options; - options.onChanged = callback; - kind = lower(spec.Kind); - if any(kind == ["scalebarreference", "scalebar"]) - options.closed = false; - options.style = "Straight lines"; - options.maxPoints = 2; - end -end - -function options = rectangleOptions(spec, callback, backgroundCallback) - options = spec.Options; - options.onMoved = callback; - if strlength(spec.BackgroundEvent) > 0 - options.onBackgroundDown = backgroundCallback; - end -end - -function values = pairedValues(value, targets) - if iscell(value) && numel(value) == numel(targets) - values = reshape(value, 1, []); - return; - end - if isstruct(value) && isscalar(value) - values = cell(1, numel(targets)); - for k = 1:numel(targets) - field = matlab.lang.makeValidName(char(targets(k))); - if ~isfield(value, field) - error('labkit:ui:runtime:InvalidInteractionValue', ... - 'Paired interaction value is missing target "%s".', targets(k)); - end - values{k} = value.(field); - end - return; - end - error('labkit:ui:runtime:InvalidInteractionValue', ... - 'Paired interaction Value must provide one point array per Target.'); -end - -function imageSize = imageSizeAt(value, index) - if iscell(value) - imageSize = value{index}; - else - imageSize = value; - end -end - -function value = requiredValue(spec, name, id) - if ~isfield(spec, name) - error('labkit:ui:runtime:InvalidInteractionSpec', ... - 'Interaction "%s" requires %s.', id, name); - end - value = spec.(name); -end - -function value = optionValue(spec, name, defaultValue) - value = defaultValue; - if isfield(spec, name) - value = spec.(name); - end -end diff --git a/+labkit/+ui/+runtime/private/refreshListboxItems.m b/+labkit/+ui/+runtime/private/refreshListboxItems.m deleted file mode 100644 index 21d430fa4..000000000 --- a/+labkit/+ui/+runtime/private/refreshListboxItems.m +++ /dev/null @@ -1,16 +0,0 @@ -% Private UI runtime helper. Expected caller: semantic list presentation, -% 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 refreshListboxItems(lb, names) -%REFRESHLISTBOXITEMS Refresh a multiselect listbox and preserve valid picks. -% -% Inputs: -% lb - MATLAB listbox handle. -% names - char/string/cellstr item names. -% -% Output: -% Mutates lb.Items and lb.Value in place, defaulting to all items. - - refreshListboxSelection(lb, names, lb.Value, struct('defaultSelection', 'all')); -end diff --git a/+labkit/+ui/+runtime/private/refreshListboxSelection.m b/+labkit/+ui/+runtime/private/refreshListboxSelection.m deleted file mode 100644 index d68b63a34..000000000 --- a/+labkit/+ui/+runtime/private/refreshListboxSelection.m +++ /dev/null @@ -1,125 +0,0 @@ -% Private UI runtime helper. Expected caller: semantic list presentation, -% 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 [value, index] = refreshListboxSelection(lb, names, preferredSelection, opts) -%REFRESHLISTBOXSELECTION Refresh listbox items while preserving valid selection. -% -% Usage: -% [value, idx] = refreshListboxSelection(lb, names, oldValue); -% [value, idx] = refreshListboxSelection(lb, names, [], ... -% struct('defaultSelection', 'all')); -% -% Inputs: -% lb - MATLAB listbox handle. -% names - char/string/cellstr listbox items. -% preferredSelection - optional previous value, item names, or indices. -% opts - optional struct. -% -% Options: -% defaultSelection - "first" (default) or "all" for multiselect listboxes. -% -% Output: -% value - selected listbox value after refresh. -% index - numeric selected indices in names. - - if nargin < 3 - preferredSelection = lb.Value; - end - if nargin < 4 - opts = struct(); - end - - if isempty(names) - lb.Items = {}; - lb.Value = {}; - value = {}; - index = []; - return; - end - - names = normalizeNames(names); - lb.Items = names; - - selected = selectValidNames(names, preferredSelection); - if isempty(selected) - selected = defaultSelection(names, lb, opts); - end - - if isMultiselect(lb) - value = selected; - else - value = selected{1}; - end - lb.Value = value; - index = selectedIndexes(names, selected); -end - -function names = normalizeNames(names) - if isempty(names) - names = {}; - return; - end - - if ischar(names) - names = {names}; - elseif isstring(names) - names = cellstr(reshape(names, 1, [])); - elseif ~iscell(names) - names = cellstr(reshape(string(names), 1, [])); - else - names = reshape(names, 1, []); - end - names = cellfun(@(value) char(string(value)), names, ... - 'UniformOutput', false); -end - -function selected = selectValidNames(names, preferredSelection) - selected = {}; - if isempty(preferredSelection) - return; - end - - if isnumeric(preferredSelection) - idx = preferredSelection(preferredSelection >= 1 & preferredSelection <= numel(names)); - selected = names(idx); - return; - end - - if ischar(preferredSelection) || isstring(preferredSelection) - preferredSelection = cellstr(reshape(string(preferredSelection), 1, [])); - end - preferredSelection = reshape(preferredSelection, 1, []); - keep = ismember(string(preferredSelection), string(names)); - selected = cellstr(string(preferredSelection(keep))); -end - -function selected = defaultSelection(names, lb, opts) - defaultMode = optionValue(opts, 'defaultSelection', 'first'); - if isMultiselect(lb) && strcmp(defaultMode, 'all') - selected = names; - else - selected = names(1); - end -end - -function index = selectedIndexes(names, selected) - index = []; - for k = 1:numel(selected) - idx = find(strcmp(names, selected{k}), 1, 'first'); - if ~isempty(idx) - index(end+1) = idx; - end - end -end - -function tf = isMultiselect(lb) - tf = isprop(lb, 'Multiselect') && strcmp(lb.Multiselect, 'on'); -end - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isfield(opts, name) - value = opts.(name); - end -end diff --git a/+labkit/+ui/+runtime/private/registerWorkbenchAxes.m b/+labkit/+ui/+runtime/private/registerWorkbenchAxes.m deleted file mode 100644 index 7f32b3926..000000000 --- a/+labkit/+ui/+runtime/private/registerWorkbenchAxes.m +++ /dev/null @@ -1,49 +0,0 @@ -% Private UI runtime helper. Expected caller: buildWorkspace. Inputs are the app -% figure and preview axes handles. Side effects: records valid axes for -% framework utility commands and installs lightweight active-axes tracking -% without taking over existing app callbacks. -function registerWorkbenchAxes(fig, axesHandles) - axesHandles = axesHandles(:).'; - existing = gobjects(1, 0); - if isappdata(fig, 'labkitUiWorkbenchAxes') - existing = getappdata(fig, 'labkitUiWorkbenchAxes'); - existing = existing(isvalid(existing)); - end - allAxes = [existing, axesHandles]; - allAxes = allAxes(isvalid(allAxes)); - setappdata(fig, 'labkitUiWorkbenchAxes', uniqueHandles(allAxes)); - for k = 1:numel(axesHandles) - ax = axesHandles(k); - existingCallback = ax.ButtonDownFcn; - set(ax, 'ButtonDownFcn', ... - @(source,event) handleAxesButtonDown(fig, source, event, ... - existingCallback)); - end -end - -function handleAxesButtonDown(fig, ax, event, existingCallback) - if ~isempty(ax) && isvalid(ax) - setappdata(fig, 'labkitUiActiveAxes', ax); - end - invokeExistingCallback(existingCallback, ax, event); -end - -function invokeExistingCallback(callback, source, event) - if isempty(callback) - return; - end - if isa(callback, 'function_handle') - callback(source, event); - elseif iscell(callback) && ~isempty(callback) && isa(callback{1}, 'function_handle') - callback{1}(source, event, callback{2:end}); - end -end - -function out = uniqueHandles(handles) - out = gobjects(1, 0); - for k = 1:numel(handles) - if ~any(out == handles(k)) - out(end + 1) = handles(k); - end - end -end diff --git a/+labkit/+ui/+runtime/private/reportStartupProgress.m b/+labkit/+ui/+runtime/private/reportStartupProgress.m deleted file mode 100644 index 4f33d358e..000000000 --- a/+labkit/+ui/+runtime/private/reportStartupProgress.m +++ /dev/null @@ -1,12 +0,0 @@ -% Private UI runtime helper. Expected callers are startup orchestration -% helpers. Inputs are an optional reporter and one phase message. Reporting is -% best-effort and must never change whether app startup succeeds. -function reportStartupProgress(reporter, message) - if ~isa(reporter, 'function_handle') - return; - end - try - reporter(string(message)); - catch - end -end diff --git a/+labkit/+ui/+runtime/private/resolveControl.m b/+labkit/+ui/+runtime/private/resolveControl.m deleted file mode 100644 index e0db07ff9..000000000 --- a/+labkit/+ui/+runtime/private/resolveControl.m +++ /dev/null @@ -1,31 +0,0 @@ -% Private UI runtime helper. Expected caller: semantic presentation helpers. -% Resolves a semantic control id against a runtime 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:control:InvalidRegistry', ... - 'Expected a UI registry struct returned by labkit.ui.runtime.create.'); - end - - if strlength(string(id)) == 0 - error('labkit:ui:control:MissingControlId', ... - 'A semantic control id is required.'); - end - - name = char(string(id)); - if ~isfield(uiOrControl.controls, name) - error('labkit:ui:control:UnknownControl', ... - 'Unknown UI control "%s".', name); - end - control = uiOrControl.controls.(name); -end diff --git a/+labkit/+ui/+runtime/private/resolvePlotControl.m b/+labkit/+ui/+runtime/private/resolvePlotControl.m deleted file mode 100644 index 8b8761bc5..000000000 --- a/+labkit/+ui/+runtime/private/resolvePlotControl.m +++ /dev/null @@ -1,31 +0,0 @@ -% Private UI runtime helper. Expected caller: resolvePreviewAxes. -% Resolves a semantic preview/control id against a runtime registry, or passes -% through an adapter struct already returned by the registry. -function control = resolvePlotControl(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:plot:InvalidRegistry', ... - 'Expected a UI registry struct returned by labkit.ui.runtime.create.'); - end - - if strlength(string(id)) == 0 - error('labkit:ui:plot:MissingControlId', ... - 'A semantic preview id is required.'); - end - - name = char(string(id)); - if ~isfield(uiOrControl.controls, name) - error('labkit:ui:plot:UnknownControl', ... - 'Unknown UI preview/control "%s".', name); - end - control = uiOrControl.controls.(name); -end diff --git a/+labkit/+ui/+runtime/private/resolvePortableFileReference.m b/+labkit/+ui/+runtime/private/resolvePortableFileReference.m deleted file mode 100644 index 114398fbb..000000000 --- a/+labkit/+ui/+runtime/private/resolvePortableFileReference.m +++ /dev/null @@ -1,33 +0,0 @@ -% Private Runtime V2 loading helper. Expected caller: project source -% resolution. Inputs are the loaded MAT-file path and a portable-reference -% struct. Outputs are the first existing candidate and its match kind; no user -% interaction or reference mutation occurs here. -function [targetFile, matchKind] = resolvePortableFileReference(anchorFile, reference) - anchorFile = string(anchorFile); - [anchorFolder, ~, ~] = fileparts(anchorFile); - candidates = strings(0, 1); - kinds = strings(0, 1); - if isfield(reference, 'relativePath') && strlength(reference.relativePath) > 0 - parts = cellstr(split(replace(string(reference.relativePath), "\", "/"), "/")); - candidates(end + 1) = fullfile(anchorFolder, parts{:}); - kinds(end + 1) = "relative"; - end - if isfield(reference, 'originalPath') && strlength(reference.originalPath) > 0 - candidates(end + 1) = string(reference.originalPath); - kinds(end + 1) = "original"; - end - if isfield(reference, 'fileName') && strlength(reference.fileName) > 0 - candidates(end + 1) = fullfile(anchorFolder, string(reference.fileName)); - kinds(end + 1) = "same_folder"; - end - targetFile = ""; - matchKind = "none"; - for index = 1:numel(candidates) - [exists, attributes] = fileattrib(char(candidates(index))); - if exists && ~attributes.directory - targetFile = string(attributes.Name); - matchKind = kinds(index); - return; - end - end -end diff --git a/+labkit/+ui/+runtime/private/resolvePreviewAxes.m b/+labkit/+ui/+runtime/private/resolvePreviewAxes.m deleted file mode 100644 index e58da1f20..000000000 --- a/+labkit/+ui/+runtime/private/resolvePreviewAxes.m +++ /dev/null @@ -1,22 +0,0 @@ -% Private UI runtime helper. Resolves framework-owned preview axes by -% semantic preview and optional axis ids. -function ax = resolvePreviewAxes(ui, id, axisId) -% -% Internal contract: -% ax = resolvePreviewAxes(ui, id, axisId) -% -% Inputs: -% ui - UI registry returned by labkit.ui.runtime.create or app runtime. -% id - semantic id for a previewArea. -% axisId - optional named axes id. When omitted, the preview area's primary -% axes is returned. -% -% Outputs: -% ax - MATLAB axes or uiaxes handle owned by the previewArea. -% - if nargin < 3 - axisId = ""; - end - control = resolvePlotControl(ui, id); - ax = controlAxes(control, axisId); -end diff --git a/+labkit/+ui/+runtime/private/resolveV2ProjectSources.m b/+labkit/+ui/+runtime/private/resolveV2ProjectSources.m deleted file mode 100644 index 44b9a80c2..000000000 --- a/+labkit/+ui/+runtime/private/resolveV2ProjectSources.m +++ /dev/null @@ -1,169 +0,0 @@ -% Private UI runtime helper. Expected caller: restoreV2Project after migration. -% Inputs are a complete project, envelope source records, project path, and app -% project spec. Outputs are a possibly relinked project and resolved source -% records for fresh session cache. Failed/cancelled relinking throws before the -% live runtime changes. -function [project, resolved, relinked] = resolveV2ProjectSources( ... - project, sources, filepath, spec, services) - relinked = false; - if isempty(sources) - sources = projectSourceRecords(project, sources); - end - [resolved, unresolved] = resolveRecords(sources, filepath); - if isempty(unresolved) - project = applyResolvedPaths(project, resolved); - return; - end - if isfield(spec, 'RelinkSources') && ... - isa(spec.RelinkSources, 'function_handle') - project = spec.RelinkSources(project, unresolved, string(filepath)); - else - project = promptForMissingSources( ... - project, unresolved, string(filepath), services); - end - if isempty(project) - error('labkit:ui:runtime:ProjectLoadCancelled', ... - 'Project source relinking was cancelled.'); - end - relinked = true; - validateSerializableState(project); - sources = projectSourceRecords(project, sources); - [resolved, unresolved] = resolveRecords(sources, filepath); - if ~isempty(unresolved) - error('labkit:ui:runtime:UnresolvedProjectSources', ... - 'Project still has unresolved required sources after relinking.'); - end - project = applyResolvedPaths(project, resolved); -end - -function project = promptForMissingSources( ... - project, unresolved, projectFile, services) - for k = 1:numel(unresolved) - source = unresolved(k); - sourceId = string(optionValue(source, 'id', "source" + k)); - role = string(optionValue(source, 'role', sourceId)); - reference = optionValue(source, 'reference', struct()); - fileName = string(optionValue(reference, 'fileName', "")); - if strlength(fileName) == 0 - fileName = "the required file"; - end - message = sprintf( ... - ['The saved project cannot find %s for source "%s".\n\n' ... - 'Locate the file to continue loading the project.'], ... - char(fileName), char(role)); - answer = services.dialogs.choice(message, "Missing project file", ... - ["Locate file", "Cancel"], "Locate file", "Cancel"); - if answer ~= "Locate file" - project = []; - return; - end - [selected, cancelled] = services.dialogs.inputFile( ... - fileFilter(fileName), "Locate " + fileName, ... - projectFolder(projectFile)); - if cancelled - project = []; - return; - end - project = replaceSourceReference( ... - project, sourceId, projectFile, selected); - end -end - -function filter = fileFilter(fileName) - [~, ~, extension] = fileparts(fileName); - if strlength(string(extension)) == 0 - filter = {'*.*', 'All files (*.*)'}; - return; - end - pattern = "*" + string(extension); - filter = {char(pattern), char(string(extension) + " files"); ... - '*.*', 'All files (*.*)'}; -end - -function folder = projectFolder(projectFile) - [folder, ~, ~] = fileparts(projectFile); - if strlength(string(folder)) == 0 || ~isfolder(folder) - folder = pwd; - end -end - -function project = replaceSourceReference( ... - project, sourceId, projectFile, selected) - if ~isfield(project, 'inputs') || ~isstruct(project.inputs) || ... - ~isfield(project.inputs, 'sources') || ... - isempty(project.inputs.sources) - error('labkit:ui:runtime:InvalidProject', ... - 'Project source "%s" cannot be relinked.', sourceId); - end - sources = project.inputs.sources; - match = find(string({sources.id}) == sourceId, 1, 'first'); - if isempty(match) - error('labkit:ui:runtime:InvalidProject', ... - 'Project source "%s" cannot be relinked.', sourceId); - end - sources(match).reference = ... - createPortableFileReference(projectFile, selected); - project.inputs.sources = sources; -end - -function project = applyResolvedPaths(project, resolved) - if isempty(resolved) || ~isfield(project, 'inputs') || ... - ~isstruct(project.inputs) || ... - ~isfield(project.inputs, 'sources') || ... - isempty(project.inputs.sources) - return; - end - sources = project.inputs.sources; - for k = 1:numel(resolved) - match = find(string({sources.id}) == resolved(k).id, 1, 'first'); - if isempty(match) || ~isfield(sources(match), 'reference') || ... - ~isstruct(sources(match).reference) - continue; - end - sources(match).reference.originalPath = resolved(k).path; - end - project.inputs.sources = sources; -end - -function [resolved, unresolved] = resolveRecords(sources, filepath) - resolved = struct("id", {}, "path", {}, "matchKind", {}); - unresolved = struct([]); - if isempty(sources) - return; - end - if ~isstruct(sources) - error('labkit:ui:runtime:InvalidProject', ... - 'Project sources must be a struct array.'); - end - for k = 1:numel(sources) - source = sources(k); - id = string(optionValue(source, 'id', "source" + k)); - reference = optionValue(source, 'reference', struct()); - [target, kind] = resolvePortableFileReference(filepath, reference); - if strlength(target) > 0 - resolved(end + 1) = struct( ... - "id", id, "path", target, "matchKind", kind); - elseif logical(optionValue(source, 'required', false)) - if isempty(unresolved) - unresolved = source; - else - unresolved(end + 1) = source; - end - end - end -end - -function sources = projectSourceRecords(project, fallback) - sources = fallback; - if isfield(project, 'inputs') && isstruct(project.inputs) && ... - isfield(project.inputs, 'sources') - sources = project.inputs.sources; - end -end - -function value = optionValue(spec, name, defaultValue) - value = defaultValue; - if isstruct(spec) && isfield(spec, name) - value = spec.(name); - end -end diff --git a/+labkit/+ui/+runtime/private/restoreV2Project.m b/+labkit/+ui/+runtime/private/restoreV2Project.m deleted file mode 100644 index a659009f2..000000000 --- a/+labkit/+ui/+runtime/private/restoreV2Project.m +++ /dev/null @@ -1,152 +0,0 @@ -% Private UI runtime helper. Expected caller: loadState. Inputs are a v2 app -% figure and project path. Side effect validates/migrates off to the side, then -% replaces project plus a fresh session in one visible commit. Any failure -% restores the prior runtime and presentation. -function restoreV2Project(fig, filepath, asRecovery) - if nargin < 3 - asRecovery = false; - end - runtime = getAppRuntime(fig); - previous = runtime; - [project, resume, envelope, needsUpgrade] = readV2ProjectFile( ... - filepath, runtime.definition); - sources = struct([]); - if isstruct(envelope) && isfield(envelope, 'sources') - sources = envelope.sources; - end - validateV2Project(project); - validateProjectPayload(runtime.definition.project.Validate, project); - services = buildV2RuntimeServices(fig, runtime, @(varargin) []); - [project, resolvedSources, relinkedSources] = resolveV2ProjectSources( ... - project, sources, filepath, runtime.definition.project, services); - session = createFreshSession(runtime.definition, project, resume); - session.cache.resolvedSources = resolvedSources; - nextState = struct("project", project, "session", session); - validateV2State(nextState, runtime.definition); - runtime.state = nextState; - runtime.document = documentFromEnvelope(runtime.document, envelope, filepath); - if relinkedSources || needsUpgrade - runtime.document.dirty = true; - end - if asRecovery - runtime.document.path = ""; - runtime.document.dirty = true; - end - runtime.document.loading = true; - try - % A replacement project owns a fresh session and must repaint every - % preview even when its semantic model equals the prior document. - runtime.lastPresentation = struct(); - setappdata(fig, appRuntimeKey(), runtime); - presentation = commitV2Presentation(runtime, nextState); - runtime = getAppRuntime(fig); - runtime.lastPresentation = presentation; - runtime.document.loading = false; - runtime.metrics.stateCommits = runtime.metrics.stateCommits + 1; - runtime.metrics.presentationCommits = ... - runtime.metrics.presentationCommits + 1; - setappdata(fig, appRuntimeKey(), runtime); - updateV2DocumentTitle(fig); - catch ME - setappdata(fig, appRuntimeKey(), previous); - try - commitV2Presentation(previous, previous.state); - catch - end - rethrow(ME); - end -end - -function validateProjectPayload(validator, project) - if nargout(validator) == 0 - validator(project); - return; - end - accepted = validator(project); - if ~isempty(accepted) && ... - ~(islogical(accepted) && isscalar(accepted) && accepted) - error('labkit:ui:runtime:InvalidProject', ... - 'Project.Validate rejected the loaded project payload.'); - end -end - -function session = createFreshSession(def, project, resume) - try - if isempty(def.createSession) - session = struct(); - elseif nargin(def.createSession) == 0 - session = def.createSession(); - else - session = def.createSession(project); - end - required = ["selection", "workflow", "view", "cache"]; - for k = 1:numel(required) - field = char(required(k)); - if ~isfield(session, field) - session.(field) = struct(); - end - end - if isfield(def.project, 'ApplyResume') && ... - isa(def.project.ApplyResume, 'function_handle') && ... - ~isempty(resume) - session = def.project.ApplyResume(session, resume, project); - end - catch cause - failure = MException( ... - 'labkit:ui:runtime:ProjectSessionRestoreFailed', ... - 'Could not rebuild the project session from %s: %s', ... - sourceDescription(project), cause.message); - failure = addCause(failure, cause); - throw(failure); - end -end - -function description = sourceDescription(project) - description = "project state"; - if ~isfield(project, 'inputs') || ~isstruct(project.inputs) || ... - ~isfield(project.inputs, 'sources') || ... - isempty(project.inputs.sources) - return; - end - sources = project.inputs.sources; - paths = labkit.ui.runtime.sourcePaths(sources); - count = min(numel(sources), 5); - labels = strings(count, 1); - for k = 1:count - id = string(sources(k).id); - role = string(sources(k).role); - [~, name, extension] = fileparts(paths(k)); - filename = string(name) + string(extension); - if strlength(filename) == 0 - filename = "(unresolved)"; - end - labels(k) = sprintf('inputs.sources id "%s" role "%s" file "%s"', ... - id, role, filename); - end - description = join(labels, "; "); - if numel(sources) > count - description = description + sprintf( ... - '; and %d additional source record(s)', numel(sources) - count); - end -end - -function document = documentFromEnvelope(current, envelope, filepath) - document = current; - if isstruct(envelope) && ~isempty(fieldnames(envelope)) && ... - isfield(envelope, 'document') - source = envelope.document; - fields = ["id", "createdAtUtc", "modifiedAtUtc", "revision"]; - for k = 1:numel(fields) - field = char(fields(k)); - if isfield(source, field) - document.(field) = source.(field); - end - end - else - document.id = string(char(java.util.UUID.randomUUID())); - document.revision = uint64(0); - end - document.path = string(filepath); - document.dirty = false; - document.envelope = envelope; -end diff --git a/+labkit/+ui/+runtime/private/runAppBusyCallback.m b/+labkit/+ui/+runtime/private/runAppBusyCallback.m deleted file mode 100644 index c707e129d..000000000 --- a/+labkit/+ui/+runtime/private/runAppBusyCallback.m +++ /dev/null @@ -1,93 +0,0 @@ -% Private Runtime V2 transaction helper. Expected caller: semantic callback -% dispatch. Inputs are the app figure, visible busy text, and one callback. -% Side effects: marks the figure busy and temporarily applies a working title -% and pointer without overwriting callback-owned changes during cleanup. -function runAppBusyCallback(fig, message, workFcn) - validFig = isLiveFigure(fig); - state = enterBusyState(fig, validFig, message); - cleanupObj = onCleanup(@() restoreBusyState(state)); - drawnow; - workFcn(); - clear cleanupObj -end - -function state = enterBusyState(fig, validFig, message) - state = struct( ... - 'fig', fig, ... - 'validFig', validFig, ... - 'oldName', "", ... - 'busyName', "", ... - 'oldPointer', "", ... - 'busyPointer', "watch", ... - 'busyState', struct('hadValue', false, 'value', [])); - if ~validFig - return; - end - - state.oldName = stripBusySuffix(fig.Name); - text = strip(string(message)); - if strlength(text) == 0 - text = "Working"; - end - state.busyName = state.oldName + " [Working: " + text + "]"; - fig.Name = char(state.busyName); - - if isprop(fig, 'Pointer') - state.oldPointer = string(fig.Pointer); - fig.Pointer = char(state.busyPointer); - end - state.busyState = setBusyFlag(fig, true); -end - -function name = stripBusySuffix(name) - name = string(name); - while true - stripped = string(regexprep(char(name), ... - '\s*\[Working: [^\]]*\]\s*$', '')); - if stripped == name - return; - end - name = stripped; - end -end - -function previous = setBusyFlag(fig, value) - key = 'labkitUiBusy'; - previous = struct('hadValue', isappdata(fig, key), 'value', []); - if previous.hadValue - previous.value = getappdata(fig, key); - end - setappdata(fig, key, logical(value)); -end - -function restoreBusyState(state) - if ~state.validFig || ~isLiveFigure(state.fig) - return; - end - fig = state.fig; - key = 'labkitUiBusy'; - if state.busyState.hadValue - setappdata(fig, key, state.busyState.value); - elseif isappdata(fig, key) - rmappdata(fig, key); - end - if isprop(fig, 'Pointer') && string(fig.Pointer) == state.busyPointer - fig.Pointer = char(state.oldPointer); - end - if isprop(fig, 'Name') && string(fig.Name) == state.busyName - fig.Name = char(state.oldName); - end - drawnow; -end - -function tf = isLiveFigure(fig) - tf = ~isempty(fig); - if ~tf - return; - end - try - tf = all(isvalid(fig)) && isprop(fig, 'Name'); - catch - tf = false; - end -end diff --git a/+labkit/+ui/+runtime/private/runAppDefinition.m b/+labkit/+ui/+runtime/private/runAppDefinition.m deleted file mode 100644 index 88d291b32..000000000 --- a/+labkit/+ui/+runtime/private/runAppDefinition.m +++ /dev/null @@ -1,25 +0,0 @@ -% Private UI runtime helper. Launches a validated Runtime V2 definition. -function fig = runAppDefinition(def, request) -% -% Internal contract: -% fig = runAppDefinition(def, request) -% -% Inputs: -% def - scalar struct returned by labkit.ui.runtime.define. -% request - optional struct. The `debug` field may contain a LabKit debug -% context created by dispatchRequest. Other app-specific -% fields are forwarded read-only to action handlers as services.request. -% -% Output: -% fig - created app figure. -% -% Runtime behavior: -% The framework validates the V2 definition and owns project/session -% creation, queued actions, presentation, resources, persistence, and Start. - - if nargin < 2 - request = struct(); - end - validateAppDefinition(def); - fig = runV2App(def, request); -end diff --git a/+labkit/+ui/+runtime/private/runSemanticAppCallback.m b/+labkit/+ui/+runtime/private/runSemanticAppCallback.m deleted file mode 100644 index 127575b42..000000000 --- a/+labkit/+ui/+runtime/private/runSemanticAppCallback.m +++ /dev/null @@ -1,144 +0,0 @@ -% Private UI runtime helper. Expected caller: semantic callback wrappers. -% Inputs are the current UI registry, control adapter, event payload, app -% callback, control id, optional delivery mode, and optional debounce delay. -% Side effects: runs the callback in app busy state or replaces one pending -% callback timer for the same control id. -function runSemanticAppCallback( ... - ui, control, event, appCallback, id, delivery, delaySeconds) - if isempty(appCallback) - return; - end - if nargin < 6 - delivery = "auto"; - end - if nargin < 7 - delaySeconds = []; - end - - if delivery == "debounced" || ... - (delivery == "auto" && shouldDebounce(control)) - scheduleDebouncedCallback( ... - ui, control, event, appCallback, id, delaySeconds); - return; - end - - runCallbackNow(ui.figure, control, event, appCallback, id); -end - -function runCallbackNow(fig, control, event, appCallback, id) - runAppBusyCallback(fig, actionBusyMessage(id, control.props), ... - @() appCallback(control, event)); -end - -function scheduleDebouncedCallback( ... - ui, control, event, appCallback, id, delaySeconds) - fig = ui.figure; - key = debounceKey(id); - clearExistingTimer(fig, key); - delay = debounceDelaySec(control.props, delaySeconds); - if delay <= 0 - runCallbackNow(fig, control, event, appCallback, id); - return; - end - state = struct( ... - 'control', control, ... - 'event', event, ... - 'appCallback', appCallback, ... - 'id', id, ... - 'delaySeconds', delaySeconds, ... - 'timer', []); - state.timer = timer( ... - 'ExecutionMode', 'singleShot', ... - 'StartDelay', delay, ... - 'TimerFcn', @(timerObj, ~) fireDebouncedCallback(fig, key, timerObj)); - setappdata(fig, key, state); - start(state.timer); -end - -function fireDebouncedCallback(fig, key, timerObj) - if isempty(fig) || ~isvalid(fig) || ~isappdata(fig, key) - deleteTimer(timerObj); - return; - end - state = getappdata(fig, key); - rmappdata(fig, key); - deleteTimer(timerObj); - if isempty(fig) || ~isvalid(fig) - return; - end - if isFigureBusy(fig) - scheduleDebouncedCallback(struct('figure', fig), state.control, ... - state.event, state.appCallback, state.id, state.delaySeconds); - return; - end - runCallbackNow(fig, state.control, state.event, state.appCallback, state.id); -end - -function clearExistingTimer(fig, key) - if isempty(fig) || ~isvalid(fig) || ~isappdata(fig, key) - return; - end - state = getappdata(fig, key); - rmappdata(fig, key); - if isstruct(state) && isfield(state, 'timer') - deleteTimer(state.timer); - end -end - -function deleteTimer(timerObj) - if isempty(timerObj) - return; - end - try - if isvalid(timerObj) - stop(timerObj); - delete(timerObj); - end - catch - end -end - -function tf = shouldDebounce(control) - tf = isfield(control, 'kind') && any(strcmp(control.kind, ... - {'field', 'rangeField', 'panner'})); -end - -function delay = debounceDelaySec(props, delaySeconds) - if isempty(delaySeconds) - delay = double(optionValue(props, 'debounceMs', 500)) / 1000; - else - delay = double(delaySeconds); - end - if ~isscalar(delay) || ~isfinite(delay) || delay < 0 - delay = 0.500; - end -end - -function key = debounceKey(id) - key = ['labkitUiSemanticDebounce_' matlab.lang.makeValidName(char(string(id)))]; -end - -function tf = isFigureBusy(fig) - tf = false; - try - tf = isappdata(fig, 'labkitUiBusy') && ... - logical(getappdata(fig, 'labkitUiBusy')); - catch - tf = false; - end -end - -function message = actionBusyMessage(id, props) - message = optionValue(props, 'busyMessage', ""); - if strlength(string(message)) == 0 - message = optionValue(props, 'label', id); - end - message = char(string(message)); -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/+runtime/private/runV2App.m b/+labkit/+ui/+runtime/private/runV2App.m deleted file mode 100644 index 87bf69f29..000000000 --- a/+labkit/+ui/+runtime/private/runV2App.m +++ /dev/null @@ -1,472 +0,0 @@ -% Private UI runtime helper. Expected caller: runAppDefinition for a V2 -% definition. Inputs are a validated definition and request. Output is the app -% figure. Side effects create the workbench, install one FIFO event queue, -% commit canonical state/presentation transactions, and own runtime resources. -function fig = runV2App(def, request) - debug = requestDebugContext(request); - progressReporter = startupProgressReporter(request); - reportStartupProgress(progressReporter, "Creating app state..."); - state = createV2State(def); - callbacks = runtimeCallbacks(def.actions); - bindingCallback = @dispatchBindingCallback; - reportStartupProgress(progressReporter, "Preparing app layout..."); - [layout, bindings] = prepareV2Layout( ... - def, callbacks, state, bindingCallback); - reportStartupProgress(progressReporter, "Building app shell..."); - ui = buildRuntimeWorkbench(layout, debug, progressReporter); - fig = ui.figure; - runtime = struct( ... - "definition", def, ... - "state", state, ... - "actions", def.actions, ... - "ui", ui, ... - "debug", debug, ... - "request", request, ... - "bindings", bindings, ... - "queue", {{}}, ... - "processing", false, ... - "resources", emptyResources(), ... - "resourceListener", [], ... - "interactionHub", [], ... - "document", createV2DocumentState(), ... - "lastPresentation", struct(), ... - "metrics", struct("stateCommits", 0, ... - "presentationCommits", 0, "eventsCompleted", 0)); - setappdata(fig, appRuntimeKey(), runtime); - installResourceCleanup(fig); - runtime = getAppRuntime(fig); - runtime.interactionHub = v2FigureInteractionHub( ... - ui, @(event) enqueueEvent(fig, event), ... - @(target) disposeInteractionsForTarget(fig, target)); - setappdata(fig, appRuntimeKey(), runtime); - v2ResourceRegistry(fig, "set", "figure", ... - "interactionHub", runtime.interactionHub, ... - @(hub) hub.delete()); - startupLifecycle(fig, 'update', "Preparing first view..."); - presentation = commitV2Presentation(getAppRuntime(fig), state); - runtime = getAppRuntime(fig); - runtime.lastPresentation = presentation; - runtime.metrics.presentationCommits = 1; - setappdata(fig, appRuntimeKey(), runtime); - startupLifecycle(fig, 'update', "Running startup actions..."); - dispatchStart(fig, def.start); - dispatchDebugSample(fig, def.debugSample); - restoreRequestedRecovery(fig, request); - startupLifecycle(fig, 'finish', "Ready."); - - function dispatchBindingCallback(control, event, path, eventId) - canonical = canonicalEvent(eventId, control.id, event, "user"); - canonical.meta.bindingPath = path; - enqueueEvent(fig, canonical); - end -end - -function restoreRequestedRecovery(fig, request) - runtime = getAppRuntime(fig); - candidate = discoverV2RecoveryFile(runtime.definition, request); - if strlength(candidate) > 0 - setappdata(fig, 'labkitV2RecoveryCandidate', candidate); - end - requested = ""; - if isstruct(request) && isfield(request, 'recoveryFile') - requested = string(request.recoveryFile); - elseif isstruct(request) && isfield(request, 'recover') && ... - logical(request.recover) - requested = candidate; - end - if strlength(requested) == 0 - return; - end - restoreV2Project(fig, requested, true); -end - -function disposeInteractionsForTarget(fig, target) - ids = v2ResourceRegistry(fig, "listIds", "interaction"); - for k = 1:numel(ids) - controlled = v2ResourceRegistry(fig, "get", ... - "interaction", ids(k)); - if ~isempty(controlled) && isfield(controlled, 'spec') && ... - any(controlled.spec.Targets == string(target)) - v2ResourceRegistry(fig, "remove", "interaction", ids(k)); - end - end -end - -function callbacks = runtimeCallbacks(actions) - callbacks = struct(); - ids = string(fieldnames(actions)); - for k = 1:numel(ids) - id = ids(k); - callbacks.(char(id)) = @(control, event) dispatchUiEvent( ... - control, event, id); - end - callbacks.runtimeLoadState = @dispatchRuntimeLoadState; -end - -function dispatchRuntimeLoadState(control, event) - fig = runtimeFigure(control, event); - filepath = ""; - if isappdata(fig, 'labkitUiUtilityStateFile') - filepath = string(getappdata(fig, 'labkitUiUtilityStateFile')); - end - if strlength(filepath) == 0 - labkit.ui.runtime.loadState(fig); - else - labkit.ui.runtime.loadState(fig, filepath); - end -end - -function dispatchUiEvent(control, event, id) - fig = runtimeFigure(control, event); - target = id; - if isstruct(control) && isfield(control, 'id') - target = string(control.id); - end - enqueueEvent(fig, canonicalEvent(id, target, event, "user")); -end - -function fig = runtimeFigure(control, event) - fig = []; - if isstruct(event) && isfield(event, 'ui') && isstruct(event.ui) && ... - isfield(event.ui, 'figure') - fig = event.ui.figure; - elseif isstruct(control) && isfield(control, 'handle') - fig = ancestor(control.handle, 'figure'); - elseif ~isstruct(control) - fig = ancestor(control, 'figure'); - end -end - -function event = canonicalEvent(id, target, sourceEvent, source) - event = struct("id", string(id), "source", string(source), ... - "target", string(target), "value", [], "meta", struct()); - if isstruct(sourceEvent) - if isfield(sourceEvent, 'value') - event.value = sourceEvent.value; - end - event.meta.original = removeRuntimeFields(sourceEvent); - elseif ~isempty(sourceEvent) - event.value = sourceEvent; - end -end - -function value = removeRuntimeFields(value) - for field = ["ui", "rawEvent"] - name = char(field); - if isfield(value, name) - value = rmfield(value, name); - end - end -end - -function dispatchStart(fig, start) - if isempty(start) - return; - end - event = canonicalEvent("start", "runtime", [], "startup"); - if isa(start, 'function_handle') - event.meta.startHandler = start; - else - event.id = string(start); - end - enqueueEvent(fig, event); -end - -function dispatchDebugSample(fig, writer) - if isempty(writer) - return; - end - runtime = getAppRuntime(fig); - if ~isstruct(runtime.debug) || ~isfield(runtime.debug, 'enabled') || ... - ~logical(runtime.debug.enabled) - return; - end - event = canonicalEvent("debugSample", "runtime", [], "startup"); - event.meta.startHandler = ... - @(state, ~, services) writeDebugSample(state, services, writer); - enqueueEvent(fig, event); -end - -function state = writeDebugSample(state, services, writer) - services.debug.trace('Runtime debug sample generation enabled.'); - state = services.workflow.log(state, ... - "Debug sample generation enabled."); - try - pack = writer(services.debug); - if isstruct(pack) && isfield(pack, 'sampleFolder') - state = services.workflow.log(state, ... - "Debug sample files: " + string(pack.sampleFolder)); - end - if isstruct(pack) && isfield(pack, 'outputFolder') - state = services.workflow.log(state, ... - "Debug output folder: " + string(pack.outputFolder)); - end - catch ME - services.diagnostics.report('Debug sample setup failed', ME); - state = services.workflow.log(state, ... - "Debug sample setup failed: " + ME.message); - end -end - -function enqueueEvent(fig, event) - event = normalizeDispatchedEvent(event); - runtime = getAppRuntime(fig); - runtime.queue{end + 1} = event; - if runtime.processing - setappdata(fig, appRuntimeKey(), runtime); - return; - end - runtime.processing = true; - setappdata(fig, appRuntimeKey(), runtime); - drainQueue(fig); -end - -function drainQueue(fig) - try - while true - runtime = getAppRuntime(fig); - if isempty(runtime.queue) - runtime.processing = false; - setappdata(fig, appRuntimeKey(), runtime); - return; - end - event = runtime.queue{1}; - runtime.queue(1) = []; - setappdata(fig, appRuntimeKey(), runtime); - processEvent(fig, event); - end - catch ME - if ~isempty(fig) && isvalid(fig) && isappdata(fig, appRuntimeKey()) - runtime = getappdata(fig, appRuntimeKey()); - runtime.processing = false; - runtime.queue = {}; - setappdata(fig, appRuntimeKey(), runtime); - end - rethrow(ME); - end -end - -function processEvent(fig, event) - runtime = getAppRuntime(fig); - previous = runtime.state; - startedAt = tic; - try - next = applyBinding(previous, event); - services = buildV2RuntimeServices(fig, runtime, ... - @(event, varargin) dispatchProgrammatic( ... - fig, event, varargin{:})); - handler = eventHandler(runtime, event); - if ~isempty(handler) - next = invokeHandler(handler, next, event, services); - end - validateV2State(next, runtime.definition); - latest = getAppRuntime(fig); - latest.state = next; - setappdata(fig, appRuntimeKey(), latest); - presentation = commitV2Presentation( ... - latest, next, event.source == "interaction"); - latest = getAppRuntime(fig); - latest.lastPresentation = presentation; - latest.metrics.stateCommits = latest.metrics.stateCommits + 1; - latest.metrics.presentationCommits = ... - latest.metrics.presentationCommits + 1; - latest.metrics.eventsCompleted = latest.metrics.eventsCompleted + 1; - if event.source ~= "startup" && ... - ~isequaln(previous.project, next.project) - latest.document.dirty = true; - latest.document.revision = latest.document.revision + uint64(1); - end - setappdata(fig, appRuntimeKey(), latest); - updateV2DocumentTitle(fig); - if latest.document.dirty - scheduleV2Autosave(fig); - end - v2ResourceRegistry(fig, "clearScope", "event"); - appendPhaseTiming(fig, event, "completed", toc(startedAt), []); - catch ME - if ~isempty(fig) && isvalid(fig) && isappdata(fig, appRuntimeKey()) - latest = getappdata(fig, appRuntimeKey()); - latest.state = previous; - setappdata(fig, appRuntimeKey(), latest); - v2ResourceRegistry(fig, "clearScope", "event"); - restorePresentation( ... - fig, previous, event.source == "interaction"); - end - appendPhaseTiming(fig, event, "failed", toc(startedAt), ME); - reportRuntimeException(fig, event, ME); - rethrow(ME); - end -end - -function next = applyBinding(state, event) - next = state; - if ~isfield(event.meta, 'bindingPath') - return; - end - path = string(event.meta.bindingPath); - previous = valueAtPath(state, path); - value = normalizeBoundValue(event.value, previous); - next = setValueAtPath(next, split(path, "."), value); -end - -function value = normalizeBoundValue(value, fallback) - if isnumeric(fallback) && isscalar(fallback) - candidate = double(value); - if ~(isnumeric(candidate) && isscalar(candidate) && isfinite(candidate)) - value = fallback; - else - value = candidate; - end - end -end - -function value = valueAtPath(state, path) - value = state; - parts = split(path, "."); - for k = 1:numel(parts) - value = value.(char(parts(k))); - end -end - -function value = setValueAtPath(value, parts, replacement) - field = char(parts(1)); - if numel(parts) == 1 - value.(field) = replacement; - else - value.(field) = setValueAtPath(value.(field), parts(2:end), replacement); - end -end - -function handler = eventHandler(runtime, event) - handler = []; - if isfield(event.meta, 'startHandler') - handler = event.meta.startHandler; - return; - end - if strlength(event.id) == 0 - return; - end - field = char(event.id); - if ~isfield(runtime.actions, field) - error('labkit:ui:runtime:UnknownAction', ... - 'App definition action "%s" is not registered.', event.id); - end - handler = runtime.actions.(field); -end - -function next = invokeHandler(handler, state, event, services) - count = nargout(handler); - if count == 0 - handler(state, event, services); - next = state; - else - next = handler(state, event, services); - end -end - -function dispatchProgrammatic(fig, event, varargin) - if isstruct(event) - canonical = event; - if ~isfield(canonical, 'source') - canonical.source = "service"; - end - else - value = []; - if ~isempty(varargin) - value = varargin{1}; - end - canonical = canonicalEvent(string(event), "runtime", value, "service"); - end - enqueueEvent(fig, canonical); -end - -function event = normalizeDispatchedEvent(event) - if ~isstruct(event) || ~isscalar(event) - error('labkit:ui:runtime:InvalidEvent', ... - 'Runtime events must be scalar structs.'); - end - defaults = struct("id", "", "source", "service", "target", "runtime", ... - "value", [], "meta", struct()); - fields = fieldnames(defaults); - for k = 1:numel(fields) - field = fields{k}; - if ~isfield(event, field) - event.(field) = defaults.(field); - end - end - event.id = string(event.id); - event.source = string(event.source); - event.target = string(event.target); - if ~isscalar(event.id) || ~isscalar(event.source) || ~isscalar(event.target) || ... - ~isstruct(event.meta) || ~isscalar(event.meta) - error('labkit:ui:runtime:InvalidEvent', ... - 'Event id, source, and target must be scalar text and meta a scalar struct.'); - end -end - -function restorePresentation(fig, state, preserveView) - try - runtime = getAppRuntime(fig); - presentation = commitV2Presentation(runtime, state, preserveView); - runtime = getAppRuntime(fig); - runtime.lastPresentation = presentation; - setappdata(fig, appRuntimeKey(), runtime); - catch - end -end - -function installResourceCleanup(fig) - try - listener = addlistener(fig, 'ObjectBeingDestroyed', ... - @(~, ~) v2ResourceRegistry(fig, "clearAll")); - runtime = getAppRuntime(fig); - runtime.resourceListener = listener; - setappdata(fig, appRuntimeKey(), runtime); - catch - end -end - -function debug = requestDebugContext(request) - debug = []; - if isstruct(request) && isfield(request, 'debug') - debug = request.debug; - elseif isstruct(request) && isfield(request, 'Debug') - debug = request.Debug; - end -end - -function appendPhaseTiming(fig, event, status, elapsedSeconds, exception) - record = struct("kind", string(event.source), "id", string(event.id), ... - "source", string(event.source), "status", string(status), ... - "elapsedSeconds", elapsedSeconds, "errorIdentifier", "", ... - "errorMessage", ""); - if isa(exception, 'MException') - record.errorIdentifier = string(exception.identifier); - record.errorMessage = string(exception.message); - end - key = 'labkitUiAppRuntimePhases'; - if isappdata(fig, key) - records = getappdata(fig, key); - records(end + 1) = record; - else - records = record; - end - setappdata(fig, key, records); -end - -function reportRuntimeException(fig, event, exception) - try - runtime = getAppRuntime(fig); - if isstruct(runtime.debug) && isfield(runtime.debug, 'reportException') - runtime.debug.reportException('runtime', ... - sprintf('%s event %s failed', char(event.source), ... - char(event.id)), exception); - end - catch - end -end - -function resources = emptyResources() - resources = struct("scope", {}, "id", {}, "value", {}, ... - "cleanup", {}, "disposed", {}); -end diff --git a/+labkit/+ui/+runtime/private/scheduleV2Autosave.m b/+labkit/+ui/+runtime/private/scheduleV2Autosave.m deleted file mode 100644 index 193f6c34d..000000000 --- a/+labkit/+ui/+runtime/private/scheduleV2Autosave.m +++ /dev/null @@ -1,57 +0,0 @@ -% Private UI runtime helper. Expected caller: successful v2 project commits. -% Input is an app figure. Side effect replaces one debounced timer resource; -% the timer writes a bounded recovery generation only while the queue is idle -% and no load, export, or drag is active. -function scheduleV2Autosave(fig) - runtime = getAppRuntime(fig); - if ~runtime.document.dirty || autosaveDisabled(runtime.request) - return; - end - delay = autosaveDelay(runtime.request); - autosaveTimer = timer( ... - "ExecutionMode", "singleShot", ... - "StartDelay", delay, ... - "TimerFcn", @(~, ~) runAutosave(fig), ... - "ErrorFcn", @(~, ~) []); - v2ResourceRegistry(fig, "set", "figure", "autosaveTimer", ... - autosaveTimer, @disposeTimer); - start(autosaveTimer); -end - -function runAutosave(fig) - if isempty(fig) || ~isvalid(fig) || ~isappdata(fig, appRuntimeKey()) - return; - end - runtime = getAppRuntime(fig); - blocked = runtime.processing || runtime.document.loading || ... - runtime.document.exporting || runtime.interactionHub.isDragging(); - if blocked - scheduleV2Autosave(fig); - return; - end - current = writeV2RecoveryFile(runtime); - setappdata(fig, 'labkitV2RecoveryFile', string(current)); -end - -function tf = autosaveDisabled(request) - tf = isstruct(request) && isfield(request, 'autosave') && ... - ~logical(request.autosave); -end - -function delay = autosaveDelay(request) - delay = 2; - if isstruct(request) && isfield(request, 'autosaveDelay') - candidate = double(request.autosaveDelay); - if isscalar(candidate) && isfinite(candidate) && candidate >= 0 - delay = candidate; - end - end -end - -function disposeTimer(value) - if isempty(value) || ~isvalid(value) - return; - end - stop(value); - delete(value); -end diff --git a/+labkit/+ui/+runtime/private/semanticEvent.m b/+labkit/+ui/+runtime/private/semanticEvent.m deleted file mode 100644 index 4ee38cfac..000000000 --- a/+labkit/+ui/+runtime/private/semanticEvent.m +++ /dev/null @@ -1,38 +0,0 @@ -% Private UI runtime helper. Expected callers: semantic callback wrappers under -% +labkit/+ui/+runtime/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:runtime:MissingRegistry', ... - 'UI registry appdata was not found on the current figure.'); - end - ui = getappdata(fig, 'labkitUiRegistry'); -end diff --git a/+labkit/+ui/+runtime/private/setControlEnabled.m b/+labkit/+ui/+runtime/private/setControlEnabled.m deleted file mode 100644 index 4684be9b9..000000000 --- a/+labkit/+ui/+runtime/private/setControlEnabled.m +++ /dev/null @@ -1,37 +0,0 @@ -% Private UI runtime helper. Expected caller: V2 presentation commit. Updates -% Enable-bearing handles for one semantic control without invoking app code. -function setControlEnabled(ui, id, enabled) -% -% Internal contract: -% setControlEnabled(ui, id, enabled) -% -% Inputs: -% ui - UI registry returned by labkit.ui.runtime.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/+runtime/private/setControlFileSelection.m b/+labkit/+ui/+runtime/private/setControlFileSelection.m deleted file mode 100644 index 55e3d1452..000000000 --- a/+labkit/+ui/+runtime/private/setControlFileSelection.m +++ /dev/null @@ -1,27 +0,0 @@ -% Private UI runtime helper. Expected caller: builders and V2 presentation. -% Applies semantic file selection without invoking the app callback. -function setControlFileSelection(ui, id, filesOrIds) -% -% Internal contract: -% setControlFileSelection(ui, id, filesOrIds) -% -% Inputs: -% ui - UI registry returned by labkit.ui.runtime.create. -% id - semantic id for a filePanel. -% filesOrIds - file-entry structs emitted by filePanel/getFiles, or -% string/cell file ids. -% -% Output: -% None. The filePanel list selection is updated without invoking the app -% selection callback. - - control = resolveControl(ui, id); - if ~isfield(control, 'kind') || ~strcmp(control.kind, 'filePanel') || ... - ~isfield(control, 'setFileSelection') || ... - ~isa(control.setFileSelection, 'function_handle') - error('labkit:ui:control:NotFilePanel', ... - 'Control "%s" is not a filePanel.', control.id); - end - control.setFileSelection(control, filesOrIds); - applySelectedFileContext(ui, id); -end diff --git a/+labkit/+ui/+runtime/private/setControlItems.m b/+labkit/+ui/+runtime/private/setControlItems.m deleted file mode 100644 index 13e062e38..000000000 --- a/+labkit/+ui/+runtime/private/setControlItems.m +++ /dev/null @@ -1,49 +0,0 @@ -% Private UI runtime helper. Expected caller: V2 presentation commit. Replaces -% selectable items while suppressing app callbacks. -function setControlItems(ui, id, items) -% -% Internal contract: -% setControlItems(ui, id, items) -% -% Inputs: -% ui - UI registry returned by labkit.ui.runtime.create. -% id - semantic id for a control whose primary handle exposes Items. -% items - nonempty cell array or string array of display values. -% -% Output: -% None. The current selection is preserved when it remains valid; -% otherwise the first new item is selected without firing the app callback. - - values = cellstr(string(items(:))); - if isempty(values) - error('labkit:ui:control:EmptyItems', ... - 'Selectable control items must not be empty.'); - end - control = resolveControl(ui, id); - handle = controlValueHandle(control); - if ~isprop(handle, 'Items') || ~isprop(handle, 'Value') - error('labkit:ui:control:NoItems', ... - 'Control "%s" does not expose selectable items.', control.id); - end - - callback = []; - if isprop(handle, 'ValueChangedFcn') - callback = handle.ValueChangedFcn; - handle.ValueChangedFcn = []; - end - cleanupObj = onCleanup(@() restoreCallback(handle, callback)); - previous = string(handle.Value); - handle.Items = values; - if any(string(values) == previous) - handle.Value = char(previous); - else - handle.Value = values{1}; - end - clear cleanupObj; -end - -function restoreCallback(handle, callback) - if ~isempty(handle) && isvalid(handle) && isprop(handle, 'ValueChangedFcn') - handle.ValueChangedFcn = callback; - end -end diff --git a/+labkit/+ui/+runtime/private/setControlLimits.m b/+labkit/+ui/+runtime/private/setControlLimits.m deleted file mode 100644 index 7f6fb6693..000000000 --- a/+labkit/+ui/+runtime/private/setControlLimits.m +++ /dev/null @@ -1,136 +0,0 @@ -% Private UI runtime helper. Expected caller: V2 presentation commit. Applies -% validated numeric limits while suppressing app callbacks. -function setControlLimits(ui, id, limits) -% -% Internal contract: -% setControlLimits(ui, id, limits) -% -% Inputs: -% ui - UI registry returned by labkit.ui.runtime.create. -% id - globally unique semantic control id. -% limits - two-element increasing numeric vector. -% -% Output: -% None. Controls with a current Value are clamped into the new limits while -% their value-change callback is temporarily suppressed. - - limits = double(limits(:)).'; - if numel(limits) ~= 2 || any(isnan(limits)) || limits(1) >= limits(2) - error('labkit:ui:control:InvalidLimits', ... - 'Limits must be an increasing two-element numeric vector.'); - end - - control = resolveControl(ui, id); - if ~isPanner(control) && any(~isfinite(limits)) - error('labkit:ui:control:InvalidLimits', ... - 'Only panner controls accept infinite numeric limits.'); - end - handles = limitsHandles(control, limits); - if isempty(handles) - error('labkit:ui:control:NoLimits', ... - 'Control "%s" does not expose numeric Limits.', control.id); - end - - for k = 1:numel(handles) - handle = handles{k}; - callback = callbackProperty(handle); - cleanupObj = suppressCallback(handle, callback); - handle.Limits = limits; - if isprop(handle, 'Value') && isnumeric(handle.Value) && isscalar(handle.Value) - handle.Value = min(limits(2), max(limits(1), handle.Value)); - end - clear cleanupObj; - end - updatePannerStep(control, limits); -end - -function updatePannerStep(control, limits) - if ~isPanner(control) || ~isfield(control, 'valueSpinner') || ... - ~isvalid(control.valueSpinner) - return; - end - if isfield(control.props, 'step') - return; - end - if ~all(isfinite(limits)) - return; - end - span = max(eps, diff(double(limits))); - fraction = optionValue(control.props, 'stepFraction', 0.002); - step = span .* max(eps, double(fraction)); - if isfield(control.props, 'minStep') - step = max(step, double(control.props.minStep)); - end - if isfield(control.props, 'maxStep') - step = min(step, double(control.props.maxStep)); - end - control.valueSpinner.Step = step; -end - -function handles = limitsHandles(control, limits) - if isPanner(control) - handles = pannerLimitsHandles(control, limits); - return; - end - allHandles = controlHandles(control); - handles = cell(1, numel(allHandles)); - count = 0; - for k = 1:numel(allHandles) - handle = allHandles{k}; - if isprop(handle, 'Limits') - count = count + 1; - handles{count} = handle; - end - end - handles = handles(1:count); -end - -function handles = pannerLimitsHandles(control, limits) - handles = {}; - if isfield(control, 'valueSpinner') && ~isempty(control.valueSpinner) && ... - isvalid(control.valueSpinner) - handles{end+1} = control.valueSpinner; - end - if all(isfinite(limits)) && isfield(control, 'slider') && ... - ~isempty(control.slider) && isvalid(control.slider) - handles{end+1} = control.slider; - end -end - -function tf = isPanner(control) - tf = isfield(control, 'kind') && strcmp(control.kind, 'panner'); -end - -function callback = callbackProperty(handle) - callback = struct('property', '', 'value', []); - for name = {'ValueChangedFcn'} - 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 - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isstruct(opts) && isfield(opts, name) - value = opts.(name); - end -end diff --git a/+labkit/+ui/+runtime/private/setControlValue.m b/+labkit/+ui/+runtime/private/setControlValue.m deleted file mode 100644 index 028610481..000000000 --- a/+labkit/+ui/+runtime/private/setControlValue.m +++ /dev/null @@ -1,60 +0,0 @@ -% Private UI runtime helper. Expected caller: V2 presentation commit. Applies -% one semantic value while suppressing app callbacks. -function setControlValue(ui, id, value) -% -% Internal contract: -% setControlValue(ui, id, value) -% -% Inputs: -% ui - UI registry returned by labkit.ui.runtime.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); - if isfield(control, 'kind') && strcmp(control.kind, 'filePanel') - applySelectedFileContext(ui, id); - end - 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/+runtime/private/setOriginalCallbackName.m b/+labkit/+ui/+runtime/private/setOriginalCallbackName.m deleted file mode 100644 index e32613471..000000000 --- a/+labkit/+ui/+runtime/private/setOriginalCallbackName.m +++ /dev/null @@ -1,12 +0,0 @@ -% Private UI runtime diagnostic helper. Expected caller: UI control builders. Input -% is a MATLAB UI handle and app callback. Side effects: stores the callback -% function name for debug instrumentation labels when available. -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 diff --git a/+labkit/+ui/+runtime/private/setReadonlyText.m b/+labkit/+ui/+runtime/private/setReadonlyText.m deleted file mode 100644 index 35757c992..000000000 --- a/+labkit/+ui/+runtime/private/setReadonlyText.m +++ /dev/null @@ -1,23 +0,0 @@ -% Private UI runtime helper. Expected caller: readonly field adapters. Inputs are -% a text-bearing MATLAB UI handle and a new value. Output is none. Side -% effects update the visible text and reapply default text fitting. -function setReadonlyText(control, value) - text = char(string(value)); - if isprop(control, 'Text') - if strcmp(control.Text, text) - return; - end - control.Text = text; - elseif isprop(control, 'Value') - currentText = readonlyValueText(control.Value); - if strcmp(currentText, text) - return; - end - control.Value = text; - end - applyTextFit(control); -end - -function text = readonlyValueText(value) - text = char(join(string(value(:)), newline)); -end diff --git a/+labkit/+ui/+runtime/private/showAlert.m b/+labkit/+ui/+runtime/private/showAlert.m deleted file mode 100644 index 5f59cc0fa..000000000 --- a/+labkit/+ui/+runtime/private/showAlert.m +++ /dev/null @@ -1,59 +0,0 @@ -% Private UI runtime helper. Shows an alert or records it in hidden tests. -function shown = showAlert(fig, message, titleText) -% -% App-facing contract: -% shown = showAlert(fig, message, titleText) -% -% Inputs: -% fig - app uifigure that owns the alert. -% message - user-facing alert message owned by the calling app. -% titleText - user-facing alert title owned by the calling app. -% -% Output: -% shown - true when a modal alert was shown, false when hidden GUI test -% mode recorded the alert without opening a modal dialog. - - if nargin < 3 - titleText = ""; - end - recordAlert(fig, message, titleText); - if isHiddenGuiTestMode() - traceAlert(fig, message, titleText, "skipped-hidden-gui"); - shown = false; - return; - end - traceAlert(fig, message, titleText, "shown"); - uialert(fig, message, titleText); - shown = true; -end - -function tf = isHiddenGuiTestMode() - tf = string(getenv('LABKIT_GUI_TEST_MODE')) == "hidden"; -end - -function recordAlert(fig, message, titleText) - if isempty(fig) || ~isvalid(fig) - return; - end - alert = struct( ... - 'title', string(titleText), ... - 'message', string(message)); - if isappdata(fig, 'labkitUiAlerts') - alerts = getappdata(fig, 'labkitUiAlerts'); - alerts(end + 1, 1) = alert; - else - alerts = alert; - end - setappdata(fig, 'labkitUiAlerts', alerts); -end - -function traceAlert(fig, message, titleText, reason) - if isempty(fig) || ~isvalid(fig) || ~isappdata(fig, 'labkitUiDebugContext') - return; - end - debug = getappdata(fig, 'labkitUiDebugContext'); - if isstruct(debug) && isfield(debug, 'trace') && isa(debug.trace, 'function_handle') - debug.trace('alert', sprintf('%s: %s', char(string(titleText)), ... - char(string(message))), reason); - end -end diff --git a/+labkit/+ui/+runtime/private/startupLifecycle.m b/+labkit/+ui/+runtime/private/startupLifecycle.m deleted file mode 100644 index 22b517caf..000000000 --- a/+labkit/+ui/+runtime/private/startupLifecycle.m +++ /dev/null @@ -1,426 +0,0 @@ -% Private UI runtime helper. Expected callers are labkit.ui.runtime.create and -% Runtime V2 launch. Inputs are app figures, internal UI registry handles, -% messages, and runtime task callbacks. Side effects are limited to framework -% startup appdata, non-modal status UI, timer scheduling, and the startup busy -% flag used to gate callbacks during first-render initialization. -function varargout = startupLifecycle(fig, action, varargin) - action = char(string(action)); - switch action - case 'start' - state = startState(fig, varargin{:}); - setState(fig, state); - case 'update' - state = updateState(fig, varargin{:}); - setState(fig, state); - case 'defer' - [state, taskTimer] = deferTask(fig, varargin{:}); - if ~isempty(state) - setState(fig, state); - end - varargout{1} = taskTimer; - case 'finish' - state = requestFinish(fig, varargin{:}); - setState(fig, state); - otherwise - error('labkit:ui:startup:InvalidAction', ... - 'Unsupported startup lifecycle action "%s".', action); - end -end - -function state = startState(fig, ui, message, varargin) - state = defaultState(fig); - state.mainGrid = ui.main; - state.panel = ui.startupStatusPanel; - state.label = ui.startupStatusLabel; - state.statusRow = ui.startupStatusPanel.Layout.Row; - rememberHandles(fig, state); - state.oldBusy = captureBusy(fig); - if ~isempty(varargin) - state.progressReporter = varargin{1}; - end - setappdata(fig, 'labkitUiBusy', true); - state = updateStateWithMessage(state, message, false); -end - -function state = updateState(fig, message) - state = getState(fig); - if isempty(state) - return; - end - state = updateStateWithMessage(state, message, false); -end - -function [state, taskTimer] = deferTask(fig, message, workFcn) - state = getState(fig); - if isempty(state) - state = defaultState(fig); - state = restoreHandles(fig, state); - state.oldBusy = captureBusy(fig); - setappdata(fig, 'labkitUiBusy', true); - state = updateStateWithMessage(state, message, true); - else - state = updateStateWithMessage(state, message, true); - end - taskId = state.nextTaskId; - state.nextTaskId = state.nextTaskId + 1; - state.pending = state.pending + 1; - if startupTaskRunsInline() - taskTimer = []; - setState(fig, state); - runDeferredTask(fig, taskTimer, message, workFcn); - state = getState(fig); - return; - end - taskTimer = timer( ... - 'ExecutionMode', 'singleShot', ... - 'StartDelay', startupTaskDelay(), ... - 'TimerFcn', @(source, ~) runDeferredTask(fig, source, message, workFcn)); - state.timers = [state.timers, taskTimer]; - start(taskTimer); -end - -function state = requestFinish(fig, message) - state = updateState(fig, message); - if isempty(state) - return; - end - state.finishRequested = true; - state = completeIfReady(state); -end - -function state = updateStateWithMessage(state, message, forceVisible) - if nargin < 3 - forceVisible = false; - end - if isempty(state) || ~isLiveHandle(state.fig) - return; - end - state.message = string(message); - reportStartupProgress(state.progressReporter, state.message); - becameVisible = false; - if shouldShowStatus(state, forceVisible) - state = showStatus(state); - becameVisible = state.visible; - end - if shouldUpdateStatusLabel(state, becameVisible) && isLiveHandle(state.label) - state.label.Text = char(state.message); - state.statusLabelUpdated = true; - end - if shouldFlushStatus(state, becameVisible) - drawnow limitrate; - state.statusFlushed = true; - end -end - -function tf = shouldUpdateStatusLabel(state, becameVisible) - tf = state.visible && (becameVisible || ~state.statusLabelUpdated || ... - isFailureMessage(state.message)); -end - -function tf = shouldShowStatus(state, forceVisible) - tf = ~state.visible && ~startupStatusSuppressed() && ... - (forceVisible || toc(state.startedAt) >= startupStatusDelay()); -end - -function state = showStatus(state) - if ~isLiveHandle(state.panel) || ~isLiveHandle(state.mainGrid) - return; - end - try - heights = state.mainGrid.RowHeight; - if numel(heights) >= state.statusRow - heights{state.statusRow} = 28; - state.mainGrid.RowHeight = heights; - end - state.panel.Visible = 'on'; - state.visible = true; - state.visibleAt = tic; - catch - end -end - -function tf = shouldFlushStatus(state, becameVisible) - tf = isFigureVisible(state.fig) && (becameVisible || ... - isFailureMessage(state.message) || ... - (state.visible && ~state.statusFlushed)); -end - -function tf = isFigureVisible(fig) - tf = false; - if ~isLiveHandle(fig) - return; - end - try - tf = strcmp(fig.Visible, 'on'); - catch - end -end - -function tf = isFailureMessage(message) - tf = startsWith(lower(string(message)), "startup failed"); -end - -function runDeferredTask(fig, taskTimer, message, workFcn) - if ~isLiveHandle(fig) - cleanupTimer(taskTimer); - return; - end - state = updateState(fig, message); - setState(fig, state); - try - workFcn(); - catch ME - state = getState(fig); - state.failed = true; - state.finishRequested = false; - state = updateStateWithMessage(state, ... - "Startup failed: " + string(ME.message), true); - setState(fig, state); - reportStartupException(fig, ME); - cleanupFinishedTask(fig, taskTimer); - return; - end - cleanupFinishedTask(fig, taskTimer); -end - -function cleanupFinishedTask(fig, taskTimer) - state = getState(fig); - if isempty(state) - cleanupTimer(taskTimer); - return; - end - state.pending = max(0, state.pending - 1); - state.timers = removeTimer(state.timers, taskTimer); - state = completeIfReady(state); - setState(fig, state); - cleanupTimer(taskTimer); -end - -function state = completeIfReady(state) - if isempty(state) || state.failed || state.pending > 0 || ~state.finishRequested - return; - end - state = revealReadyFigure(state); - state = hideStatus(state); - restoreBusy(state.fig, state.oldBusy); - if isLiveHandle(state.fig) - rmappdata(state.fig, startupKey()); - end - state = []; -end - -function state = hideStatus(state) - if isLiveHandle(state.panel) - try - state.panel.Visible = 'off'; - catch - end - end - if isLiveHandle(state.mainGrid) - try - heights = state.mainGrid.RowHeight; - if numel(heights) >= state.statusRow - heights{state.statusRow} = 0; - state.mainGrid.RowHeight = heights; - end - catch - end - end - state.visible = false; -end - -function state = revealReadyFigure(state) - if ~isLiveHandle(state.fig) || startupGuiMode() == "hidden" - return; - end - try - state.fig.Visible = 'on'; - if startupGuiMode() == "minimized" && isprop(state.fig, 'WindowState') - state.fig.WindowState = 'minimized'; - end - drawnow limitrate; - state.statusFlushed = true; - catch - end -end - -function reportStartupException(fig, ME) - try - debug = getappdata(fig, 'labkitUiDebugContext'); - if isstruct(debug) && isfield(debug, 'reportException') && ... - isa(debug.reportException, 'function_handle') - debug.reportException('startup', 'Deferred startup failed', ME); - end - catch - end -end - -function state = defaultState(fig) - state = struct(); - state.fig = fig; - state.startedAt = tic; - state.visible = false; - state.visibleAt = tic; - state.finishRequested = false; - state.failed = false; - state.pending = 0; - state.nextTaskId = 1; - state.timers = timer.empty; - state.message = ""; - state.mainGrid = []; - state.panel = []; - state.label = []; - state.statusRow = 1; - state.statusLabelUpdated = false; - state.statusFlushed = false; - state.oldBusy = struct('hadValue', false, 'value', []); - state.progressReporter = []; -end - -function rememberHandles(fig, state) - if ~isLiveHandle(fig) - return; - end - handles = struct( ... - 'mainGrid', state.mainGrid, ... - 'panel', state.panel, ... - 'label', state.label, ... - 'statusRow', state.statusRow); - try - setappdata(fig, 'labkitUiStartupHandles', handles); - catch - end -end - -function state = restoreHandles(fig, state) - if ~isLiveHandle(fig) || ~isappdata(fig, 'labkitUiStartupHandles') - return; - end - try - handles = getappdata(fig, 'labkitUiStartupHandles'); - state.mainGrid = handles.mainGrid; - state.panel = handles.panel; - state.label = handles.label; - if isfield(handles, 'statusRow') - state.statusRow = handles.statusRow; - end - catch - end -end - -function busy = captureBusy(fig) - busy = struct('hadValue', false, 'value', []); - if ~isLiveHandle(fig) - return; - end - try - busy.hadValue = isappdata(fig, 'labkitUiBusy'); - if busy.hadValue - busy.value = getappdata(fig, 'labkitUiBusy'); - end - catch - end -end - -function restoreBusy(fig, busy) - if ~isLiveHandle(fig) - return; - end - try - if busy.hadValue - setappdata(fig, 'labkitUiBusy', busy.value); - else - rmappdata(fig, 'labkitUiBusy'); - end - catch - end -end - -function state = getState(fig) - state = []; - if isLiveHandle(fig) && isappdata(fig, startupKey()) - state = getappdata(fig, startupKey()); - end -end - -function setState(fig, state) - if ~isempty(state) && isLiveHandle(fig) - setappdata(fig, startupKey(), state); - end -end - -function timers = liveTimers(timers) - keep = false(size(timers)); - for k = 1:numel(timers) - try - keep(k) = isvalid(timers(k)); - catch - keep(k) = false; - end - end - timers = timers(keep); -end - -function timers = removeTimer(timers, taskTimer) - timers = liveTimers(timers); - keep = true(size(timers)); - for k = 1:numel(timers) - try - keep(k) = ~isequal(timers(k), taskTimer); - catch - keep(k) = true; - end - end - timers = timers(keep); -end - -function cleanupTimer(taskTimer) - try - if ~isempty(taskTimer) && isvalid(taskTimer) - stop(taskTimer); - delete(taskTimer); - end - catch - end -end - -function key = startupKey() - key = 'labkitUiStartup'; -end - -function tf = startupStatusSuppressed() - mode = startupGuiMode(); - tf = mode == "hidden" || mode == "minimized"; -end - -function mode = startupGuiMode() - mode = lower(strtrim(string(getenv('LABKIT_GUI_TEST_MODE')))); - if strlength(mode) == 0 - mode = "visible"; - end -end - -function tf = startupTaskRunsInline() - mode = lower(strtrim(string(getenv('LABKIT_GUI_TEST_MODE')))); - tf = mode == "hidden" || mode == "minimized"; -end - -function value = startupStatusDelay() - value = 0.25; -end - -function value = startupTaskDelay() - value = 0.01; -end - -function tf = isLiveHandle(h) - tf = ~isempty(h); - if ~tf - return; - end - try - tf = all(isvalid(h)); - catch - tf = false; - end -end diff --git a/+labkit/+ui/+runtime/private/startupProgressReporter.m b/+labkit/+ui/+runtime/private/startupProgressReporter.m deleted file mode 100644 index 692ab4084..000000000 --- a/+labkit/+ui/+runtime/private/startupProgressReporter.m +++ /dev/null @@ -1,21 +0,0 @@ -% Private UI runtime helper. Expected callers are Runtime V2 launch and the -% compatibility create path. Input is a launch request that may carry a -% launcher-owned startupProgress callback. Output is a best-effort progress -% reporter; hidden/minimized GUI tests remain silent. -function reporter = startupProgressReporter(request) - reporter = []; - if isstruct(request) && isfield(request, 'startupProgress') && ... - isa(request.startupProgress, 'function_handle') - reporter = request.startupProgress; - return; - end - mode = lower(strtrim(string(getenv('LABKIT_GUI_TEST_MODE')))); - if mode == "hidden" || mode == "minimized" - return; - end - reporter = @writeProgress; -end - -function writeProgress(message) - fprintf('[LabKit startup] %s\n', char(string(message))); -end diff --git a/+labkit/+ui/+runtime/private/traceFilePanelControl.m b/+labkit/+ui/+runtime/private/traceFilePanelControl.m deleted file mode 100644 index 3249cdda2..000000000 --- a/+labkit/+ui/+runtime/private/traceFilePanelControl.m +++ /dev/null @@ -1,10 +0,0 @@ -% Private filePanel diagnostic helper. Expected caller: filePanel control -% builders and semantic callbacks. Inputs are a filePanel adapter plus event -% text. Side effects: emits a debug trace when debug mode is enabled. -function traceFilePanelControl(control, eventName, reason) - if ~isstruct(control) || ~isfield(control, 'trace') || ... - isempty(control.trace) || ~isa(control.trace, 'function_handle') - return; - end - control.trace(control.panel, eventName, reason); -end diff --git a/+labkit/+ui/+runtime/private/traceFilePanelFromSource.m b/+labkit/+ui/+runtime/private/traceFilePanelFromSource.m deleted file mode 100644 index bb247a106..000000000 --- a/+labkit/+ui/+runtime/private/traceFilePanelFromSource.m +++ /dev/null @@ -1,20 +0,0 @@ -% Private filePanel diagnostic helper. Expected caller: semantic filePanel -% callback wiring. Inputs are the filePanel id, MATLAB source handle, event -% text, and reason. Side effects: emits a debug trace through the UI registry. -function traceFilePanelFromSource(id, source, eventName, reason) - ui = currentUiRegistry(source); - if ~isfield(ui, 'debug') || ~isstruct(ui.debug) || ... - ~isfield(ui.debug, 'trace') || ~isa(ui.debug.trace, 'function_handle') - return; - end - ui.debug.trace('filePanel', sprintf('%s %s', char(string(id)), eventName), reason); -end - -function ui = currentUiRegistry(source) - fig = ancestor(source, 'figure'); - if isempty(fig) || ~isappdata(fig, 'labkitUiRegistry') - error('labkit:ui:runtime:MissingRegistry', ... - 'UI registry appdata was not found on the current figure.'); - end - ui = getappdata(fig, 'labkitUiRegistry'); -end diff --git a/+labkit/+ui/+runtime/private/updateV2DocumentTitle.m b/+labkit/+ui/+runtime/private/updateV2DocumentTitle.m deleted file mode 100644 index 43bc7e740..000000000 --- a/+labkit/+ui/+runtime/private/updateV2DocumentTitle.m +++ /dev/null @@ -1,17 +0,0 @@ -% Private UI runtime helper. Expected callers: v2 commit/save/load paths. Input -% is an app figure. Side effect adds or removes the framework-owned dirty title -% marker without altering version text or app-owned title content. -function updateV2DocumentTitle(fig) - if isempty(fig) || ~isvalid(fig) || ~isappdata(fig, appRuntimeKey()) - return; - end - runtime = getappdata(fig, appRuntimeKey()); - if ~isfield(runtime, 'document') - return; - end - name = erase(string(fig.Name), " *"); - if runtime.document.dirty - name = name + " *"; - end - fig.Name = char(name); -end diff --git a/+labkit/+ui/+runtime/private/v2FigureInteractionHub.m b/+labkit/+ui/+runtime/private/v2FigureInteractionHub.m deleted file mode 100644 index c1749ebcc..000000000 --- a/+labkit/+ui/+runtime/private/v2FigureInteractionHub.m +++ /dev/null @@ -1,563 +0,0 @@ -% Private UI runtime helper. Expected caller: runV2App after layout creation. -% Inputs are a v2 UI registry and a semantic-event callback. Output is one -% figure-scoped interaction hub. The hub owns figure pointer, wheel, and drag -% callbacks; registers preview axes by semantic id; and supplies internal -% adapters to the app-neutral controlled interaction editors. -function hub = v2FigureInteractionHub(ui, dispatchEvent, cleanupTarget) - fig = ui.figure; - state = struct(); - state.targets = discoverTargets(ui); - state.sessions = emptySessions(); - state.activeGroup = ""; - state.drag = emptyDrag(); - state.suppressed = false; - state.deleted = false; - state.prior = priorCallbacks(fig); - state.callbacks = struct( ... - "down", @onPointerDown, ... - "motion", @onPointerMotion, ... - "up", @onPointerUp, ... - "scroll", @onScroll); - installTargetListeners(); - removeLegacyPreviewDispatcher(fig); - installCallbacks(); - - hub = struct( ... - "adapter", @adapter, ... - "dispatch", @dispatchSemanticEvent, ... - "point", @targetPoint, ... - "setSuppressed", @setSuppressed, ... - "targetIds", @targetIds, ... - "activeGroup", @activeGroup, ... - "isDragging", @isDragging, ... - "routeWheel", @routeWheel, ... - "delete", @deleteHub); - - function point = targetPoint(targetId) - ax = targetAxes(requireTarget(targetId)); - current = double(ax.CurrentPoint); - point = current(1, 1:2); - end - - function runtime = adapter(targetId, groupId) - targetId = requireTarget(targetId); - if nargin < 2 || strlength(string(groupId)) == 0 - groupId = "interaction:" + targetId; - end - groupId = string(groupId); - runtime = struct( ... - "axes", @() targetAxes(targetId), ... - "figure", @() fig, ... - "createSession", @(options) createSession( ... - targetId, groupId, options)); - end - - function session = createSession(targetId, groupId, options) - if nargin < 3 || isempty(options) - options = struct(); - end - token = nextToken(); - entry = struct( ... - "token", token, ... - "target", targetId, ... - "group", groupId, ... - "name", string(optionValue(options, 'name', 'interaction')), ... - "onPointerDown", optionValue(options, 'onPointerDown', []), ... - "onScroll", optionValue(options, 'onScroll', []), ... - "installScrollWheel", logical(optionValue( ... - options, 'installScrollWheel', true)), ... - "background", gobjects(1, 0), ... - "graphics", gobjects(1, 0)); - state.sessions(end + 1) = entry; - session = struct( ... - "activate", @activate, ... - "activateIfAvailable", @activateIfAvailable, ... - "canActivate", @canActivate, ... - "deactivate", @deactivate, ... - "isActive", @isActive, ... - "setBackground", @(value) setHandles('background', value), ... - "setGraphics", @(value) setHandles('graphics', value), ... - "captureDrag", @captureDrag, ... - "releaseDrag", @releaseDrag, ... - "refresh", @refresh, ... - "delete", @deleteSession); - - function activate() - assertTargetValid(targetId); - state.activeGroup = groupId; - end - - function activated = activateIfAvailable() - activated = canActivate(); - if activated - activate(); - end - end - - function tf = canActivate() - tf = strlength(state.activeGroup) == 0 || ... - state.activeGroup == groupId; - end - - function deactivate() - releaseDrag(); - if state.activeGroup == groupId - state.activeGroup = ""; - end - end - - function tf = isActive() - tf = state.activeGroup == groupId && sessionExists(token); - end - - function setHandles(field, value) - index = sessionIndex(token); - if isempty(index) - return; - end - state.sessions(index).(field) = normalizeHandles(value); - end - - function captureDrag(motionFcn, releaseFcn) - if ~isActive() - return; - end - state.drag = struct("token", token, "motion", motionFcn, ... - "release", releaseFcn); - end - - function releaseDrag() - if isequal(state.drag.token, token) - state.drag = emptyDrag(); - end - end - - function refresh() - assertTargetValid(targetId); - end - - function deleteSession() - releaseDrag(); - index = sessionIndex(token); - if isempty(index) - return; - end - state.sessions(index) = []; - if state.activeGroup == groupId - state.activeGroup = ""; - end - end - end - - function onPointerDown(src, event) - hit = pointerHitObject(src, event); - target = targetUnderPointer(hit); - entry = activeSessionForTarget(target); - if isempty(entry) - invokeCallback(state.prior.down, src, event); - return; - end - invokeCallback(entry.onPointerDown, hit, event); - end - - function hit = pointerHitObject(fallback, event) - hit = []; - if isstruct(event) && isfield(event, 'HitObject') && ... - isValidHandle(event.HitObject) - hit = event.HitObject; - elseif isobject(event) && isprop(event, 'HitObject') && ... - isValidHandle(event.HitObject) - hit = event.HitObject; - end - if isempty(hit) - try - hit = hittest(fig); - catch - end - end - if isempty(hit) - hit = fallback; - end - end - - function onPointerMotion(src, event) - if isempty(state.drag.token) - invokeCallback(state.prior.motion, src, event); - return; - end - try - invokeCallback(state.drag.motion, src, event); - catch ME - state.drag = emptyDrag(); - rethrow(ME); - end - end - - function onPointerUp(src, event) - if isempty(state.drag.token) - invokeCallback(state.prior.up, src, event); - return; - end - release = state.drag.release; - state.drag = emptyDrag(); - invokeCallback(release, src, event); - end - - function onScroll(src, event) - target = targetUnderPointer(); - if strlength(target) == 0 - invokeCallback(state.prior.scroll, src, event); - return; - end - routeWheel(target, event, src); - end - - function routeWheel(targetId, event, src) - if nargin < 3 - src = fig; - end - if strlength(string(targetId)) == 0 - invokeCallback(state.prior.scroll, src, event); - return; - end - targetId = requireTarget(targetId); - entry = activeSessionForTarget(targetId); - if ~isempty(entry) && entry.installScrollWheel && ... - ~isempty(entry.onScroll) - invokeCallback(entry.onScroll, src, event); - return; - end - ax = targetAxes(targetId); - count = scrollCount(event); - if count == 0 - return; - end - point = wheelPoint(ax, event); - zoomAxesAtPoint(ax, point, count, ... - "ZoomAxes", scrollZoomAxes(ax)); - end - - function dispatchSemanticEvent(id, target, value, phase) - if state.suppressed || isempty(dispatchEvent) - return; - end - event = struct(); - event.id = string(id); - event.source = "interaction"; - event.target = string(target); - event.value = value; - event.meta = struct("phase", string(phase)); - dispatchEvent(event); - end - - function setSuppressed(value) - state.suppressed = logical(value); - end - - function ids = targetIds() - ids = [state.targets.id]; - end - - function value = activeGroup() - value = state.activeGroup; - end - - function tf = isDragging() - tf = ~isempty(state.drag.token); - end - - function deleteHub() - if state.deleted - return; - end - state.deleted = true; - deleteTargetListeners(); - state.sessions = emptySessions(); - state.drag = emptyDrag(); - restoreCallbacks(); - end - - function installTargetListeners() - for index = 1:numel(state.targets) - id = state.targets(index).id; - state.targets(index).listener = addlistener( ... - state.targets(index).axes, 'ObjectBeingDestroyed', ... - @(~, ~) onTargetDeleted(id)); - end - end - - function onTargetDeleted(id) - if state.deleted - return; - end - matches = [state.sessions.target] == id; - removedTokens = [state.sessions(matches).token]; - removedGroups = [state.sessions(matches).group]; - state.sessions(matches) = []; - if ~isempty(state.drag.token) && any(removedTokens == state.drag.token) - state.drag = emptyDrag(); - end - if ~isempty(removedGroups) && ... - any(string(removedGroups) == state.activeGroup) - state.activeGroup = ""; - end - cleanupTarget(id); - end - - function deleteTargetListeners() - for index = 1:numel(state.targets) - listener = state.targets(index).listener; - if ~isempty(listener) && isvalid(listener) - delete(listener); - end - end - end - - function installCallbacks() - fig.WindowButtonDownFcn = state.callbacks.down; - fig.WindowButtonMotionFcn = state.callbacks.motion; - fig.WindowButtonUpFcn = state.callbacks.up; - fig.WindowScrollWheelFcn = state.callbacks.scroll; - end - - function restoreCallbacks() - if ~isValidHandle(fig) - return; - end - restoreIfOwned('WindowButtonDownFcn', state.callbacks.down, state.prior.down); - restoreIfOwned('WindowButtonMotionFcn', state.callbacks.motion, state.prior.motion); - restoreIfOwned('WindowButtonUpFcn', state.callbacks.up, state.prior.up); - restoreIfOwned('WindowScrollWheelFcn', state.callbacks.scroll, state.prior.scroll); - end - - function restoreIfOwned(property, owned, prior) - if isequal(fig.(property), owned) - fig.(property) = prior; - end - end - - function target = targetUnderPointer(hit) - target = ""; - if ~isValidHandle(fig) - return; - end - if nargin < 1 || isempty(hit) - hit = []; - try - hit = hittest(fig); - catch - end - end - for k = 1:numel(state.targets) - if handleDescendsFrom(hit, state.targets(k).axes) - target = state.targets(k).id; - return; - end - end - try - point = fig.CurrentPoint; - catch - return; - end - for k = 1:numel(state.targets) - ax = state.targets(k).axes; - if ~isValidHandle(ax) - continue; - end - position = getpixelposition(ax, true); - if point(1) >= position(1) && point(1) <= position(1) + position(3) && ... - point(2) >= position(2) && point(2) <= position(2) + position(4) - target = state.targets(k).id; - return; - end - end - end - - function entry = activeSessionForTarget(target) - entry = []; - if strlength(target) == 0 || strlength(state.activeGroup) == 0 - return; - end - index = find([state.sessions.target] == target & ... - [state.sessions.group] == state.activeGroup, 1, 'last'); - if ~isempty(index) - entry = state.sessions(index); - end - end - - function id = requireTarget(id) - id = string(id); - if ~isscalar(id) || ~any([state.targets.id] == id) - error('labkit:ui:runtime:UnknownInteractionTarget', ... - 'Unknown interaction target "%s".', id); - end - end - - function ax = targetAxes(id) - index = find([state.targets.id] == string(id), 1, 'first'); - ax = state.targets(index).axes; - end - - function assertTargetValid(id) - if ~isValidHandle(targetAxes(id)) - error('labkit:ui:runtime:InvalidInteractionTarget', ... - 'Interaction target "%s" no longer exists.', id); - end - end - - function index = sessionIndex(token) - index = find([state.sessions.token] == token, 1, 'first'); - end - - function tf = sessionExists(token) - tf = ~isempty(sessionIndex(token)); - end -end - -function targets = discoverTargets(ui) - targets = struct("id", {}, "axes", {}, "listener", {}); - ids = string(fieldnames(ui.controls)); - for k = 1:numel(ids) - control = ui.controls.(char(ids(k))); - if ~isstruct(control) || ~isfield(control, 'kind') || ... - string(control.kind) ~= "previewArea" - continue; - end - axisIds = string(fieldnames(control.axesById)); - if numel(axisIds) == 1 - targets(end + 1) = struct("id", ids(k), ... - "axes", control.axesById.(char(axisIds(1))), ... - "listener", []); - else - for j = 1:numel(axisIds) - targets(end + 1) = struct( ... - "id", ids(k) + "." + axisIds(j), ... - "axes", control.axesById.(char(axisIds(j))), ... - "listener", []); - end - end - end -end - -function callbacks = priorCallbacks(fig) - callbacks = struct( ... - "down", fig.WindowButtonDownFcn, ... - "motion", fig.WindowButtonMotionFcn, ... - "up", fig.WindowButtonUpFcn, ... - "scroll", fig.WindowScrollWheelFcn); - key = 'labkitPreviewScrollNavigation'; - if isappdata(fig, key) - navigation = getappdata(fig, key); - if isfield(navigation, 'fallbackScrollFcn') - callbacks.scroll = navigation.fallbackScrollFcn; - end - end -end - -function removeLegacyPreviewDispatcher(fig) - key = 'labkitPreviewScrollNavigation'; - if isappdata(fig, key) - rmappdata(fig, key); - end -end - -function sessions = emptySessions() - sessions = struct("token", {}, "target", {}, "group", {}, ... - "name", {}, "onPointerDown", {}, "onScroll", {}, ... - "installScrollWheel", {}, "background", {}, "graphics", {}); -end - -function drag = emptyDrag() - drag = struct("token", [], "motion", [], "release", []); -end - -function token = nextToken() - persistent value - if isempty(value) - value = uint64(0); - end - value = value + 1; - token = value; -end - -function value = optionValue(options, name, defaultValue) - value = defaultValue; - if isstruct(options) && isfield(options, name) - value = options.(name); - end -end - -function handles = normalizeHandles(value) - handles = gobjects(1, 0); - if isempty(value) - return; - end - if iscell(value) - for k = 1:numel(value) - handles = [handles normalizeHandles(value{k})]; - end - return; - end - value = value(:).'; - handles = value(arrayfun(@isValidHandle, value)); -end - -function tf = handleDescendsFrom(handle, ancestorHandle) - tf = false; - while isValidHandle(handle) - if isequal(handle, ancestorHandle) - tf = true; - return; - end - if ~isprop(handle, 'Parent') - return; - end - handle = handle.Parent; - end -end - -function count = scrollCount(event) - count = 0; - if isstruct(event) && isfield(event, 'VerticalScrollCount') - count = event.VerticalScrollCount; - elseif isobject(event) && isprop(event, 'VerticalScrollCount') - count = event.VerticalScrollCount; - end - if ~isnumeric(count) || ~isscalar(count) || ~isfinite(count) - count = 0; - end -end - -function point = wheelPoint(ax, event) - if isstruct(event) && isfield(event, 'Point') && ... - isnumeric(event.Point) && numel(event.Point) >= 2 - point = double(event.Point(1, 1:2)); - return; - end - current = ax.CurrentPoint; - point = current(1, 1:2); -end - -function axesMode = scrollZoomAxes(ax) - axesMode = "xy"; - if isappdata(ax, 'labkitPreviewScrollZoomAxes') - axesMode = string(getappdata(ax, 'labkitPreviewScrollZoomAxes')); - end -end - -function invokeCallback(callback, src, event) - if isempty(callback) - return; - end - if isa(callback, 'function_handle') - callback(src, event); - elseif iscell(callback) - feval(callback{1}, src, event, callback{2:end}); - elseif ischar(callback) || isstring(callback) - feval(char(callback), src, event); - end -end - -function tf = isValidHandle(value) - tf = ~isempty(value) && isscalar(value) && isgraphics(value); -end diff --git a/+labkit/+ui/+runtime/private/v2ResourceRegistry.m b/+labkit/+ui/+runtime/private/v2ResourceRegistry.m deleted file mode 100644 index 3c4eacae8..000000000 --- a/+labkit/+ui/+runtime/private/v2ResourceRegistry.m +++ /dev/null @@ -1,175 +0,0 @@ -% Private UI runtime helper. Expected callers: v2 handler services and runtime -% cleanup. Inputs are a figure, command, and command arguments. The registry -% owns nonsemantic handles/listeners/tools outside app state and guarantees -% idempotent cleanup on replacement, scope disposal, and figure deletion. -function varargout = v2ResourceRegistry(fig, command, varargin) - command = string(command); - switch command - case "set" - setResource(fig, varargin{:}); - case "get" - varargout{1} = getResource(fig, varargin{:}); - case "remove" - removeResource(fig, varargin{:}); - case "clearScope" - clearScope(fig, varargin{:}); - case "clearAll" - clearAll(fig); - case "listIds" - varargout{1} = listIds(fig, varargin{1}); - otherwise - error('labkit:ui:runtime:InvalidResourceCommand', ... - 'Unsupported resource registry command "%s".', command); - end -end - -function ids = listIds(fig, scope) - runtime = runtimeFromFigure(fig); - if isempty(runtime.resources) - ids = strings(1, 0); - return; - end - matches = [runtime.resources.scope] == string(scope); - if ~any(matches) - ids = strings(1, 0); - else - ids = string({runtime.resources(matches).id}); - end -end - -function setResource(fig, scope, id, value, cleanup) - scope = validateText(scope, "scope"); - id = validateText(id, "id"); - if nargin < 5 || isempty(cleanup) - cleanup = defaultCleanup(value); - end - if ~isa(cleanup, 'function_handle') - error('labkit:ui:runtime:InvalidResourceCleanup', ... - 'Resource cleanup must be a function handle.'); - end - runtime = runtimeFromFigure(fig); - index = findResource(runtime.resources, scope, id); - if ~isempty(index) - disposeEntry(runtime.resources(index)); - runtime.resources(index) = []; - end - entry = struct(); - entry.scope = scope; - entry.id = id; - entry.value = value; - entry.cleanup = cleanup; - entry.disposed = false; - runtime.resources(end + 1) = entry; - setappdata(fig, appRuntimeKey(), runtime); -end - -function value = getResource(fig, scope, id) - runtime = runtimeFromFigure(fig); - index = findResource(runtime.resources, string(scope), string(id)); - if isempty(index) - value = []; - else - value = runtime.resources(index).value; - end -end - -function removeResource(fig, scope, id) - runtime = runtimeFromFigure(fig); - index = findResource(runtime.resources, string(scope), string(id)); - if isempty(index) - return; - end - disposeEntry(runtime.resources(index)); - runtime.resources(index) = []; - setappdata(fig, appRuntimeKey(), runtime); -end - -function clearScope(fig, scope) - if isempty(fig) || ~isappdata(fig, appRuntimeKey()) - return; - end - runtime = getappdata(fig, appRuntimeKey()); - if isempty(runtime.resources) - return; - end - matches = [runtime.resources.scope] == string(scope); - disposeEntries(runtime.resources(matches)); - runtime.resources(matches) = []; - setappdata(fig, appRuntimeKey(), runtime); -end - -function clearAll(fig) - if isempty(fig) || ~isappdata(fig, appRuntimeKey()) - return; - end - runtime = getappdata(fig, appRuntimeKey()); - disposeEntries(runtime.resources); - runtime.resources = emptyResources(); - if isvalid(fig) - setappdata(fig, appRuntimeKey(), runtime); - end -end - -function runtime = runtimeFromFigure(fig) - if isempty(fig) || ~isvalid(fig) || ~isappdata(fig, appRuntimeKey()) - error('labkit:ui:runtime:MissingRuntime', ... - 'The figure does not have a LabKit app runtime.'); - end - runtime = getappdata(fig, appRuntimeKey()); -end - -function index = findResource(resources, scope, id) - index = []; - if isempty(resources) - return; - end - index = find([resources.scope] == scope & [resources.id] == id, 1, 'first'); -end - -function disposeEntries(entries) - for k = numel(entries):-1:1 - disposeEntry(entries(k)); - end -end - -function disposeEntry(entry) - if entry.disposed - return; - end - try - entry.cleanup(entry.value); - catch - end -end - -function cleanup = defaultCleanup(value) - if isa(value, 'onCleanup') - cleanup = @(item) delete(item); - elseif isobject(value) || isgraphics(value) - cleanup = @deleteIfValid; - else - cleanup = @(~) []; - end -end - -function deleteIfValid(value) - try - if isvalid(value) - delete(value); - end - catch - end -end - -function value = validateText(value, label) - value = string(value); - if ~isscalar(value) || strlength(value) == 0 - error('labkit:ui:runtime:InvalidResourceId', ... - 'Resource %s must be nonempty scalar text.', label); - end -end - -function resources = emptyResources() - resources = struct("scope", {}, "id", {}, "value", {}, ... - "cleanup", {}, "disposed", {}); -end diff --git a/+labkit/+ui/+runtime/private/validateAppDefinition.m b/+labkit/+ui/+runtime/private/validateAppDefinition.m deleted file mode 100644 index 8e372c6b1..000000000 --- a/+labkit/+ui/+runtime/private/validateAppDefinition.m +++ /dev/null @@ -1,228 +0,0 @@ -% Private UI runtime helper. Expected caller: labkit.ui.runtime.define and -% Runtime V2 launch. Input is a candidate app definition struct. Side effect: -% throws app-neutral validation errors before runtime construction begins. -function validateAppDefinition(def) - if ~isstruct(def) || ~isscalar(def) - error('labkit:ui:runtime:InvalidDefinition', ... - 'App definition must be a scalar struct.'); - end - validateV2Definition(def); -end - -function validateV2Definition(def) - required = ["type", "contractVersion", "id", "title", "product", ... - "requirements", "project", ... - "createSession", "layout", "actions", "present", "renderers", ... - "start", "debugSample", "utilities"]; - requireFields(def, required); - if string(def.type) ~= "labkit.ui.runtime.definition" - error('labkit:ui:runtime:InvalidDefinition', ... - 'App definition has unsupported type "%s".', string(def.type)); - end - assertAppId(def.id); - assertScalarText(def.title, "title"); - validateProductMetadata(def.product); - validateRequirements(def.requirements); - validateProjectSpec(def.project); - if ~isempty(def.createSession) && ~isa(def.createSession, 'function_handle') - error('labkit:ui:runtime:InvalidDefinition', ... - 'CreateSession must be a function handle when supplied.'); - end - if ~isa(def.layout, 'function_handle') - error('labkit:ui:runtime:InvalidDefinition', ... - 'Layout must be a function handle.'); - end - validateActions(def.actions); - if ~isa(def.present, 'function_handle') - error('labkit:ui:runtime:InvalidDefinition', ... - 'Present must be a function handle.'); - end - validateRenderers(def.renderers); - if ~isempty(def.start) && ~isa(def.start, 'function_handle') && ... - ~(ischar(def.start) || (isstring(def.start) && isscalar(def.start))) - error('labkit:ui:runtime:InvalidDefinition', ... - 'Start must be a function handle or scalar action id when supplied.'); - end - if ischar(def.start) || isstring(def.start) - validatePhaseIds(string(def.start), def.actions, "Start"); - end - if ~isempty(def.debugSample) && ~isa(def.debugSample, 'function_handle') - error('labkit:ui:runtime:InvalidDefinition', ... - 'DebugSample must be a function handle when supplied.'); - end - validateUtilitiesSpec(def.utilities); -end - -function validateProductMetadata(product) - if ~isstruct(product) || ~isscalar(product) - error('labkit:ui:runtime:InvalidDefinition', ... - 'App product metadata must be a scalar struct.'); - end - fields = ["command", "displayName", "family", "version", "updated"]; - requireFields(product, fields); - for field = fields - value = product.(field); - if ~(ischar(value) || (isstring(value) && isscalar(value))) - error('labkit:ui:runtime:InvalidDefinition', ... - 'App product metadata field %s must be scalar text.', field); - end - end - if strlength(string(product.displayName)) == 0 - error('labkit:ui:runtime:InvalidDefinition', ... - 'App product displayName must be nonempty scalar text.'); - end - populated = strlength([string(product.command), string(product.family), ... - string(product.version), string(product.updated)]) > 0; - if any(populated) && ~all(populated) - error('labkit:ui:runtime:InvalidDefinition', ... - ['Command, Family, AppVersion, and Updated must be supplied ' ... - 'together.']); - end -end - -function validateRequirements(requirements) - if isempty(requirements) - return; - end - if ~isstruct(requirements) || ~isscalar(requirements) || ... - ~isfield(requirements, 'type') || ... - string(requirements.type) ~= "labkit.requirements" || ... - ~isfield(requirements, 'facades') || ... - ~isstruct(requirements.facades) - error('labkit:ui:runtime:InvalidDefinition', ... - ['Requirements must be a scalar result returned by ' ... - 'labkit.contract.requirements.']); - end -end - -function assertAppId(value) - assertScalarText(value, "id"); - value = string(value); - if isempty(regexp(char(value), '^[A-Za-z][A-Za-z0-9_.-]*$', 'once')) - error('labkit:ui:runtime:InvalidDefinition', ... - ['App definition id must start with an ASCII letter and contain ' ... - 'only letters, digits, underscore, hyphen, or period.']); - end - appStorageKey(value); -end - -function requireFields(value, required) - for k = 1:numel(required) - if ~isfield(value, required(k)) - error('labkit:ui:runtime:InvalidDefinition', ... - 'App definition is missing field "%s".', required(k)); - end - end -end - -function validateProjectSpec(spec) - if ~isstruct(spec) || ~isscalar(spec) - error('labkit:ui:runtime:InvalidDefinition', ... - 'Project must be a scalar struct.'); - end - requireFields(spec, ["Version", "Create", "Validate"]); - version = spec.Version; - if ~(isnumeric(version) && isscalar(version) && isfinite(version) && ... - version >= 1 && version == fix(version)) - error('labkit:ui:runtime:InvalidDefinition', ... - 'Project.Version must be a positive integer scalar.'); - end - if ~isa(spec.Create, 'function_handle') || ... - ~isa(spec.Validate, 'function_handle') - error('labkit:ui:runtime:InvalidDefinition', ... - 'Project.Create and Project.Validate must be function handles.'); - end - if isfield(spec, 'Migrations') - error('labkit:ui:runtime:InvalidDefinition', ... - ['Project.Migrations is retired. Use one version-aware ' ... - 'Project.Migrate callback.']); - end - hasMigrate = isfield(spec, 'Migrate') && ~isempty(spec.Migrate); - if hasMigrate && ~isa(spec.Migrate, 'function_handle') - error('labkit:ui:runtime:InvalidDefinition', ... - 'Project.Migrate must be a function handle when supplied.'); - end - if version > 1 && ~hasMigrate - error('labkit:ui:runtime:InvalidDefinition', ... - 'Project.Migrate is required when Project.Version is greater than 1.'); - end - if isfield(spec, 'LegacyImports') && ... - (~isstruct(spec.LegacyImports) || ~isscalar(spec.LegacyImports) || ... - ~all(structfun(@(f) isa(f, 'function_handle'), spec.LegacyImports))) - error('labkit:ui:runtime:InvalidDefinition', ... - 'Project.LegacyImports must map variable names to import functions.'); - end - if isfield(spec, 'ApplyResume') && ... - ~isa(spec.ApplyResume, 'function_handle') - error('labkit:ui:runtime:InvalidDefinition', ... - 'Project.ApplyResume must be a function handle.'); - end - if isfield(spec, 'CreateResume') && ... - ~isa(spec.CreateResume, 'function_handle') - error('labkit:ui:runtime:InvalidDefinition', ... - 'Project.CreateResume must be a function handle.'); - end - if isfield(spec, 'RelinkSources') && ... - ~isa(spec.RelinkSources, 'function_handle') - error('labkit:ui:runtime:InvalidDefinition', ... - 'Project.RelinkSources must be a function handle.'); - end -end - -function validateRenderers(renderers) - if ~isstruct(renderers) || ~isscalar(renderers) - error('labkit:ui:runtime:InvalidDefinition', ... - 'Renderers must be a scalar struct of function handles.'); - end - names = fieldnames(renderers); - for k = 1:numel(names) - if ~isa(renderers.(names{k}), 'function_handle') - error('labkit:ui:runtime:InvalidDefinition', ... - 'Renderer "%s" must be a function handle.', names{k}); - end - end -end - -function validateActions(actions) - if ~isstruct(actions) || ~isscalar(actions) - error('labkit:ui:runtime:InvalidDefinition', ... - 'Actions must be a scalar struct of function handles.'); - end - ids = fieldnames(actions); - for k = 1:numel(ids) - if ~isa(actions.(ids{k}), 'function_handle') - error('labkit:ui:runtime:InvalidDefinition', ... - 'Action "%s" must be a function handle.', ids{k}); - end - end -end - -function validatePhaseIds(ids, actions, label) - ids = string(ids); - ids = ids(ids ~= ""); - actionIds = string(fieldnames(actions)); - missing = setdiff(ids, actionIds); - if ~isempty(missing) - error('labkit:ui:runtime:InvalidDefinition', ... - '%s phase references unknown action id(s): %s.', ... - label, strjoin(cellstr(missing), ', ')); - end -end - -function assertScalarText(value, name) - if ~(ischar(value) || (isstring(value) && isscalar(value))) || ... - strlength(string(value)) == 0 - error('labkit:ui:runtime:InvalidDefinition', ... - 'App definition %s must be nonempty scalar text.', name); - end -end - -function validateUtilitiesSpec(spec) - if isempty(spec) - return; - end - if ~isstruct(spec) || ~isscalar(spec) - error('labkit:ui:runtime:InvalidDefinition', ... - 'Utilities must be a scalar struct when supplied.'); - end -end diff --git a/+labkit/+ui/+runtime/private/validateSerializableState.m b/+labkit/+ui/+runtime/private/validateSerializableState.m deleted file mode 100644 index 7451ec400..000000000 --- a/+labkit/+ui/+runtime/private/validateSerializableState.m +++ /dev/null @@ -1,72 +0,0 @@ -% Private UI runtime helper. Expected caller: snapshot save/load services. Input -% is candidate semantic app state. Side effect: throws a path-specific error -% if the value contains runtime handles, callbacks, listeners, or opaque -% objects that should not be persisted in a LabKit state snapshot. -function validateSerializableState(value) - validateValue(value, "state"); -end - -function validateValue(value, path) - if isa(value, 'function_handle') - reject(path, 'function handle'); - end - if isnumeric(value) || islogical(value) || isstring(value) || ... - ischar(value) || isMissingValue(value) - return; - end - if isgraphics(value) - reject(path, 'graphics handle'); - end - if isa(value, 'datetime') || isa(value, 'duration') || ... - isa(value, 'calendarDuration') || isa(value, 'categorical') - return; - end - if isstruct(value) - rejectRuntimeLikeStruct(value, path); - fields = fieldnames(value); - for element = 1:numel(value) - for k = 1:numel(fields) - validateValue(value(element).(fields{k}), ... - path + "." + string(fields{k})); - end - end - return; - end - if iscell(value) - for k = 1:numel(value) - validateValue(value{k}, path + "{" + string(k) + "}"); - end - return; - end - if istable(value) - names = string(value.Properties.VariableNames); - for k = 1:numel(names) - validateValue(value.(names(k)), path + "." + names(k)); - end - return; - end - reject(path, "unsupported " + string(class(value))); -end - -function tf = isMissingValue(value) - tf = false; - try - tf = ismissing(value); - tf = all(tf(:)); - catch - tf = false; - end -end - -function rejectRuntimeLikeStruct(value, path) - runtimeFields = ["definition", "state", "actions", "ui", "debug"]; - if all(isfield(value, runtimeFields)) - reject(path, 'LabKit runtime struct'); - end -end - -function reject(path, reason) - error('labkit:ui:runtime:UnserializableState', ... - '%s is a %s and cannot be saved in a LabKit state snapshot.', ... - char(path), char(string(reason))); -end diff --git a/+labkit/+ui/+runtime/private/validateSourceRecords.m b/+labkit/+ui/+runtime/private/validateSourceRecords.m deleted file mode 100644 index b5d874f97..000000000 --- a/+labkit/+ui/+runtime/private/validateSourceRecords.m +++ /dev/null @@ -1,31 +0,0 @@ -% Private Runtime V2 source contract validator. Expected callers are state -% validation and injected project services. Input is an empty value or source -% struct array. Side effect: rejects missing, empty, nonscalar, or duplicate -% durable source ids before lookup, persistence, or presentation. -function validateSourceRecords(sources) - if isempty(sources) - return; - end - if ~isstruct(sources) || ~isfield(sources, 'id') - invalid('Project sources must be a struct array with id fields.'); - end - ids = strings(numel(sources), 1); - for k = 1:numel(sources) - value = sources(k).id; - if ~(ischar(value) || (isstring(value) && isscalar(value))) || ... - strlength(string(value)) == 0 - invalid('Project source %d id must be nonempty scalar text.', k); - end - ids(k) = string(value); - end - [uniqueIds, first] = unique(ids, 'stable'); - if numel(uniqueIds) ~= numel(ids) - repeated = ids(setdiff(1:numel(ids), first, 'stable')); - invalid('Project source ids must be unique; duplicate "%s".', ... - repeated(1)); - end -end - -function invalid(message, varargin) - error('labkit:ui:runtime:InvalidSourceRecords', message, varargin{:}); -end diff --git a/+labkit/+ui/+runtime/private/validateV2Project.m b/+labkit/+ui/+runtime/private/validateV2Project.m deleted file mode 100644 index 3f8c2c079..000000000 --- a/+labkit/+ui/+runtime/private/validateV2Project.m +++ /dev/null @@ -1,28 +0,0 @@ -% Private UI runtime helper. Expected callers: project restore and semantic -% state validation. Input is one durable project payload. Side effect: rejects -% noncanonical project buckets and malformed framework-owned source records -% before an App-specific validator runs. -function validateV2Project(project) - if ~isstruct(project) || ~isscalar(project) - invalid('Project must be a scalar struct.'); - end - buckets = ["inputs", "parameters", "annotations", "results", "extensions"]; - missing = setdiff(buckets, string(fieldnames(project))); - if ~isempty(missing) - invalid('Project is missing required bucket(s): %s.', ... - strjoin(cellstr(missing), ', ')); - end - for k = 1:numel(buckets) - field = char(buckets(k)); - if ~isstruct(project.(field)) || ~isscalar(project.(field)) - invalid('Project bucket "%s" must be a scalar struct.', field); - end - end - if isfield(project.inputs, 'sources') - validateSourceRecords(project.inputs.sources); - end -end - -function invalid(message, varargin) - error('labkit:ui:runtime:InvalidState', message, varargin{:}); -end diff --git a/+labkit/+ui/+runtime/private/validateV2State.m b/+labkit/+ui/+runtime/private/validateV2State.m deleted file mode 100644 index afeb0b17c..000000000 --- a/+labkit/+ui/+runtime/private/validateV2State.m +++ /dev/null @@ -1,53 +0,0 @@ -% Private UI runtime helper. Expected callers: v2 runtime construction and -% event commits. Inputs are candidate semantic state and its v2 definition. -% Side effect: rejects noncanonical roots, missing buckets, runtime resources, -% and app-invalid project payloads before a live commit. -function validateV2State(state, def) - if ~isstruct(state) || ~isscalar(state) - invalid('State must be a scalar struct.'); - end - roots = string(fieldnames(state)); - if ~isequal(sort(roots), sort(["project"; "session"])) - invalid('State root fields must be exactly project and session.'); - end - validateV2Project(state.project); - validateBuckets(state.session, ... - ["selection", "workflow", "view", "cache"], "session"); - try - validateSerializableState(state); - catch ME - if strcmp(ME.identifier, 'labkit:ui:runtime:UnserializableState') - error('labkit:ui:runtime:InvalidState', '%s', ME.message); - end - rethrow(ME); - end - runProjectValidator(def.project.Validate, state.project); -end - -function validateBuckets(value, required, label) - if ~isstruct(value) || ~isscalar(value) - invalid('State %s must be a scalar struct.', label); - end - missing = setdiff(required, string(fieldnames(value))); - if ~isempty(missing) - invalid('State %s is missing required bucket(s): %s.', ... - label, strjoin(cellstr(missing), ', ')); - end -end - -function runProjectValidator(validator, project) - count = nargout(validator); - if count == 0 - validator(project); - return; - end - accepted = validator(project); - if ~isempty(accepted) && ... - ~(islogical(accepted) && isscalar(accepted) && accepted) - invalid('Project.Validate must return true, return empty, or throw.'); - end -end - -function invalid(message, varargin) - error('labkit:ui:runtime:InvalidState', message, varargin{:}); -end diff --git a/+labkit/+ui/+runtime/private/validateWorkbenchLayout.m b/+labkit/+ui/+runtime/private/validateWorkbenchLayout.m deleted file mode 100644 index 8c62e2303..000000000 --- a/+labkit/+ui/+runtime/private/validateWorkbenchLayout.m +++ /dev/null @@ -1,202 +0,0 @@ -% Private UI runtime helper. Expected caller: labkit.ui.runtime.create. Input is one -% data-only workbench layout. Output is validation-by-success; errors are raised for -% duplicate ids, invalid tree shape, or unsupported child relationships before -% GUI construction begins. -function validateWorkbenchLayout(layout) - assertLayoutKind(layout, 'app'); - if ~isfield(layout.props, 'controlTabs') || ... - ~iscell(layout.props.controlTabs) || ~isrow(layout.props.controlTabs) - error('labkit:ui:runtime:InvalidLayout', ... - 'workbench layout requires controlTabs as a cell row vector.'); - end - if ~isfield(layout.props, 'workspace') || ... - ~isstruct(layout.props.workspace) || ~isscalar(layout.props.workspace) - error('labkit:ui:runtime:InvalidLayout', ... - 'workbench layout requires one workspace layout.'); - end - - assertLayoutKind(layout.props.workspace, 'workspace'); - ids = collectLayoutIds(layout, {}); - duplicate = firstDuplicate(ids); - if strlength(duplicate) > 0 - error('labkit:ui:runtime:DuplicateId', ... - 'Duplicate declarative layout id "%s".', char(duplicate)); - end - validateTreeShape(layout); -end - -function ids = collectLayoutIds(node, ids) - ids{end + 1} = node.id; - if strcmp(node.kind, 'app') && isfield(node.props, 'controlTabs') - for k = 1:numel(node.props.controlTabs) - ids = collectLayoutIds(node.props.controlTabs{k}, ids); - end - ids = collectLayoutIds(node.props.workspace, ids); - end - for k = 1:numel(node.children) - ids = collectLayoutIds(node.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(node) - assertCommonLayoutNode(node); - validateNoAppLayoutProps(node); - switch node.kind - case 'app' - for k = 1:numel(node.props.controlTabs) - assertLayoutKind(node.props.controlTabs{k}, 'tab'); - validateTreeShape(node.props.controlTabs{k}); - end - validateTreeShape(node.props.workspace); - case 'workspace' - validateWorkspaceChildren(node); - case 'tab' - validateChildKinds(node, {'section'}); - case 'section' - validateNonEmptySection(node); - validateChildKinds(node, {'field', 'rangeField', 'panner', 'action', ... - 'group', 'filePanel', 'resultTable', 'statusPanel', ... - 'usagePanel', 'logPanel'}); - case 'group' - validateNonEmptyGroup(node); - validateGroupLayout(node); - validateChildKinds(node, {'field', 'rangeField', 'panner', ... - 'action', 'group'}); - otherwise - validateChildKinds(node, {}); - end -end - -function validateWorkspaceChildren(node) - if isempty(node.children) - return; - end - kinds = string(cellfun(@(child) child.kind, ... - node.children, 'UniformOutput', false)); - if any(kinds == "tab") - if ~all(kinds == "tab") || numel(node.children) < 2 - error('labkit:ui:runtime:InvalidChildKind', ... - ['Workspace "%s" must contain either direct panels or ' ... - 'at least two tab pages, without mixing them.'], node.id); - end - for k = 1:numel(node.children) - validateWorkspaceTab(node.children{k}); - end - return; - end - validateChildKinds(node, {'previewArea', 'resultTable', ... - 'statusPanel', 'usagePanel', 'logPanel'}); -end - -function validateWorkspaceTab(node) - assertCommonLayoutNode(node); - validateNoAppLayoutProps(node); - if isempty(node.children) - error('labkit:ui:runtime:EmptyWorkspaceTab', ... - 'Workspace tab "%s" must contain at least one panel.', node.id); - end - validateChildKinds(node, {'previewArea', 'resultTable', ... - 'statusPanel', 'usagePanel', 'logPanel'}); -end - -function validateNonEmptySection(node) - if isempty(node.children) - error('labkit:ui:runtime:EmptySection', ... - 'Layout "%s" declares an empty section. Add semantic controls.', ... - node.id); - end -end - -function validateNonEmptyGroup(node) - if isempty(node.children) - error('labkit:ui:runtime:EmptyGroup', ... - 'Layout "%s" declares an empty group. Add semantic child controls.', ... - node.id); - end -end - -function validateGroupLayout(node) - groupLayout = string(optionValue(node.props, 'layout', 'auto')); - allowed = ["auto", "actions", "form", "inline", "grid"]; - if ~isscalar(groupLayout) || ~any(groupLayout == allowed) - error('labkit:ui:runtime:InvalidGroupLayout', ... - 'Layout "%s" uses unsupported group layout "%s".', ... - node.id, char(groupLayout)); - end - if groupLayout == "actions" && ~allGroupChildrenAre(node, 'action') - error('labkit:ui:runtime:InvalidGroupLayout', ... - 'Layout "%s" uses action layout but contains non-action children.', ... - node.id); - end -end - -function tf = allGroupChildrenAre(node, kind) - tf = true; - for k = 1:numel(node.children) - tf = tf && strcmp(node.children{k}.kind, kind); - end -end - -function validateNoAppLayoutProps(node) - layoutProps = {'height', 'minRows', 'minHeight', 'maxColumns', ... - 'rowSpacing', 'columnSpacing', 'padding', 'chrome', ... - 'columnWidth', 'rowHeight', 'position', 'leftWidth'}; - for k = 1:numel(layoutProps) - if isfield(node.props, layoutProps{k}) - error('labkit:ui:runtime:RetiredLayoutProperty', ... - ['Layout "%s" uses app-owned layout property "%s". ' ... - 'Apps may declare pages, sections, controls, order, and ' ... - 'semantic options; LabKit owns concrete layout.'], ... - node.id, layoutProps{k}); - end - end -end - -function validateChildKinds(node, allowedKinds) - for k = 1:numel(node.children) - child = node.children{k}; - if ~any(strcmp(child.kind, allowedKinds)) - error('labkit:ui:runtime:InvalidChildKind', ... - 'Layout "%s" cannot contain child kind "%s".', node.id, child.kind); - end - validateTreeShape(child); - end -end - -function assertLayoutKind(node, kind) - assertCommonLayoutNode(node); - if ~strcmp(node.kind, kind) - error('labkit:ui:runtime:InvalidLayoutKind', ... - 'Expected %s layout node, got "%s".', kind, node.kind); - end -end - -function assertCommonLayoutNode(node) - if ~isstruct(node) || ~isscalar(node) || ... - ~all(isfield(node, {'kind', 'id', 'props', 'children', 'slots'})) || ... - ~iscell(node.children) || ... - ~(isempty(node.children) || isrow(node.children)) - error('labkit:ui:runtime:InvalidLayout', ... - 'Declarative layout nodes must be scalar structs with cell row children.'); - 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/+runtime/private/writeV2ProjectFile.m b/+labkit/+ui/+runtime/private/writeV2ProjectFile.m deleted file mode 100644 index a1211792f..000000000 --- a/+labkit/+ui/+runtime/private/writeV2ProjectFile.m +++ /dev/null @@ -1,47 +0,0 @@ -% Private UI runtime helper. Expected callers: explicit save and recovery -% policy. Inputs are a target MAT path and validated project envelope. Side -% effect writes a temporary file, reads it back, then atomically replaces the -% target while preserving any prior file if validation or replacement fails. -function writeV2ProjectFile(filepath, labkitProject, beforeReplace) - if nargin < 3 - beforeReplace = []; - end - filepath = string(filepath); - folder = string(fileparts(filepath)); - if strlength(folder) == 0 - folder = string(pwd); - filepath = fullfile(folder, filepath); - end - if ~isfolder(folder) - error('labkit:ui:runtime:ProjectWriteFailed', ... - 'Project destination folder does not exist: %s.', folder); - end - temporary = string(tempname(folder)) + ".mat"; - cleanup = onCleanup(@() deleteIfPresent(temporary)); - save(temporary, 'labkitProject'); - inventory = whos('-file', temporary); - if numel(inventory) ~= 1 || string(inventory.name) ~= "labkitProject" - error('labkit:ui:runtime:ProjectWriteFailed', ... - 'Temporary project readback inventory was invalid.'); - end - readback = load(temporary, 'labkitProject'); - if ~isequaln(readback.labkitProject, labkitProject) - error('labkit:ui:runtime:ProjectWriteFailed', ... - 'Temporary project readback did not match the encoded document.'); - end - if isa(beforeReplace, 'function_handle') - beforeReplace(temporary, filepath); - end - [moved, message] = movefile(temporary, filepath, 'f'); - if ~moved - error('labkit:ui:runtime:ProjectWriteFailed', ... - 'Could not replace project file: %s.', message); - end - clear cleanup; -end - -function deleteIfPresent(filepath) - if isfile(filepath) - delete(filepath); - end -end diff --git a/+labkit/+ui/+runtime/private/writeV2RecoveryFile.m b/+labkit/+ui/+runtime/private/writeV2RecoveryFile.m deleted file mode 100644 index 306ee4a4f..000000000 --- a/+labkit/+ui/+runtime/private/writeV2RecoveryFile.m +++ /dev/null @@ -1,34 +0,0 @@ -% Private Runtime V2 recovery writer. Expected callers are the debounced -% autosave scheduler and the injected project.saveAutosave service. Input is a -% current runtime snapshot. Side effect atomically writes the current recovery -% generation while retaining at most one previous generation. -function filepath = writeV2RecoveryFile(runtime) - folder = recoveryFolder(runtime); - if ~isfolder(folder) - mkdir(folder); - end - filepath = string(fullfile(folder, "recovery.mat")); - previous = string(fullfile(folder, "previous.mat")); - if isfile(filepath) - [copied, message] = copyfile(filepath, previous, 'f'); - if ~copied - error('labkit:ui:runtime:RecoveryWriteFailed', ... - 'Could not retain the previous recovery generation: %s.', ... - message); - end - end - envelope = createV2ProjectEnvelope(runtime, [], filepath); - writeV2ProjectFile(filepath, envelope); -end - -function folder = recoveryFolder(runtime) - root = ""; - if isstruct(runtime.request) && isfield(runtime.request, 'recoveryRoot') - root = string(runtime.request.recoveryRoot); - end - if strlength(root) == 0 - root = fullfile(prefdir, "LabKit", "recovery"); - end - folder = string(fullfile(root, appStorageKey(runtime.definition.id), ... - char(runtime.document.id))); -end diff --git a/+labkit/+ui/+runtime/private/writeV2ResultManifest.m b/+labkit/+ui/+runtime/private/writeV2ResultManifest.m deleted file mode 100644 index d5917a913..000000000 --- a/+labkit/+ui/+runtime/private/writeV2ResultManifest.m +++ /dev/null @@ -1,211 +0,0 @@ -% Private UI runtime helper. Expected caller: v2 handler result services. -% Inputs are the current runtime, result package folder, and app-owned spec. -% Outputs are the JSON manifest path and normalized labkit.result envelope. -% Side effects calculate completed-file metadata and atomically write JSON. -function [manifestPath, manifest] = writeV2ResultManifest(runtime, folder, spec) - folder = string(folder); - if ~isfolder(folder) - mkdir(folder); - end - if ~isstruct(spec) || ~isscalar(spec) - invalid('Result specification must be a scalar struct.'); - end - outputs = normalizeOutputs(folder, requiredValue(spec, 'Outputs')); - manifest = struct( ... - "format", "labkit.result", ... - "formatVersion", struct("major", 1, "minor", 0), ... - "app", struct("id", string(runtime.definition.id), ... - "version", appVersion(runtime)), ... - "run", struct("id", newId(), "createdAtUtc", utcNow(), ... - "status", aggregateStatus(outputs)), ... - "inputs", optionValue(spec, 'Inputs', struct([])), ... - "parameters", optionValue(spec, 'Parameters', struct()), ... - "outputs", outputs, ... - "summary", optionValue(spec, 'Summary', struct()), ... - "provenance", struct( ... - "labkitUiVersion", uiVersion(), ... - "matlabRelease", string(version("-release")), ... - "platform", string(computer), ... - "warnings", optionValue(spec, 'Warnings', strings(1, 0))), ... - "extensions", optionValue(spec, 'Extensions', struct())); - if strlength(runtime.document.id) > 0 - manifest.project = struct("documentId", runtime.document.id, ... - "revision", runtime.document.revision); - end - validateSerializableState(manifest); - manifestName = optionValue(spec, 'ManifestName', ''); - if strlength(string(manifestName)) == 0 - manifestName = defaultManifestName(outputs); - end - manifestName = normalizedRelativePath(manifestName); - if contains(manifestName, "/") - invalid('Result manifest name must stay in the package root.'); - end - manifestPath = fullfile(folder, manifestName); - writeJsonAtomic(manifestPath, manifest); -end - -function name = defaultManifestName(outputs) - if numel(outputs) == 1 && strlength(outputs(1).relativePath) > 0 - [~, base, ~] = fileparts(outputs(1).relativePath); - name = string(base) + ".labkit.json"; - else - name = "labkit_result.json"; - end -end - -function outputs = normalizeOutputs(folder, values) - if ~isstruct(values) - invalid('Result Outputs must be a struct array.'); - end - outputs = struct("id", {}, "role", {}, "relativePath", {}, ... - "mediaType", {}, "bytes", {}, "sha256", {}, "status", {}, ... - "message", {}); - for k = 1:numel(values) - relativePath = normalizedRelativePath(requiredValue(values(k), 'Path')); - status = string(optionValue(values(k), 'Status', 'success')); - message = string(optionValue(values(k), 'Message', '')); - bytes = uint64(0); - digest = ""; - if status == "success" - target = packagePath(folder, relativePath); - if ~isfile(target) - status = "failed"; - message = "Output file was not found after export."; - else - details = dir(target); - bytes = uint64(details.bytes); - digest = sha256File(target); - end - end - outputs(end + 1) = struct( ... - "id", string(requiredValue(values(k), 'Id')), ... - "role", string(requiredValue(values(k), 'Role')), ... - "relativePath", relativePath, ... - "mediaType", string(optionValue(values(k), 'MediaType', ... - 'application/octet-stream')), ... - "bytes", bytes, "sha256", digest, "status", status, ... - "message", message); - end - ids = string({outputs.id}); - if any(strlength(ids) == 0) - invalid('Result output ids must be nonempty scalar text.'); - end - if numel(unique(ids, 'stable')) ~= numel(ids) - invalid('Result output ids must be unique.'); - end -end - -function path = normalizedRelativePath(value) - path = replace(string(value), "\", "/"); - if ~isscalar(path) || strlength(path) == 0 || startsWith(path, "/") || ... - startsWith(path, "//") || ~isempty(regexp(char(path), ... - '^[A-Za-z]:', 'once')) - invalid('Result output paths must be relative package paths.'); - end - parts = split(path, "/"); - if any(parts == "..") || any(parts == "") - invalid('Result output paths must not traverse package boundaries.'); - end - path = join(parts, "/"); -end - -function target = packagePath(folder, relativePath) - parts = cellstr(split(relativePath, "/")); - target = string(fullfile(folder, parts{:})); -end - -function digest = sha256File(filepath) - file = fopen(filepath, 'rb'); - if file < 0 - invalid('Could not read exported output for checksum.'); - end - cleanup = onCleanup(@() fclose(file)); - algorithm = java.security.MessageDigest.getInstance('SHA-256'); - while true - buffer = fread(file, 65536, '*uint8'); - if isempty(buffer) - break; - end - algorithm.update(typecast(buffer, 'int8')); - end - raw = typecast(algorithm.digest(), 'uint8'); - digest = lower(string(reshape(dec2hex(raw, 2).', 1, []))); - clear cleanup; -end - -function writeJsonAtomic(filepath, value) - temporary = string(filepath) + ".tmp"; - cleanup = onCleanup(@() deleteIfPresent(temporary)); - file = fopen(temporary, 'w'); - if file < 0 - invalid('Could not create result manifest temporary file.'); - end - fileCleanup = onCleanup(@() fclose(file)); - fwrite(file, jsonencode(value, PrettyPrint=true), 'char'); - clear fileCleanup; - [moved, message] = movefile(temporary, filepath, 'f'); - if ~moved - invalid('Could not replace result manifest: %s.', message); - end - clear cleanup; -end - -function value = requiredValue(spec, name) - if ~isfield(spec, name) - invalid('Result specification is missing %s.', name); - end - value = spec.(name); -end - -function value = optionValue(spec, name, defaultValue) - value = defaultValue; - if isstruct(spec) && isfield(spec, name) - value = spec.(name); - end -end - -function value = aggregateStatus(outputs) - statuses = string({outputs.status}); - if isempty(statuses) || all(statuses == "success") - value = "success"; - elseif all(statuses == "failed") - value = "failed"; - else - value = "partial"; - end -end - -function value = appVersion(runtime) - value = ""; - if isappdata(runtime.ui.figure, 'labkitUiAppVersion') - info = getappdata(runtime.ui.figure, 'labkitUiAppVersion'); - if isstruct(info) && isfield(info, 'version') - value = string(info.version); - end - end -end - -function value = uiVersion() - info = labkit.ui.version(); - value = string(info.current); -end - -function value = newId() - value = string(char(java.util.UUID.randomUUID())); -end - -function value = utcNow() - value = string(datetime("now", "TimeZone", "UTC", ... - "Format", "yyyy-MM-dd'T'HH:mm:ss'Z'")); -end - -function deleteIfPresent(filepath) - if isfile(filepath) - delete(filepath); - end -end - -function invalid(message, varargin) - error('labkit:ui:runtime:InvalidResultManifest', message, varargin{:}); -end diff --git a/+labkit/+ui/+runtime/saveState.m b/+labkit/+ui/+runtime/saveState.m deleted file mode 100644 index a39f16f00..000000000 --- a/+labkit/+ui/+runtime/saveState.m +++ /dev/null @@ -1,85 +0,0 @@ -function filepath = saveState(fig, filepath) -%SAVESTATE Save a Runtime V2 project to a MAT file. -% -% Usage: -% filepath = labkit.ui.runtime.saveState(fig) -% filepath = labkit.ui.runtime.saveState(fig, filepath) -% -% Inputs: -% fig - Live app figure created by labkit.ui.runtime.launch. -% filepath - Destination MAT-file. When omitted for an opened project, its -% current path is reused. When omitted for a new project, MATLAB opens a -% save dialog. The parent folder must already exist. -% -% Outputs: -% filepath - Selected or supplied path as a string scalar, or "" when the -% user cancels the dialog. -% -% Description: -% saveState writes one variable named labkitProject. The envelope contains -% durable project data, schema and producer versions, source references, -% document identity and revision information, and optional resume data. -% Session caches and live graphics handles are not saved. Unknown additive -% envelope fields from a previously loaded project are preserved. -% -% The file is first written and verified at a temporary path in the same -% folder. It replaces the destination only after the read-back comparison -% succeeds, so a failure before replacement leaves an existing project file -% unchanged. Save without an explicit filepath reuses an opened document's -% path, including a migrated old project, and asks for a path only for a new -% unsaved document. Source relativePath fields are recalculated from the -% actual destination before serialization; additive reference fields are -% preserved. After a successful save the app records the path, clears -% its dirty flag, and updates the window title. -% -% Failure Behavior: -% Cancelling the save dialog returns "". labkit:ui:runtime:MissingRuntime is -% thrown when fig is not a live Runtime V2 figure. -% labkit:ui:runtime:ProjectWriteFailed is thrown when temporary writing, -% verification, the pre-replace hook, or atomic replacement fails. The -% previous destination and in-memory document ownership remain unchanged. -% Project canonicalization or App resume callbacks may propagate their -% validation error before any destination is replaced. -% -% Typical Call: -% savedFile = labkit.ui.runtime.saveState(fig, "analysis.project.mat"); -% -% See also labkit.ui.runtime.loadState - - runtime = getAppRuntime(fig); - if nargin < 2 - filepath = string(runtime.document.path); - if strlength(filepath) == 0 - filepath = chooseProjectOutput(); - end - if strlength(filepath) == 0 - return; - end - else - filepath = string(filepath); - end - labkitProject = createV2ProjectEnvelope(runtime, [], filepath); - beforeReplace = []; - if isstruct(runtime.request) && ... - isfield(runtime.request, 'projectBeforeReplace') - beforeReplace = runtime.request.projectBeforeReplace; - end - writeV2ProjectFile(filepath, labkitProject, beforeReplace); - runtime = getAppRuntime(fig); - runtime.document.path = filepath; - runtime.document.dirty = false; - runtime.document.modifiedAtUtc = labkitProject.document.modifiedAtUtc; - runtime.document.envelope = labkitProject; - setappdata(fig, appRuntimeKey(), runtime); - updateV2DocumentTitle(fig); -end - -function filepath = chooseProjectOutput() - [file, path] = uiputfile({'*.mat', 'MAT files (*.mat)'}, ... - 'Save LabKit Project'); - if isequal(file, 0) || isequal(path, 0) - filepath = ""; - else - filepath = string(fullfile(path, file)); - end -end diff --git a/+labkit/+ui/+runtime/sourcePaths.m b/+labkit/+ui/+runtime/sourcePaths.m deleted file mode 100644 index 2f3e202c1..000000000 --- a/+labkit/+ui/+runtime/sourcePaths.m +++ /dev/null @@ -1,97 +0,0 @@ -function paths = sourcePaths(sources, ids) -%SOURCEPATHS Read resolved paths from Runtime V2 source records. -% -% Usage: -% paths = labkit.ui.runtime.sourcePaths(sources) -% paths = labkit.ui.runtime.sourcePaths(sources, ids) -% -% Inputs: -% sources - Runtime V2 source struct array. Apps normally initialize an -% empty collection with labkit.ui.runtime.emptySourceRecords and add -% records through labkit.ui.runtime.sourceRecord or the equivalent -% injected project services. -% ids - Optional source ID or collection of source IDs. Requested IDs are -% returned in the supplied order. An ID that has not been added yet -% returns an empty string in that position. When omitted, paths follow -% source-record order. -% -% Outputs: -% paths - Column string array containing each source's current resolved -% path. An empty source collection or empty ids input returns a 0-by-1 -% string array. -% -% Description: -% Source IDs, roles, and required flags are App-facing project data. The -% nested portable-reference representation is owned by Runtime V2 and may -% evolve independently. Use sourcePaths wherever App code needs to read a -% file, compare selected files, build a session cache, or present a path. -% -% Errors: -% labkit:ui:runtime:InvalidSourceRecords - A source record, requested ID, -% or runtime-owned reference is malformed. -% -% Example: -% sources = labkit.ui.runtime.emptySourceRecords(); -% assert(isempty(labkit.ui.runtime.sourcePaths(sources))) -% -% See also labkit.ui.runtime.sourceRecord, -% labkit.ui.runtime.emptySourceRecords, -% labkit.ui.runtime.defaultOutputFolder - - if nargin < 1 - error('labkit:ui:runtime:InvalidSourceRecords', ... - 'Source records are required.'); - end - validateSourceRecords(sources); - - sourceIds = strings(0, 1); - if ~isempty(sources) - sourceIds = string({sources.id}).'; - end - indices = (1:numel(sources)).'; - if nargin >= 2 - requested = normalizeIds(ids); - if isempty(requested) - paths = strings(0, 1); - return; - end - [~, indices] = ismember(requested, sourceIds); - end - - paths = strings(numel(indices), 1); - for k = 1:numel(indices) - if indices(k) == 0 - continue; - end - source = sources(indices(k)); - if ~isfield(source, 'reference') || ... - ~isstruct(source.reference) || ... - ~isscalar(source.reference) || ... - ~isfield(source.reference, 'originalPath') - invalidReference(sourceIds(indices(k))); - end - value = source.reference.originalPath; - if ~(ischar(value) || (isstring(value) && isscalar(value))) - invalidReference(sourceIds(indices(k))); - end - paths(k) = string(value); - end -end - -function ids = normalizeIds(value) - if ~(ischar(value) || isstring(value) || iscellstr(value)) - error('labkit:ui:runtime:InvalidSourceRecords', ... - 'Requested source ids must be text.'); - end - ids = string(value); - ids = ids(:); - if any(strlength(ids) == 0) - error('labkit:ui:runtime:InvalidSourceRecords', ... - 'Requested source ids must be nonempty text.'); - end -end - -function invalidReference(id) - error('labkit:ui:runtime:InvalidSourceRecords', ... - 'Project source "%s" has no valid resolved path reference.', id); -end diff --git a/+labkit/+ui/+runtime/sourceRecord.m b/+labkit/+ui/+runtime/sourceRecord.m deleted file mode 100644 index 2c90dede3..000000000 --- a/+labkit/+ui/+runtime/sourceRecord.m +++ /dev/null @@ -1,75 +0,0 @@ -function source = sourceRecord(id, role, sourceValue, required) -%SOURCERECORD Create one canonical Runtime V2 external-source record. -% -% Usage: -% source = labkit.ui.runtime.sourceRecord(id, role, filepath) -% source = labkit.ui.runtime.sourceRecord(id, role, reference) -% source = labkit.ui.runtime.sourceRecord(id, role, sourceValue, required) -% -% Inputs: -% id - Nonempty scalar text identifying this source within one App project. -% The ID is App-owned, stable across saves, and unique in the project's -% source collection. It is not derived from the filename. -% role - Nonempty scalar text describing the source's App-owned semantic -% role, such as "referenceImage" or "numericTrace". -% sourceValue - Either a nonempty scalar file path as char or string, or an -% existing portable-reference scalar struct supplied by a legacy project -% importer. Runtime V2 validates and owns the resulting reference shape. -% App code must preserve an existing reference as an opaque value and -% must not read or construct its nested fields. -% required - Optional scalar logical flag. True means project loading must -% resolve this source before committing the loaded state. Default: true. -% -% Outputs: -% source - Scalar struct with stable App-facing id, required, and role -% fields plus a runtime-owned reference field. Preserve reference but do -% not read or construct its nested fields; use sourcePaths to obtain the -% current resolved path. -% -% Description: -% Use the path form in project creation, migration, and tests. The reference -% form lets a legacy importer preserve a portable reference without copying -% Runtime-private field construction into the App. Runtime action handlers -% may equivalently call the injected -% services.project.sourceRecord service. Both entry points produce the same -% canonical record. -% -% Errors: -% labkit:ui:runtime:InvalidSourceRecords - An ID, role, source value, or -% required value is empty, nonscalar, malformed, or has the wrong type. -% -% Example: -% source = labkit.ui.runtime.sourceRecord( ... -% "reference", "referenceImage", "reference.tif"); -% assert(labkit.ui.runtime.sourcePaths(source) == "reference.tif") -% -% See also labkit.ui.runtime.sourcePaths, -% labkit.ui.runtime.emptySourceRecords, labkit.ui.runtime.define - - if nargin < 4 - required = true; - end - validateText(id, 'Project source id'); - validateText(role, 'Project source role'); - if ~isstruct(sourceValue) - validateText(sourceValue, 'Project source filepath'); - end - if ~(islogical(required) || isnumeric(required)) || ... - ~isscalar(required) || ~isfinite(double(required)) || ... - ~any(double(required) == [0 1]) - invalid('Project source required flag must be scalar logical.'); - end - source = canonicalSourceRecord( ... - string(id), string(role), sourceValue, logical(required)); -end - -function validateText(value, label) - if ~(ischar(value) || (isstring(value) && isscalar(value))) || ... - strlength(string(value)) == 0 - invalid('%s must be nonempty scalar text.', label); - end -end - -function invalid(message, varargin) - error('labkit:ui:runtime:InvalidSourceRecords', message, varargin{:}); -end diff --git a/+labkit/+ui/version.m b/+labkit/+ui/version.m deleted file mode 100644 index eab319e90..000000000 --- a/+labkit/+ui/version.m +++ /dev/null @@ -1,35 +0,0 @@ -function info = version() -%VERSION Return the LabKit UI facade contract version. -% -% Usage: -% info = labkit.ui.version() -% -% Description: -% Reports the semantic version and compatibility range of the public -% labkit.ui framework contract. Apps declare this range through -% labkit.contract.requirements; it is independent of any App product or -% saved-project schema version. -% -% Inputs: -% None. -% -% Outputs: -% info - Scalar structure returned by labkit.contract.versionInfo with name, -% facade, current, compatible, status, and notes fields. -% -% Failure Behavior: -% The function accepts no caller input. Invalid embedded facade metadata -% raises labkit:contract:InvalidVersionInfo; released metadata is validated -% by the contract test suite. -% -% Example: -% info = labkit.ui.version(); -% assert(startsWith(info.current, "7.")) -% -% See also labkit.contract.versionInfo, -% labkit.contract.requirements, -% labkit.ui.runtime.define - - info = labkit.contract.versionInfo("ui", "7.6.0", ">=7 <8", ... - "stable", "UI 7 Runtime V2 contract with one definition factory, one version-aware project migration entry per App, Runtime-owned canonical project and session validation, atomic source relinking and strict session reconstruction diagnostics, minimal optional lifecycle capabilities, canonical new-state construction, queued semantic events, deterministic presentation including dynamic result-table headers, collapsible workspace panels, and native workspace tabs, managed interactions and resources, opaque portable source references with GUI-free record creation and path access, runtime dialog/result services, and private serialization and busy-state mechanics."); -end diff --git a/+labkit/AGENTS.md b/+labkit/AGENTS.md index d2267be5d..22b94feff 100644 --- a/+labkit/AGENTS.md +++ b/+labkit/AGENTS.md @@ -5,8 +5,8 @@ than the apps that consume it. ## Read before editing -Read `docs/development/architecture.md`, the affected source and tests, and the -one owning manual: +Read `docs/development/build-apps/architecture.md`, the affected source and +tests, and the one owning manual: - UI: `docs/framework/README.md` - image: `docs/libraries/image/README.md` @@ -30,24 +30,48 @@ Framework tests live under `tests/cases/unit/labkit_framework/` and - `labkit.image`, `thermal`, `dta`, `rhs`, and `biosignal` stay GUI-free and app-free. Each owns its documented file/data/scientific primitive contract, not an app's task orchestration. -- `labkit.ui` stays parser- and analysis-free. Its public layers are - `runtime`, `layout`, `plot`, `interaction`, and `debug`; registry mutation, - queueing, concrete controls, and lifecycle handles remain private. +- `labkit.app` owns the App SDK. Registry mutation, queueing, concrete + controls, native adapters, and lifecycle handles remain private. - `labkit.contract` owns MATLAB-native version requirements and range checks, not app discovery or package management. - Do not introduce MATLAB classes or a third-party runtime dependency without explicit approval. +- The active UI explicit-contract migration has Phase 1 approval for the small + sealed immutable value vocabulary accepted in + `.agents/migration/ui-explicit-contract/phase-1-contract-rfc.md`. Keep it + composition-only: no public inheritance hierarchy, mutable handle-state + model, version-named namespace, or adapter back to retired transport + structs. Phase 2 may implement the production kernel, but do not release the + contract until its own compile/help/error gate passes. ## Runtime contracts -- Apps launch through `labkit.ui.runtime.launch/define`. The runtime owns +- Apps return one `labkit.app.Definition` and launch it through + `Definition.launch`. The runtime owns startup readiness, busy state, queued events, atomic presentation, close guards, diagnostics, persistence, recovery, resources, and interactions. - Semantic ids are developer-owned and framework-validated. App ids are stable compatibility identifiers; layout/action/axis/source/result namespaces must remain legal and unique; references must resolve before UI mutation. -- Presentation must preserve unchanged graphics and viewports. Renderers own +- View snapshots must preserve unchanged graphics and viewports. Renderers own incremental overlay changes; interaction specs own user gestures. +- The App runtime composes complete `labkit.app.view.Snapshot` values from + `labkit.app.layout.*` defaults, strict state bindings, framework-owned state, + and the App's dynamic view fragment; private reconciliation owns diffs. +- Layout controls own direct function-handle callbacks and plot renderers. + Definition privately compiles those links; Apps never maintain a second + handler, renderer, or capability registry. File-list source and selection + lifecycle is binding-driven rather than mirrored by App callbacks. +- App examples do not relay SDK values through untyped app-local parameters. + Runtime callbacks name the complete application state, concrete event + payload, and `CallbackContext` at their boundary, then delegate domain work + through narrow explicit inputs. +- Compile the immutable static Definition graph once. View commits + validate against the cached graph and must not re-flatten layout on every + update. +- A new public UI capability needs repeated evidence from at least two Apps or + one framework-owned lifecycle/consistency requirement. Prefer framework + automation when repeated App callback or presenter glue is the evidence. - Persistence writes only the current project envelope. Ordered app migrations and declared legacy importers are read-only compatibility hooks and must not introduce app-id branches in the framework. diff --git a/.agents/migration/ui-explicit-contract/authoring-ergonomics.md b/.agents/migration/ui-explicit-contract/authoring-ergonomics.md new file mode 100644 index 000000000..dc711dab7 --- /dev/null +++ b/.agents/migration/ui-explicit-contract/authoring-ergonomics.md @@ -0,0 +1,45 @@ +# UI explicit-contract authoring ergonomics gate + +Status: active production gate, established 2026-07-19. + +The replacement is an SDK. Internal framework size is not an acceptance +metric; repeated App wiring and the number of concepts required on the paved +road are. `UiAuthoringErgonomicsTest` protects the first executable budgets. + +| Author task | Current production path | Gate | +| --- | --- | --- | +| Static App | `Application` plus `Layout` | No Command, presenter, project, or capability list | +| Ordinary state field | `Layout.field(..., Bind=path)` | No Command and no presenter operation | +| Business-effect control | One `Command`, callback, and direct Layout reference | No duplicate Application command registry | +| Simple project | `ProjectContract()` | Version 1 scalar-struct create/validate defaults | +| Context capabilities | Omit `Capabilities` | Standard path has no synchronized allow-list | +| Framework log | `context.appendStatus(message)` | No false App-state return value | +| Standard file collection | `filePanel(..., Bind=..., SelectionBind=...)` | No Command or presenter; runtime owns records, selection, and project rebasing | + +Chrono Overlay is the first real migrated App: + +| Chrono author surface | Runtime V2 | Explicit contract | +| --- | ---: | ---: | +| Definition code lines | 20 | 20 | +| Layout code lines | 73 | 33 | +| Action/callback code lines | 149 | 38 | +| Presenter code lines | 55 | 15 | +| App callbacks | 5 | 1 | +| App-authored presenter operations | 5 | 2 | + +Counts exclude blank/comment-only MATLAB lines and use commit `5a87bdd2` as +the Runtime V2 baseline. The remaining callback is the scientific CSV/result +export. Runtime owns file add/remove/clear, stable source identities, +selection, transient session rebuild, project source portability, binding +updates, and native component reconciliation. One renderer receives the two +declared axes in order. + +This closes the one-plot and one-result-package rows. Project save/restore is +covered through the declared `ProjectContract` without App menu code; the +production project menu and recovery UX remain a framework item. T-Test +Wizard and Curvature or Video Marker next supply the table/workspace and +interaction/resource comparisons. + +Public capability extensions require either repeated need in at least two Apps +or one framework-owned lifecycle/consistency problem. A capability is not +accepted merely because it can be generalized. diff --git a/.agents/migration/ui-explicit-contract/baseline.json b/.agents/migration/ui-explicit-contract/baseline.json new file mode 100644 index 000000000..09733872e --- /dev/null +++ b/.agents/migration/ui-explicit-contract/baseline.json @@ -0,0 +1,31023 @@ +{ + "schemaVersion": 3, + "debtId": "ui-explicit-contract-redesign", + "publicUiSymbols": [ + "labkit.ui.debug.context", + "labkit.ui.interaction.anchorPath", + "labkit.ui.interaction.enablePopout", + "labkit.ui.interaction.scaleBarCalibration", + "labkit.ui.interaction.scaleBarGeometry", + "labkit.ui.layout.action", + "labkit.ui.layout.field", + "labkit.ui.layout.filePanel", + "labkit.ui.layout.group", + "labkit.ui.layout.logPanel", + "labkit.ui.layout.panner", + "labkit.ui.layout.previewArea", + "labkit.ui.layout.rangeField", + "labkit.ui.layout.resultTable", + "labkit.ui.layout.section", + "labkit.ui.layout.statusPanel", + "labkit.ui.layout.tab", + "labkit.ui.layout.workbench", + "labkit.ui.layout.workspace", + "labkit.ui.plot.clampData", + "labkit.ui.plot.clear", + "labkit.ui.plot.fit", + "labkit.ui.plot.fitCanvas", + "labkit.ui.plot.message", + "labkit.ui.plot.offsetData", + "labkit.ui.runtime.create", + "labkit.ui.runtime.defaultOutputFolder", + "labkit.ui.runtime.define", + "labkit.ui.runtime.emptySourceRecords", + "labkit.ui.runtime.launch", + "labkit.ui.runtime.loadState", + "labkit.ui.runtime.saveState", + "labkit.ui.runtime.sourcePaths", + "labkit.ui.runtime.sourceRecord", + "labkit.ui.version" + ], + "apps": [ + { + "id": "batch-crop", + "command": "labkit_BatchImageCrop_app", + "title": "BatchImageCrop", + "family": "Image Measurement", + "folder": "apps/image_measurement/batch_crop", + "fileCount": 59, + "categories": { + "layoutconstructor": { + "count": 45, + "values": [ + "workbench", + "tab", + "section", + "action", + "field", + "resultTable", + "statusPanel", + "logPanel", + "filePanel", + "group", + "workspace", + "previewArea", + "panner" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 39, + "values": [ + "controls", + "interactions", + "previews" + ] + }, + "interactionkind": { + "count": 2, + "values": [ + "pointSlots", + "scaleBarReference" + ] + }, + "servicecall": { + "count": 35, + "values": [ + "dialogs", + "workflow", + "diagnostics", + "results", + "events", + "project" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 29, + "values": [ + "onExportSettingChanged.state, ~, ~", + "onChooseOutputFolder.state, ~, services", + "onExportCrops.state, ~, services", + "createSession.project", + "onImagesChosen.state, event, services", + "onClearImages.state, ~, services", + "onRemoveImages.state, event, services", + "onDuplicateImage.state, ~, services", + "onImageSelectionChanged.state, event, services", + "onPreviousImage.state, ~, services", + "onNextImage.state, ~, services", + "onCropGeometryChanged.state, ~, services", + "onRotationChanged.state, event, services", + "onPaddingChanged.state, event, services", + "onCenterChanged.state, event, services", + "onUseImageCenterXY.state, event, services", + "onUseImageCenterX.state, event, services", + "onUseImageCenterY.state, event, services", + "onUseImageCenter.state, ~, services, mode", + "onScaleSettingChanged.state, ~, ~", + "onScaleCalibrationFieldChanged.state, event, ~", + "onMeasureScaleReference.state, ~, services", + "onScaleReferenceEdited.state, event, ~", + "onScaleBarSettingChanged.state, ~, ~", + "onPlaceScaleBar.state, ~, services", + "onCropCenterEdited.state, event, services", + "createProject.", + "migrateProject.project, fromVersion", + "validateProject.project" + ] + } + } + }, + { + "id": "cic", + "command": "labkit_CIC_app", + "title": "CIC", + "family": "Electrochem", + "folder": "apps/electrochem/cic", + "fileCount": 27, + "categories": { + "layoutconstructor": { + "count": 31, + "values": [ + "workbench", + "tab", + "section", + "logPanel", + "filePanel", + "group", + "action", + "field", + "resultTable", + "workspace", + "previewArea", + "panner" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 7, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 29, + "values": [ + "events", + "workflow", + "project", + "dialogs", + "results" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 10, + "values": [ + "createSession.project", + "onOpenFilesChosen.state, event, services", + "onRemoveSelected.state, event, services", + "onClearAll.state, ~, services", + "onFileSelectionChanged.state, event, services", + "onPresetChanged.state, ~, services", + "onAnalysisChanged.state, ~, services", + "onExportResults.state, ~, services", + "createProject.", + "validateProject.project" + ] + } + } + }, + { + "id": "csc", + "command": "labkit_CSC_app", + "title": "CSC", + "family": "Electrochem", + "folder": "apps/electrochem/csc", + "fileCount": 25, + "categories": { + "layoutconstructor": { + "count": 26, + "values": [ + "workbench", + "tab", + "section", + "logPanel", + "filePanel", + "group", + "action", + "field", + "resultTable", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 13, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 32, + "values": [ + "events", + "workflow", + "dialogs", + "results", + "project" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 10, + "values": [ + "createSession.project", + "onOpenFilesChosen.state, event, services", + "onRemoveSelected.state, event, services", + "onClearAll.state, ~, services", + "onReloadSelected.state, ~, services", + "onFileSelectionChanged.state, event, services", + "onExportResults.state, ~, services", + "onExportVoltageCurrent.state, ~, services", + "createProject.", + "validateProject.project" + ] + } + } + }, + { + "id": "chrono-overlay", + "command": "labkit_ChronoOverlay_app", + "title": "ChronoOverlay", + "family": "Electrochem", + "folder": "apps/electrochem/chrono_overlay", + "fileCount": 12, + "categories": { + "layoutconstructor": { + "count": 16, + "values": [ + "workbench", + "tab", + "section", + "logPanel", + "filePanel", + "group", + "action", + "field", + "panner", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 6, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 20, + "values": [ + "events", + "workflow", + "dialogs", + "results", + "project" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 9, + "values": [ + "createSession.project", + "onOpenFilesChosen.state, event, services", + "onRemoveSelected.state, event, services", + "onClearAll.state, ~, services", + "onSelectionChanged.state, event, services", + "onExportCSV.state, ~, services", + "createProject.", + "validateProject.project", + "migrateProject.project, fromVersion" + ] + } + } + }, + { + "id": "curvature", + "command": "labkit_CurvatureMeasurement_app", + "title": "CurvatureMeasurement", + "family": "Image Measurement", + "folder": "apps/image_measurement/curvature", + "fileCount": 33, + "categories": { + "layoutconstructor": { + "count": 37, + "values": [ + "workbench", + "tab", + "section", + "action", + "field", + "resultTable", + "statusPanel", + "logPanel", + "filePanel", + "group", + "panner", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 27, + "values": [ + "controls", + "previews", + "interactions" + ] + }, + "interactionkind": { + "count": 2, + "values": [ + "anchors", + "scaleBarReference" + ] + }, + "servicecall": { + "count": 38, + "values": [ + "events", + "workflow", + "diagnostics", + "dialogs", + "project", + "results" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 20, + "values": [ + "createSession.project", + "onOpenImage.state, event, services", + "onToggleCurveEdit.state, ~, services", + "onCurvePointsEdited.state, event, services", + "onUndoCurvePoint.state, ~, services", + "onClearCurve.state, ~, services", + "onMeasureScaleReference.state, ~, services", + "onScaleReferenceEdited.state, event, ~", + "onScaleCalibrationChanged.state, event, ~", + "onScaleBarSettingChanged.state, ~, ~", + "onPlaceScaleBar.state, ~, services", + "onFitSettingChanged.state, ~, ~", + "onViewSettingChanged.state, ~, ~", + "onFitCurvature.state, ~, services", + "onMeasureCurveLength.state, ~, services", + "onExportCsv.state, ~, services", + "onExportOverlay.state, ~, services", + "createProject.", + "validateProject.project", + "migrateProject.project, fromVersion" + ] + } + } + }, + { + "id": "dic-postprocess", + "command": "labkit_DICPostprocess_app", + "title": "DICPostprocess", + "family": "Dic", + "folder": "apps/dic/dic_postprocess", + "fileCount": 32, + "categories": { + "layoutconstructor": { + "count": 22, + "values": [ + "workbench", + "tab", + "section", + "resultTable", + "statusPanel", + "logPanel", + "filePanel", + "action", + "workspace", + "previewArea", + "panner" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 9, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 33, + "values": [ + "workflow", + "project", + "dialogs", + "diagnostics", + "results", + "events" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 10, + "values": [ + "createSession.project", + "onMatChosen.state, event, services", + "onReferenceChosen.state, event, services", + "onMaskChosen.state, event, services", + "onGenerate.state, ~, services", + "onOptionsChanged.state, ~, services", + "onSaveOverlays.state, ~, services", + "onExportSummary.state, ~, services", + "createProject.", + "validateProject.project" + ] + } + } + }, + { + "id": "dic-preprocess", + "command": "labkit_DICPreprocess_app", + "title": "DICPreprocess", + "family": "Dic", + "folder": "apps/dic/dic_preprocess", + "fileCount": 47, + "categories": { + "layoutconstructor": { + "count": 45, + "values": [ + "workbench", + "tab", + "section", + "statusPanel", + "logPanel", + "filePanel", + "field", + "action", + "group", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 31, + "values": [ + "controls", + "previews", + "interactions" + ] + }, + "interactionkind": { + "count": 3, + "values": [ + "pairedAnchors", + "rectangle", + "anchors" + ] + }, + "servicecall": { + "count": 49, + "values": [ + "events", + "workflow", + "diagnostics", + "dialogs", + "project", + "results" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 34, + "values": [ + "createSession.project", + "onReferenceChosen.state, event, services", + "onMovingChosen.state, event, services", + "onImageChosen.state, event, services, role", + "onReferenceCleared.state, ~, services", + "onMovingCleared.state, ~, services", + "onImageCleared.state, services, role", + "onPreviewChanged.state, ~, ~", + "onStartPointMatching.state, ~, services", + "onPointPairsEdited.state, event, ~", + "onApplyPointAlignment.state, ~, services", + "onCancelPointMatching.state, ~, services", + "onUndoPointPair.state, ~, ~", + "onAutoAlign.state, ~, services", + "onStartCropRoi.state, ~, services", + "onCropRectMoved.state, event, ~", + "onApplyCropRoi.state, ~, services", + "onCancelCropRoi.state, ~, services", + "onUndoEdit.state, ~, services", + "onResetToOriginals.state, ~, services", + "onSaveCurrentImages.state, ~, services", + "onStartMaskEdit.state, ~, services", + "onMaskPointsEdited.state, event, ~", + "onBoundaryStyleChanged.state, ~, ~", + "onPreviewMaskRoi.state, ~, services", + "onAddBoundaryToMask.state, ~, services", + "onSubtractBoundaryFromMask.state, ~, services", + "onUndoMaskAnchor.state, ~, ~", + "onUndoMaskEdit.state, ~, services", + "onClearMaskBoundary.state, ~, services", + "onClearMaskCanvas.state, ~, services", + "onSaveMask.state, ~, services", + "createProject.", + "validateProject.project" + ] + } + } + }, + { + "id": "ecg-print", + "command": "labkit_ECGPrint_app", + "title": "ECGPrint", + "family": "Wearable", + "folder": "apps/wearable/ecg_print", + "fileCount": 21, + "categories": { + "layoutconstructor": { + "count": 40, + "values": [ + "workbench", + "tab", + "section", + "resultTable", + "statusPanel", + "logPanel", + "filePanel", + "action", + "field", + "panner", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 14, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 28, + "values": [ + "workflow", + "project", + "dialogs", + "diagnostics", + "results", + "events" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 12, + "values": [ + "createSession.project", + "onRecordingChosen.state, event, services", + "onPreviewHeader.state, ~, services", + "onImportOptionChanged.state, ~, ~", + "onRefreshImport.state, ~, services", + "onChannelChanged.state, event, services", + "onAnalyze.state, ~, services", + "onExportSegments.state, ~, services", + "onExportWaveform.state, ~, services", + "createProject.", + "migrateProject.project, fromVersion", + "validateProject.project" + ] + } + } + }, + { + "id": "eis", + "command": "labkit_EIS_app", + "title": "EIS", + "family": "Electrochem", + "folder": "apps/electrochem/eis", + "fileCount": 17, + "categories": { + "layoutconstructor": { + "count": 24, + "values": [ + "workbench", + "tab", + "section", + "logPanel", + "filePanel", + "group", + "action", + "field", + "panner", + "statusPanel", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 3, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 20, + "values": [ + "events", + "workflow", + "dialogs", + "results", + "project" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 8, + "values": [ + "createSession.project", + "onOpenFilesChosen.state, event, services", + "onRemoveSelected.state, event, services", + "onClearAll.state, ~, services", + "onSelectionChanged.state, event, services", + "onExportCSV.state, ~, services", + "createProject.", + "validateProject.project" + ] + } + } + }, + { + "id": "flir-thermal", + "command": "labkit_FLIRThermal_app", + "title": "FLIRThermal", + "family": "Image Measurement", + "folder": "apps/image_measurement/flir_thermal", + "fileCount": 34, + "categories": { + "layoutconstructor": { + "count": 43, + "values": [ + "workbench", + "tab", + "section", + "logPanel", + "filePanel", + "field", + "group", + "action", + "resultTable", + "panner", + "statusPanel", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 27, + "values": [ + "controls", + "previews", + "interactions" + ] + }, + "interactionkind": { + "count": 1, + "values": "regionSelection" + }, + "servicecall": { + "count": 28, + "values": [ + "events", + "workflow", + "project", + "dialogs", + "results", + "diagnostics" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 20, + "values": [ + "createSession.project", + "onFilesChosen.state, event, services", + "onRemoveFiles.state, event, services", + "onClearFiles.state, ~, services", + "onSelectionChanged.state, event, services", + "onPreviousImage.state, ~, services", + "onNextImage.state, ~, services", + "onAutoRange.state, ~, services", + "onGroupRange.state, ~, services", + "onPerImageRange.state, ~, services", + "onRoundRange.state, ~, services", + "onDisplaySettingChanged.state, event, ~", + "onRangePresetChanged.state, event, ~", + "onRangeChanged.state, event, ~", + "onTemperaturePointSelected.state, event, services", + "onTemperatureRegionSelected.state, event, services", + "onExportSettingChanged.state, event, ~", + "onChooseOutputFolder.state, ~, services", + "createProject.", + "validateProject.project" + ] + } + } + }, + { + "id": "figure-studio", + "command": "labkit_FigureStudio_app", + "title": "FigureStudio", + "family": "LabKit Core", + "folder": "apps/labkit_core/figure_studio", + "fileCount": 24, + "categories": { + "layoutconstructor": { + "count": 29, + "values": [ + "workbench", + "tab", + "section", + "filePanel", + "field", + "group", + "action", + "panner", + "logPanel", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "Start", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 6, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 37, + "values": [ + "events", + "workflow", + "project", + "dialogs", + "results", + "diagnostics", + "request", + "previews", + "resources", + "debug" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 18, + "values": [ + "createSession.project", + "onFiguresChosen.state, event, services", + "onFiguresRemoved.state, event, services", + "onClearFigures.state, ~, services", + "onSelectionChanged.state, event, services", + "onStyleChanged.state, ~, services", + "onStyleParameterChanged.state, event, ~", + "onChooseOutputFolder.state, ~, services", + "onSaveFig.state, ~, services", + "onExportPng.state, ~, services", + "onExportJpg.state, ~, services", + "onExportSvg.state, ~, services", + "onQuickExport.state, services, format, mediaType", + "onExportCurrent.state, ~, services", + "onOff.tf", + "createProject.", + "validateProject.project" + ] + } + } + }, + { + "id": "focus-stack", + "command": "labkit_FocusStack_app", + "title": "FocusStack", + "family": "Image Measurement", + "folder": "apps/image_measurement/focus_stack", + "fileCount": 29, + "categories": { + "layoutconstructor": { + "count": 27, + "values": [ + "workbench", + "tab", + "section", + "resultTable", + "statusPanel", + "logPanel", + "field", + "filePanel", + "action", + "panner", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 10, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 26, + "values": [ + "dialogs", + "workflow", + "events", + "project", + "results", + "diagnostics" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 13, + "values": [ + "createSession.project", + "onSourceFolderChosen.state, ~, services", + "onOpenFilesChosen.state, event, services", + "onRemoveImages.state, event, services", + "onClearImages.state, ~, services", + "onFusionPresetChanged.state, ~, services", + "onFusionOptionsChanged.state, ~, ~", + "onRunFocusStack.state, ~, services", + "onExportFused.state, ~, services", + "onExportFocusMap.state, ~, services", + "onExportSummary.state, ~, services", + "createProject.", + "validateProject.project" + ] + } + } + }, + { + "id": "gait-analysis", + "command": "labkit_GaitAnalysis_app", + "title": "GaitAnalysis", + "family": "Gait", + "folder": "apps/gait/gait_analysis", + "fileCount": 17, + "categories": { + "layoutconstructor": { + "count": 46, + "values": [ + "workbench", + "tab", + "section", + "filePanel", + "field", + "action", + "resultTable", + "group", + "logPanel", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 15, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 25, + "values": [ + "workflow", + "diagnostics", + "dialogs", + "project", + "results", + "events" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 12, + "values": [ + "createSession.project", + "onOpenPoseFile.state, event, services", + "onOptionsChanged.state, ~, ~", + "onRunAnalysis.state, ~, services", + "onChooseOutputFolder.state, ~, services", + "onExportResults.state, ~, services", + "onStepSelected.state, event, ~", + "onPreviousStep.state, ~, ~", + "onNextStep.state, ~, ~", + "createProject.", + "migrateProject.project, fromVersion", + "validateProject.project" + ] + } + } + }, + { + "id": "image-enhance", + "command": "labkit_ImageEnhance_app", + "title": "ImageEnhance", + "family": "Image Measurement", + "folder": "apps/image_measurement/image_enhance", + "fileCount": 36, + "categories": { + "layoutconstructor": { + "count": 36, + "values": [ + "workbench", + "tab", + "section", + "logPanel", + "filePanel", + "field", + "group", + "action", + "statusPanel", + "panner", + "resultTable", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 22, + "values": [ + "controls", + "previews", + "interactions" + ] + }, + "interactionkind": { + "count": 1, + "values": "rectangle" + }, + "servicecall": { + "count": 28, + "values": [ + "diagnostics", + "dialogs", + "workflow", + "events", + "results", + "project" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 19, + "values": [ + "createSession.project", + "onSourceImagesChosen.state, event, services", + "onRemoveImages.state, event, services", + "onClearImages.state, ~, services", + "onImageSelectionChanged.state, event, services", + "onBatchModeChanged.state, event, ~", + "onPreviewModeChanged.state, event, ~", + "onToolChanged.state, event, ~", + "onToolSettingChanged.state, event, ~", + "onSetWhiteRoi.state, ~, services", + "onWhiteRoiEdited.state, event, ~", + "onApplyTool.state, ~, services", + "onUndoHistory.state, ~, services", + "onResetHistory.state, ~, services", + "onExportSettingChanged.state, event, ~", + "onChooseOutputFolder.state, ~, services", + "onExportImages.state, ~, services", + "createProject.", + "validateProject.project" + ] + } + } + }, + { + "id": "image-match", + "command": "labkit_ImageMatch_app", + "title": "ImageMatch", + "family": "Image Measurement", + "folder": "apps/image_measurement/image_match", + "fileCount": 27, + "categories": { + "layoutconstructor": { + "count": 34, + "values": [ + "workbench", + "tab", + "section", + "logPanel", + "filePanel", + "field", + "group", + "action", + "statusPanel", + "resultTable", + "workspace", + "previewArea", + "panner" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 20, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 28, + "values": [ + "events", + "workflow", + "project", + "dialogs", + "results", + "diagnostics" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 16, + "values": [ + "createSession.project", + "onReferenceChosen.state, event, services", + "onSourcesChosen.state, event, services", + "onRemoveImages.state, event, services", + "onClearImages.state, ~, services", + "onSelectionChanged.state, event, services", + "onPreviewModeChanged.state, event, ~", + "onMatchSettingChanged.state, event, ~", + "onApplyMatch.state, ~, services", + "onUndoHistory.state, ~, services", + "onResetHistory.state, ~, services", + "onExportSettingChanged.state, event, ~", + "onChooseOutputFolder.state, ~, services", + "onExportImages.state, ~, services", + "createProject.", + "validateProject.project" + ] + } + } + }, + { + "id": "nerve-response-analysis", + "command": "labkit_NerveResponseAnalysis_app", + "title": "NerveResponseAnalysis", + "family": "Neurophysiology", + "folder": "apps/neurophysiology/nerve_response_analysis", + "fileCount": 19, + "categories": { + "layoutconstructor": { + "count": 34, + "values": [ + "workbench", + "tab", + "section", + "logPanel", + "filePanel", + "field", + "group", + "action", + "resultTable", + "statusPanel", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 10, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 19, + "values": [ + "diagnostics", + "workflow", + "project", + "dialogs", + "results", + "events" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 13, + "values": [ + "createSession.project", + "onSessionChosen.state, event, services", + "onProtocolChosen.state, event, services", + "onOutputFolderChosen.state, ~, services", + "onOutputFolderCleared.state, ~, ~", + "onSettingChanged.state, ~, ~", + "onPreviewModeChanged.state, event, ~", + "onRunAnalysis.state, ~, services", + "onExportAnalysis.state, ~, services", + "onResetWorkflow.~, ~, services", + "createProject.", + "migrateProject.project, fromVersion", + "validateProject.project" + ] + } + } + }, + { + "id": "rhs-preview", + "command": "labkit_RHSPreview_app", + "title": "RHSPreview", + "family": "Neurophysiology", + "folder": "apps/neurophysiology/rhs_preview", + "fileCount": 36, + "categories": { + "layoutconstructor": { + "count": 42, + "values": [ + "workbench", + "tab", + "section", + "logPanel", + "filePanel", + "field", + "panner", + "resultTable", + "group", + "action", + "statusPanel", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 21, + "values": [ + "controls", + "previews", + "interactions" + ] + }, + "interactionkind": { + "count": 1, + "values": "interval" + }, + "servicecall": { + "count": 28, + "values": [ + "events", + "workflow", + "project", + "diagnostics", + "dialogs", + "results" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 20, + "values": [ + "createSession.project", + "onRhsChosen.state, event, services", + "onProtocolChosen.state, event, services", + "onFolderChosen.state, event, services", + "onFolderRemoved.state, event, services", + "onFolderCleared.state, ~, services", + "onSettingChanged.state, event, services", + "onPreviewChannelEdited.state, event, services", + "onFileFilterEdited.state, event, ~", + "onRefreshPreviewWindow.state, ~, services", + "onRefreshFolderFiles.state, ~, services", + "onPreviewRoiEdited.state, event, ~", + "onPreviewWindowScrolled.state, event, services", + "onZoomToRoi.state, ~, services", + "onSaveProtocol.state, ~, services", + "onSaveFilterRecord.state, ~, services", + "onResetWorkflow.~, ~, services", + "createProject.", + "migrateProject.project, fromVersion", + "validateProject.project" + ] + } + } + }, + { + "id": "response-review-stats", + "command": "labkit_ResponseReviewStats_app", + "title": "ResponseReviewStats", + "family": "Neurophysiology", + "folder": "apps/neurophysiology/response_review_stats", + "fileCount": 17, + "categories": { + "layoutconstructor": { + "count": 31, + "values": [ + "workbench", + "tab", + "section", + "logPanel", + "filePanel", + "rangeField", + "field", + "group", + "action", + "resultTable", + "statusPanel", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 9, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 15, + "values": [ + "project", + "dialogs", + "workflow", + "diagnostics", + "results", + "events" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 12, + "values": [ + "createSession.project", + "onInputChosen.state, event, services", + "onOutputFolderChosen.state, ~, services", + "onOutputFolderCleared.state, ~, ~", + "onSettingChanged.state, ~, services", + "onPreviewModeChanged.state, event, ~", + "onLoadMetrics.state, ~, services", + "onExportMetrics.state, ~, services", + "onResetWorkflow.~, ~, services", + "createProject.", + "migrateProject.project, fromVersion", + "validateProject.project" + ] + } + } + }, + { + "id": "ttest-wizard", + "command": "labkit_TTestWizard_app", + "title": "TTestWizard", + "family": "Statistics", + "folder": "apps/statistics/ttest_wizard", + "fileCount": 24, + "categories": { + "layoutconstructor": { + "count": 47, + "values": [ + "workbench", + "tab", + "section", + "filePanel", + "field", + "action", + "statusPanel", + "resultTable", + "logPanel", + "previewArea", + "workspace" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 23, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 37, + "values": [ + "events", + "diagnostics", + "dialogs", + "workflow", + "project" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 19, + "values": [ + "createSession.project", + "onOpenSource.state, event, services", + "onClearSource.state, ~, services", + "onSheetChanged.state, event, services", + "onSourceSelectionChanged.state, event, services", + "onAnalysisSelectionChanged.state, event, services", + "onCaptureGroup.state, ~, services", + "onAssignRowsToGroup.state, ~, services", + "onDeleteSelectedRows.state, ~, services", + "onGroupsEdited.state, event, services", + "onClearGroups.state, ~, services", + "onTestSettingsChanged.state, ~, services", + "onRunComparisons.state, ~, services", + "onPlotChanged.state, ~, ~", + "onExportData.state, ~, services", + "onExportResult.state, ~, services", + "createProject.", + "migrateProject.project, fromVersion", + "validateProject.project" + ] + } + } + }, + { + "id": "vt-resistance", + "command": "labkit_VTResistance_app", + "title": "VTResistance", + "family": "Electrochem", + "folder": "apps/electrochem/vt_resistance", + "fileCount": 23, + "categories": { + "layoutconstructor": { + "count": 24, + "values": [ + "workbench", + "tab", + "section", + "logPanel", + "filePanel", + "group", + "action", + "field", + "resultTable", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 6, + "values": [ + "controls", + "previews" + ] + }, + "interactionkind": { + "count": 0, + "values": [] + }, + "servicecall": { + "count": 29, + "values": [ + "events", + "workflow", + "project", + "dialogs", + "results" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 9, + "values": [ + "createSession.project", + "onOpenFilesChosen.state, event, services", + "onRemoveSelected.state, event, services", + "onClearAll.state, ~, services", + "onFileSelectionChanged.state, event, services", + "onAnalysisChanged.state, ~, services", + "onExportResults.state, ~, services", + "createProject.", + "validateProject.project" + ] + } + } + }, + { + "id": "video-marker", + "command": "labkit_VideoMarker_app", + "title": "VideoMarker", + "family": "Image Measurement", + "folder": "apps/image_measurement/video_marker", + "fileCount": 48, + "categories": { + "layoutconstructor": { + "count": 69, + "values": [ + "workbench", + "tab", + "section", + "action", + "field", + "group", + "resultTable", + "filePanel", + "panner", + "logPanel", + "workspace", + "previewArea" + ] + }, + "definitionfield": { + "count": 15, + "values": [ + "Command", + "Id", + "Title", + "DisplayName", + "Family", + "AppVersion", + "Updated", + "Requirements", + "Project", + "CreateSession", + "Layout", + "Actions", + "Present", + "Renderers", + "DebugSample" + ] + }, + "presentationtransport": { + "count": 42, + "values": [ + "controls", + "previews", + "interactions" + ] + }, + "interactionkind": { + "count": 2, + "values": [ + "scaleBarReference", + "anchors" + ] + }, + "servicecall": { + "count": 62, + "values": [ + "workflow", + "events", + "diagnostics", + "dialogs", + "project", + "resources", + "results" + ] + }, + "eventmeta": { + "count": 0, + "values": [] + }, + "registryaccess": { + "count": 0, + "values": [] + }, + "callbacksignature": { + "count": 36, + "values": [ + "onUseSkeletonPreset.state, ~, services", + "onKeypointEdited.state, event, services", + "onKeypointSelected.state, event, services", + "onAddKeypoint.state, ~, services", + "onRemoveKeypoint.state, ~, services", + "onConnectionSelected.state, event, services", + "onConnectionEndpointChanged.state, event, ~", + "onAddConnection.state, ~, services", + "onConnectInOrder.state, ~, services", + "onRemoveConnection.state, ~, services", + "createSession.project", + "onShape.annotations, info", + "onOpenVideo.state, event, services", + "onFrameChanged.state, event, services", + "onPreviousFrame.state, ~, services", + "onNextFrame.state, ~, services", + "onPointsEdited.state, event, services", + "onUndoPoint.state, ~, services", + "onClearFramePoints.state, ~, services", + "onMeasureScaleReference.state, ~, services", + "onScaleReferenceEdited.state, event, ~", + "onScaleCalibrationChanged.state, event, ~", + "onScaleBarSettingChanged.state, ~, ~", + "onPlaceScaleBar.state, ~, services", + "onImportMarkerCsv.state, ~, services", + "onExportMarkerCsv.state, ~, services", + "onExportSettingChanged.state, ~, ~", + "onExportCoordinateCsv.state, ~, services", + "onSaveAutosave.state, ~, services", + "onNewSetup.state, ~, services", + "createProject.", + "migrateProject.project, fromVersion", + "importLegacyProject.legacy", + "createResume.session, ~", + "applyResume.session, resume, project", + "validateProject.project" + ] + } + } + } + ], + "matches": [ + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onExportSettingChanged.state, ~, ~", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 10, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onChooseOutputFolder.state, ~, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 15, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onExportCrops.state, ~, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 28, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/image_measurement/batch_crop/+batch_crop/createSession.m", + "line": 3, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onImagesChosen.state, event, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 31, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onClearImages.state, ~, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 63, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onRemoveImages.state, event, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 76, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onDuplicateImage.state, ~, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 105, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onImageSelectionChanged.state, event, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 129, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onPreviousImage.state, ~, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 144, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onNextImage.state, ~, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 152, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onCropGeometryChanged.state, ~, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 170, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onRotationChanged.state, event, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 183, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onPaddingChanged.state, event, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 199, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onCenterChanged.state, event, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 216, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onUseImageCenterXY.state, event, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 235, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onUseImageCenterX.state, event, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 239, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onUseImageCenterY.state, event, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 243, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onUseImageCenter.state, ~, services, mode", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 247, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onScaleSettingChanged.state, ~, ~", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 272, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onScaleCalibrationFieldChanged.state, event, ~", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 287, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onMeasureScaleReference.state, ~, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 306, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onScaleReferenceEdited.state, event, ~", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 317, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onScaleBarSettingChanged.state, ~, ~", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 340, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onPlaceScaleBar.state, ~, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 346, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "onCropCenterEdited.state, event, services", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 371, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/image_measurement/batch_crop/+batch_crop/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "migrateProject.project, fromVersion", + "source": "apps/image_measurement/batch_crop/+batch_crop/projectSpec.m", + "line": 39, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/image_measurement/batch_crop/+batch_crop/projectSpec.m", + "line": 92, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "Command", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "Id", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "Title", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "Family", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "Updated", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "Requirements", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "Project", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "Layout", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "Actions", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "Present", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "Renderers", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/image_measurement/batch_crop/+batch_crop/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "interaction-kind", + "value": "pointSlots", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 153, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "interaction-kind", + "value": "scaleBarReference", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 170, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 7, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 60, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 62, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 64, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 69, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 70, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 76, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 98, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 99, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 100, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 104, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 109, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 110, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 111, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 116, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 117, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 129, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 130, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 132, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 133, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 135, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 137, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 140, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 146, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 159, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 160, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 162, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 164, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 169, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 170, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 176, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 179, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 180, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 182, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 187, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 189, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 205, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 215, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m", + "line": 220, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 16, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 24, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 33, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 34, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 44, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "interactions", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 65, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "interactions", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 67, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "previews", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 93, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 97, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 98, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 100, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 102, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 104, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 105, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m", + "line": 108, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 16, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 43, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 53, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 54, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 64, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 74, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 75, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 85, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 89, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 113, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 123, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m", + "line": 129, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 33, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "project", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 43, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 50, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 58, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 73, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 78, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 100, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 124, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 130, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 194, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 211, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 230, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 268, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 387, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 439, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 440, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "batch-crop", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/batch_crop/+batch_crop/definitionActions.m", + "line": 587, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/createSession.m", + "line": 4, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "callback-signature", + "value": "onOpenFilesChosen.state, event, services", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 12, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "callback-signature", + "value": "onRemoveSelected.state, event, services", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 58, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "callback-signature", + "value": "onClearAll.state, ~, services", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 73, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "callback-signature", + "value": "onSelectionChanged.state, event, services", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 80, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "callback-signature", + "value": "onExportCSV.state, ~, services", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 85, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/projectSpec.m", + "line": 25, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "callback-signature", + "value": "migrateProject.project, fromVersion", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/projectSpec.m", + "line": 41, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "Command", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "Id", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "Title", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "Family", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "Updated", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "Requirements", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "Project", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "Layout", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "Actions", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "Present", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "Renderers", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 6, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 24, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "group", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "action", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "panner", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 52, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 56, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 60, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 76, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/presentWorkbench.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/presentWorkbench.m", + "line": 15, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/presentWorkbench.m", + "line": 16, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "presentation-transport", + "value": "previews", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/presentWorkbench.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "presentation-transport", + "value": "previews", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/presentWorkbench.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 13, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 15, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 43, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 52, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 59, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 69, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 82, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 87, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 93, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 97, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "results", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "results", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 117, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "chrono-overlay", + "category": "service-call", + "value": "project", + "source": "apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m", + "line": 159, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/electrochem/cic/+cic/createSession.m", + "line": 4, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "cic", + "category": "callback-signature", + "value": "onOpenFilesChosen.state, event, services", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 15, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "cic", + "category": "callback-signature", + "value": "onRemoveSelected.state, event, services", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 56, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "cic", + "category": "callback-signature", + "value": "onClearAll.state, ~, services", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 78, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "cic", + "category": "callback-signature", + "value": "onFileSelectionChanged.state, event, services", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 89, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "cic", + "category": "callback-signature", + "value": "onPresetChanged.state, ~, services", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 101, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "cic", + "category": "callback-signature", + "value": "onAnalysisChanged.state, ~, services", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 114, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "cic", + "category": "callback-signature", + "value": "onExportResults.state, ~, services", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 132, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "cic", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/electrochem/cic/+cic/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "cic", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/electrochem/cic/+cic/projectSpec.m", + "line": 41, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "Command", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "Id", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "Title", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "Family", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "Updated", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "Requirements", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "Project", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "Layout", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "Actions", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "Present", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "Renderers", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/electrochem/cic/+cic/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 52, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 53, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "group", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 64, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "action", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 65, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 72, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 84, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 90, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 97, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 102, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 117, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 118, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 122, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 126, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 133, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 143, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 158, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 159, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 167, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 168, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "panner", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 176, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 186, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 194, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m", + "line": 201, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/cic/+cic/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/cic/+cic/+userInterface/presentWorkbench.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/cic/+cic/+userInterface/presentWorkbench.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/cic/+cic/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "presentation-transport", + "value": "previews", + "source": "apps/electrochem/cic/+cic/+userInterface/presentWorkbench.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "presentation-transport", + "value": "previews", + "source": "apps/electrochem/cic/+cic/+userInterface/presentWorkbench.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/cic/+cic/+userInterface/presentWorkbench.m", + "line": 81, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 16, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 33, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "project", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 50, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 58, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "project", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 67, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 74, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "project", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 79, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 86, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 90, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 127, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 134, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 142, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 145, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 153, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "results", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 157, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "results", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 165, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 168, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 174, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 178, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 214, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 216, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 222, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 225, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 238, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "cic", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/cic/+cic/definitionActions.m", + "line": 240, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/electrochem/csc/+csc/createSession.m", + "line": 4, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "csc", + "category": "callback-signature", + "value": "onOpenFilesChosen.state, event, services", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 14, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "csc", + "category": "callback-signature", + "value": "onRemoveSelected.state, event, services", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 60, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "csc", + "category": "callback-signature", + "value": "onClearAll.state, ~, services", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 78, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "csc", + "category": "callback-signature", + "value": "onReloadSelected.state, ~, services", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 87, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "csc", + "category": "callback-signature", + "value": "onFileSelectionChanged.state, event, services", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 110, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "csc", + "category": "callback-signature", + "value": "onExportResults.state, ~, services", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 121, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "csc", + "category": "callback-signature", + "value": "onExportVoltageCurrent.state, ~, services", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 155, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "csc", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/electrochem/csc/+csc/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "csc", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/electrochem/csc/+csc/projectSpec.m", + "line": 38, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "Command", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "Id", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "Title", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "Family", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "Updated", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "Requirements", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "Project", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "Layout", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "Actions", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "Present", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "Renderers", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/electrochem/csc/+csc/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 5, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 13, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "group", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "action", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 44, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 55, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 70, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 72, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 75, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 78, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 88, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "group", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "action", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 93, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "action", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 96, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 102, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 111, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 118, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m", + "line": 124, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 10, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 24, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 50, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 67, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 96, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "previews", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 101, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "presentation-transport", + "value": "previews", + "source": "apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 15, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 55, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 62, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 74, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 84, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 91, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 99, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 100, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 111, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 124, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 127, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 131, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 141, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "results", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 145, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "results", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 148, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 151, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 158, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 162, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 165, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 174, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "results", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 180, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 192, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "results", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 209, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 268, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 270, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "csc", + "category": "service-call", + "value": "project", + "source": "apps/electrochem/csc/+csc/definitionActions.m", + "line": 292, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/image_measurement/curvature/+curvature/createSession.m", + "line": 3, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onOpenImage.state, event, services", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 24, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onToggleCurveEdit.state, ~, services", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 56, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onCurvePointsEdited.state, event, services", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 75, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onUndoCurvePoint.state, ~, services", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 83, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onClearCurve.state, ~, services", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 93, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onMeasureScaleReference.state, ~, services", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 99, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onScaleReferenceEdited.state, event, ~", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 119, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onScaleCalibrationChanged.state, event, ~", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 133, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onScaleBarSettingChanged.state, ~, ~", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 156, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onPlaceScaleBar.state, ~, services", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 162, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onFitSettingChanged.state, ~, ~", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 187, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onViewSettingChanged.state, ~, ~", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 193, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onFitCurvature.state, ~, services", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 198, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onMeasureCurveLength.state, ~, services", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 239, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onExportCsv.state, ~, services", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 272, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "onExportOverlay.state, ~, services", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 297, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/image_measurement/curvature/+curvature/projectSpec.m", + "line": 11, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/image_measurement/curvature/+curvature/projectSpec.m", + "line": 34, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "curvature", + "category": "callback-signature", + "value": "migrateProject.project, fromVersion", + "source": "apps/image_measurement/curvature/+curvature/projectSpec.m", + "line": 60, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "Command", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "Id", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "Title", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "Family", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "Updated", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "Requirements", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "Project", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "Layout", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "Actions", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "Present", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "Renderers", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/image_measurement/curvature/+curvature/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "curvature", + "category": "interaction-kind", + "value": "anchors", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 50, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "interaction-kind", + "value": "scaleBarReference", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 60, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 5, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 33, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 49, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 51, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 56, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 57, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 58, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 62, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 63, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 68, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 69, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 70, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 75, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 76, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 83, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 89, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 90, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 93, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 95, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 100, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 101, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 106, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 112, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 117, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 119, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 121, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 123, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 128, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 135, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 141, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 155, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m", + "line": 157, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 24, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 33, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 34, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "previews", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "interactions", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 49, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "interactions", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 59, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 84, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 88, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 90, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 94, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 95, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 96, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 97, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 99, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m", + "line": 100, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "project", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 53, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 58, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 72, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 79, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 90, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 96, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 101, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 116, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 165, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 178, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 182, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 183, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 201, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 214, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 223, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 224, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 234, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 242, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 253, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 260, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 261, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 267, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 276, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 281, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 284, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 294, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 299, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 303, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 306, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 315, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 341, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "curvature", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/curvature/+curvature/definitionActions.m", + "line": 351, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/dic/dic_postprocess/+dic_postprocess/createSession.m", + "line": 4, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "callback-signature", + "value": "onMatChosen.state, event, services", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 15, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "callback-signature", + "value": "onReferenceChosen.state, event, services", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 28, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "callback-signature", + "value": "onMaskChosen.state, event, services", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 43, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "callback-signature", + "value": "onGenerate.state, ~, services", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 56, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "callback-signature", + "value": "onOptionsChanged.state, ~, services", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 86, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "callback-signature", + "value": "onSaveOverlays.state, ~, services", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 104, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "callback-signature", + "value": "onExportSummary.state, ~, services", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 140, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/dic/dic_postprocess/+dic_postprocess/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/dic/dic_postprocess/+dic_postprocess/projectSpec.m", + "line": 35, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "Command", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "Id", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "Title", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "Family", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "Updated", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "Requirements", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "Project", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "Layout", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "Actions", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "Present", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "Renderers", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 6, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "tab", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "tab", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "section", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "tab", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "section", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "section", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 43, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 49, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 56, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 63, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "section", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 68, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "section", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 78, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "section", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 90, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 91, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 93, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 98, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 100, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "layout-constructor", + "value": "panner", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/presentWorkbench.m", + "line": 8, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/presentWorkbench.m", + "line": 10, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/presentWorkbench.m", + "line": 12, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/presentWorkbench.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/presentWorkbench.m", + "line": 15, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/presentWorkbench.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/presentWorkbench.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "presentation-transport", + "value": "previews", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "presentation-transport", + "value": "previews", + "source": "apps/dic/dic_postprocess/+dic_postprocess/+userInterface/presentWorkbench.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "project", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "project", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "project", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 50, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 53, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 62, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 68, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 76, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "diagnostics", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 79, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 80, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 82, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 91, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "diagnostics", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 98, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 99, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 111, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "results", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 128, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "results", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 130, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "results", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 134, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 136, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 143, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 149, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 151, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 154, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "results", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 160, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "results", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 163, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 165, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-postprocess", + "category": "service-call", + "value": "events", + "source": "apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m", + "line": 193, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/dic/dic_preprocess/+dic_preprocess/createSession.m", + "line": 4, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onReferenceChosen.state, event, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 37, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onMovingChosen.state, event, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 41, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onImageChosen.state, event, services, role", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 45, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onReferenceCleared.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 75, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onMovingCleared.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 79, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onImageCleared.state, services, role", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 83, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onPreviewChanged.state, ~, ~", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 95, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onStartPointMatching.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 99, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onPointPairsEdited.state, event, ~", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 117, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onApplyPointAlignment.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 141, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onCancelPointMatching.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 179, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onUndoPointPair.state, ~, ~", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 188, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onAutoAlign.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 206, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onStartCropRoi.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 243, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onCropRectMoved.state, event, ~", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 262, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onApplyCropRoi.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 273, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onCancelCropRoi.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 298, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onUndoEdit.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 306, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onResetToOriginals.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 327, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onSaveCurrentImages.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 345, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onStartMaskEdit.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 382, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onMaskPointsEdited.state, event, ~", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 403, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onBoundaryStyleChanged.state, ~, ~", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 410, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onPreviewMaskRoi.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 415, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onAddBoundaryToMask.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 433, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onSubtractBoundaryFromMask.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 437, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onUndoMaskAnchor.state, ~, ~", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 460, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onUndoMaskEdit.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 468, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onClearMaskBoundary.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 484, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onClearMaskCanvas.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 489, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "onSaveMask.state, ~, services", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 499, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m", + "line": 32, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "Command", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "Id", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "Title", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "Family", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "Updated", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "Requirements", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "Project", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "Layout", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "Actions", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "Present", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "Renderers", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "interaction-kind", + "value": "pairedAnchors", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 67, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "interaction-kind", + "value": "rectangle", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 78, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "interaction-kind", + "value": "anchors", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 88, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 10, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "tab", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "tab", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 33, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "section", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 34, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "section", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "tab", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 43, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "section", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 44, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "section", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 50, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 51, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 59, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "field", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 67, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "section", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 79, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "group", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 81, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 82, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 84, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 86, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 88, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 90, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "group", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 93, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 95, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "group", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 97, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 98, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 100, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 102, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "section", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 108, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "field", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 110, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "group", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 117, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 118, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 120, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "group", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 122, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 123, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 126, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "group", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 128, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 129, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 131, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 134, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "action", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 136, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 150, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m", + "line": 152, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 33, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 34, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 43, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "previews", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 60, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "previews", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 62, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "interactions", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 66, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "interactions", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "interactions", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 87, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 105, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 108, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 109, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 110, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 111, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "presentation-transport", + "value": "controls", + "source": "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m", + "line": 112, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "events", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "diagnostics", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 56, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 57, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "project", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 61, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 113, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 146, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "diagnostics", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 158, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 159, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 175, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 185, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "diagnostics", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 219, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 220, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 223, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 239, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 258, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 276, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 294, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 303, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 309, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 324, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 330, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 341, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 356, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 357, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 360, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "results", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 367, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "results", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 369, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "results", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 375, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 377, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 384, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 399, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 421, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 428, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 444, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 455, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 480, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 486, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 496, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 504, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 514, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 515, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 518, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "results", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 524, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "results", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 531, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "workflow", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 533, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "dic-preprocess", + "category": "service-call", + "value": "dialogs", + "source": "apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m", + "line": 584, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/wearable/ecg_print/+ecg_print/createSession.m", + "line": 3, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "callback-signature", + "value": "onRecordingChosen.state, event, services", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 16, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "callback-signature", + "value": "onPreviewHeader.state, ~, services", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 30, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "callback-signature", + "value": "onImportOptionChanged.state, ~, ~", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 43, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "callback-signature", + "value": "onRefreshImport.state, ~, services", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 51, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "callback-signature", + "value": "onChannelChanged.state, event, services", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 96, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "callback-signature", + "value": "onAnalyze.state, ~, services", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 118, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "callback-signature", + "value": "onExportSegments.state, ~, services", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 146, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "callback-signature", + "value": "onExportWaveform.state, ~, services", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 172, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/wearable/ecg_print/+ecg_print/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "callback-signature", + "value": "migrateProject.project, fromVersion", + "source": "apps/wearable/ecg_print/+ecg_print/projectSpec.m", + "line": 32, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/wearable/ecg_print/+ecg_print/projectSpec.m", + "line": 44, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "Command", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "Id", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "Title", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "Family", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "Updated", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "Requirements", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "Project", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "Layout", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "Actions", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "Present", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "Renderers", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/wearable/ecg_print/+ecg_print/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 6, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "tab", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "tab", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "section", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 33, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "tab", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "section", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "section", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "action", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 59, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "section", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 64, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "field", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 65, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "panner", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 68, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "field", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 74, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "field", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 80, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "field", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 85, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "field", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "panner", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 97, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "action", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "section", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 108, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "field", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 109, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "panner", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 115, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "panner", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 120, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "section", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 128, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "panner", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 130, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "panner", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 135, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "field", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 140, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "panner", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 146, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "panner", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 151, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "panner", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 156, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "panner", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 161, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "field", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 166, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "action", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 171, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "section", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 176, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "action", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 177, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "action", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 179, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 191, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m", + "line": 192, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "controls", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 12, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "controls", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 13, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "controls", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "controls", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 15, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "controls", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 16, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "controls", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "controls", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "controls", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "controls", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "controls", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "previews", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "previews", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "previews", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "presentation-transport", + "value": "previews", + "source": "apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "workflow", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "project", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "workflow", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "dialogs", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 59, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "workflow", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 80, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "diagnostics", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 84, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "workflow", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 88, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "dialogs", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 91, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "diagnostics", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 105, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "dialogs", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 106, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "workflow", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 115, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "dialogs", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 120, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "diagnostics", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 131, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "dialogs", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 132, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "workflow", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 133, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "workflow", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 139, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "dialogs", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 149, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "dialogs", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 155, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "workflow", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 158, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "workflow", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 168, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "dialogs", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 177, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "dialogs", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 183, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "workflow", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 186, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "workflow", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 194, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "results", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 216, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "results", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 226, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "events", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 281, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ecg-print", + "category": "service-call", + "value": "events", + "source": "apps/wearable/ecg_print/+ecg_print/definitionActions.m", + "line": 283, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/electrochem/eis/+eis/createSession.m", + "line": 4, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "eis", + "category": "callback-signature", + "value": "onOpenFilesChosen.state, event, services", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 12, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "eis", + "category": "callback-signature", + "value": "onRemoveSelected.state, event, services", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 56, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "eis", + "category": "callback-signature", + "value": "onClearAll.state, ~, services", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 72, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "eis", + "category": "callback-signature", + "value": "onSelectionChanged.state, event, services", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 80, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "eis", + "category": "callback-signature", + "value": "onExportCSV.state, ~, services", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 85, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "eis", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/electrochem/eis/+eis/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "eis", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/electrochem/eis/+eis/projectSpec.m", + "line": 27, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "Command", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "Id", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "Title", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "Family", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "Updated", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "Requirements", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "Project", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "Layout", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "Actions", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "Present", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "Renderers", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/electrochem/eis/+eis/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 8, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 33, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "group", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "action", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 49, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 54, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 55, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 60, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "panner", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 65, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "panner", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 70, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 75, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 79, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 83, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 87, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 91, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 108, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 109, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m", + "line": 115, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/eis/+eis/+userInterface/presentWorkbench.m", + "line": 7, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/eis/+eis/+userInterface/presentWorkbench.m", + "line": 16, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "presentation-transport", + "value": "previews", + "source": "apps/electrochem/eis/+eis/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 13, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 15, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 51, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 57, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 68, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 82, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 88, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 91, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 95, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "results", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "results", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 111, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 138, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 140, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 142, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "eis", + "category": "service-call", + "value": "project", + "source": "apps/electrochem/eis/+eis/definitionActions.m", + "line": 175, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/labkit_core/figure_studio/+figure_studio/createSession.m", + "line": 5, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onFiguresChosen.state, event, services", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 19, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onFiguresRemoved.state, event, services", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 49, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onClearFigures.state, ~, services", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 67, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onSelectionChanged.state, event, services", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 75, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onStyleChanged.state, ~, services", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 129, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onStyleParameterChanged.state, event, ~", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 149, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onChooseOutputFolder.state, ~, services", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 171, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onSaveFig.state, ~, services", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 183, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onExportPng.state, ~, services", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 210, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onExportJpg.state, ~, services", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 214, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onExportSvg.state, ~, services", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 218, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onQuickExport.state, services, format, mediaType", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 222, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onExportCurrent.state, ~, services", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 255, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onOff.tf", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 441, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/labkit_core/figure_studio/+figure_studio/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/labkit_core/figure_studio/+figure_studio/projectSpec.m", + "line": 34, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "callback-signature", + "value": "onOff.tf", + "source": "apps/labkit_core/figure_studio/+figure_studio/projectSpec.m", + "line": 48, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "Command", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "Id", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "Title", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "Family", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "Updated", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "Requirements", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "Project", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 14, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "Layout", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "Actions", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "Present", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "Renderers", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "Start", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/labkit_core/figure_studio/+figure_studio/definition.m", + "line": 21, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 4, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "tab", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "section", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "field", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "field", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "group", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "action", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "action", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "action", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 50, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "action", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 52, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "section", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 57, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "field", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 58, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "field", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "section", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 80, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "field", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 81, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "field", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 90, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "panner", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 99, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "tab", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 108, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "section", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 109, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "field", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 110, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "group", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 113, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "action", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "action", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 116, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "tab", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 121, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "section", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 122, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 123, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 128, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m", + "line": 130, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "presentation-transport", + "value": "controls", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/presentWorkbench.m", + "line": 8, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "presentation-transport", + "value": "controls", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/presentWorkbench.m", + "line": 10, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "presentation-transport", + "value": "controls", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/presentWorkbench.m", + "line": 11, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "presentation-transport", + "value": "controls", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/presentWorkbench.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "presentation-transport", + "value": "controls", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/presentWorkbench.m", + "line": 16, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "presentation-transport", + "value": "previews", + "source": "apps/labkit_core/figure_studio/+figure_studio/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "events", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "events", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "workflow", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "project", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "dialogs", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "workflow", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "events", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 51, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "workflow", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 72, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "events", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "workflow", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "workflow", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 145, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "dialogs", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 172, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "workflow", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 180, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "dialogs", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 185, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "dialogs", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 189, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "workflow", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 207, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "dialogs", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 224, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "dialogs", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 229, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "workflow", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 251, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "dialogs", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 257, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "results", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 269, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "workflow", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 275, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "results", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 287, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "results", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 289, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "results", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 314, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "results", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 317, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "diagnostics", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 457, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "dialogs", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 458, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "workflow", + "source": "apps/labkit_core/figure_studio/+figure_studio/definitionActions.m", + "line": 460, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "dialogs", + "source": "apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m", + "line": 7, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "request", + "source": "apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m", + "line": 10, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "request", + "source": "apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m", + "line": 11, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "workflow", + "source": "apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "previews", + "source": "apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "resources", + "source": "apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "debug", + "source": "apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "figure-studio", + "category": "service-call", + "value": "workflow", + "source": "apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/createSession.m", + "line": 3, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onFilesChosen.state, event, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 35, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onRemoveFiles.state, event, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 82, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onClearFiles.state, ~, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 100, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onSelectionChanged.state, event, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 110, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onPreviousImage.state, ~, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 120, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onNextImage.state, ~, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 126, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onAutoRange.state, ~, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 133, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onGroupRange.state, ~, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 144, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onPerImageRange.state, ~, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 165, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onRoundRange.state, ~, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 179, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onDisplaySettingChanged.state, event, ~", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 200, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onRangePresetChanged.state, event, ~", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 216, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onRangeChanged.state, event, ~", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 238, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onTemperaturePointSelected.state, event, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 262, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onTemperatureRegionSelected.state, event, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 279, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onExportSettingChanged.state, event, ~", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 299, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "onChooseOutputFolder.state, ~, services", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 307, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/projectSpec.m", + "line": 11, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/projectSpec.m", + "line": 28, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "Command", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "Id", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "Title", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "Family", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "Updated", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "Requirements", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "Project", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "Layout", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "Actions", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "Present", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "Renderers", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 21, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definition.m", + "line": 24, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "interaction-kind", + "value": "regionSelection", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 5, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 43, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 55, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 58, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 59, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 62, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 65, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 72, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 79, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 80, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 85, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 90, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 97, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 102, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 106, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 109, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 112, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 115, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 123, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 134, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 135, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 138, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 142, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 143, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 145, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 148, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 154, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 155, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 162, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 163, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 164, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 167, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 170, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 176, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m", + "line": 177, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 15, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 16, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 24, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 34, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "previews", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "previews", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "presentation-transport", + "value": "interactions", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "project", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 60, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 70, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 73, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 84, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 111, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 141, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 161, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 175, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 196, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 257, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 274, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 294, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 308, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 321, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 344, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 352, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 426, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 427, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 568, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 569, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 570, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 574, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "flir-thermal", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m", + "line": 581, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/image_measurement/focus_stack/+focus_stack/createSession.m", + "line": 3, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "onSourceFolderChosen.state, ~, services", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 17, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "onOpenFilesChosen.state, event, services", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 36, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "onRemoveImages.state, event, services", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 69, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "onClearImages.state, ~, services", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 88, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "onFusionPresetChanged.state, ~, services", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 100, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "onFusionOptionsChanged.state, ~, ~", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 110, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "onRunFocusStack.state, ~, services", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 115, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "onExportFused.state, ~, services", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 167, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "onExportFocusMap.state, ~, services", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 172, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "onExportSummary.state, ~, services", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 177, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/image_measurement/focus_stack/+focus_stack/projectSpec.m", + "line": 10, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/image_measurement/focus_stack/+focus_stack/projectSpec.m", + "line": 31, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "Command", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "Id", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "Title", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "Family", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "Updated", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "Requirements", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "Project", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "Layout", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "Actions", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "Present", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "Renderers", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/image_measurement/focus_stack/+focus_stack/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 12, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 34, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 51, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 52, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 55, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 65, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 70, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 83, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 89, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 95, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 101, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 108, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 111, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 120, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m", + "line": 121, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m", + "line": 13, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m", + "line": 15, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m", + "line": 16, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "presentation-transport", + "value": "previews", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "presentation-transport", + "value": "previews", + "source": "apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "project", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 51, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 62, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 66, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 78, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 90, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 118, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 131, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 151, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 155, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 185, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 190, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 193, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 201, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 210, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 219, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 299, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 304, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 305, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "focus-stack", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/focus_stack/+focus_stack/definitionActions.m", + "line": 306, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/gait/gait_analysis/+gait_analysis/createSession.m", + "line": 3, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "callback-signature", + "value": "onOpenPoseFile.state, event, services", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 16, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "callback-signature", + "value": "onOptionsChanged.state, ~, ~", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 46, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "callback-signature", + "value": "onRunAnalysis.state, ~, services", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 55, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "callback-signature", + "value": "onChooseOutputFolder.state, ~, services", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 91, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "callback-signature", + "value": "onExportResults.state, ~, services", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 104, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "callback-signature", + "value": "onStepSelected.state, event, ~", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 146, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "callback-signature", + "value": "onPreviousStep.state, ~, ~", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 154, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "callback-signature", + "value": "onNextStep.state, ~, ~", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 159, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/gait/gait_analysis/+gait_analysis/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "callback-signature", + "value": "migrateProject.project, fromVersion", + "source": "apps/gait/gait_analysis/+gait_analysis/projectSpec.m", + "line": 24, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/gait/gait_analysis/+gait_analysis/projectSpec.m", + "line": 72, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "Command", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "Id", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "Title", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "Family", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "Updated", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "Requirements", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "Project", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 14, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "Layout", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "Actions", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "Present", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "Renderers", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/gait/gait_analysis/+gait_analysis/definition.m", + "line": 21, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 4, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "tab", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "action", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "tab", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 49, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 52, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 55, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 58, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 64, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 65, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 68, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 74, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 82, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 83, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 86, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 91, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 96, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 101, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 104, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 110, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "tab", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 116, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 117, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 118, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 121, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "group", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 122, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "action", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 123, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "action", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 125, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 127, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 131, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "action", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 132, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 134, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "action", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 137, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "tab", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 142, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 143, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 144, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 149, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 150, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 7, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 8, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 9, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 11, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 12, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 13, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 24, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "previews", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "previews", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "presentation-transport", + "value": "previews", + "source": "apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "diagnostics", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "dialogs", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "project", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "dialogs", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 43, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "dialogs", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 58, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 68, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "diagnostics", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 75, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "dialogs", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 76, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 86, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "dialogs", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 96, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 101, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "dialogs", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "dialogs", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 113, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "diagnostics", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 124, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "dialogs", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 125, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "results", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 139, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 142, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "results", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 175, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "events", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 203, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "gait-analysis", + "category": "service-call", + "value": "events", + "source": "apps/gait/gait_analysis/+gait_analysis/definitionActions.m", + "line": 205, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/image_measurement/image_enhance/+image_enhance/createSession.m", + "line": 3, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onSourceImagesChosen.state, event, services", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 24, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onRemoveImages.state, event, services", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 51, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onClearImages.state, ~, services", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 77, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onImageSelectionChanged.state, event, services", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 91, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onBatchModeChanged.state, event, ~", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 105, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onPreviewModeChanged.state, event, ~", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 113, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onToolChanged.state, event, ~", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 120, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onToolSettingChanged.state, event, ~", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 135, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onSetWhiteRoi.state, ~, services", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 151, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onWhiteRoiEdited.state, event, ~", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 171, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onApplyTool.state, ~, services", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 187, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onUndoHistory.state, ~, services", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 205, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onResetHistory.state, ~, services", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 219, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onExportSettingChanged.state, event, ~", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 231, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onChooseOutputFolder.state, ~, services", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 239, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "onExportImages.state, ~, services", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 251, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/image_measurement/image_enhance/+image_enhance/projectSpec.m", + "line": 11, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/image_measurement/image_enhance/+image_enhance/projectSpec.m", + "line": 29, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "Command", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "Id", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "Title", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "Family", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "Updated", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "Requirements", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "Project", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "Layout", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "Actions", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "Present", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "Renderers", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/image_measurement/image_enhance/+image_enhance/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "interaction-kind", + "value": "rectangle", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 8, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 53, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 59, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 60, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 63, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 68, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 69, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 78, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 85, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 86, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 90, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 93, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 98, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 108, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 111, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 120, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 121, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 122, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 125, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 128, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 132, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 138, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 139, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 145, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m", + "line": 146, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 15, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 34, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "previews", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 44, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "presentation-transport", + "value": "interactions", + "source": "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/image_enhance/+image_enhance/+sourceFiles/ensureCurrentPreview.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_enhance/+image_enhance/+sourceFiles/ensureCurrentPreview.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_enhance/+image_enhance/+sourceFiles/ensureCurrentPreview.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 53, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 73, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 87, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 153, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 191, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 202, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 216, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 228, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 240, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 244, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 253, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 265, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 277, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 280, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 281, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 289, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "project", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 299, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 390, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-enhance", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/image_enhance/+image_enhance/definitionActions.m", + "line": 393, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/image_measurement/image_match/+image_match/createSession.m", + "line": 3, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onReferenceChosen.state, event, services", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 20, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onSourcesChosen.state, event, services", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 39, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onRemoveImages.state, event, services", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 70, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onClearImages.state, ~, services", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 93, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onSelectionChanged.state, event, services", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 107, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onPreviewModeChanged.state, event, ~", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 120, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onMatchSettingChanged.state, event, ~", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 127, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onApplyMatch.state, ~, services", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 143, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onUndoHistory.state, ~, services", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 158, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onResetHistory.state, ~, services", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 172, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onExportSettingChanged.state, event, ~", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 181, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onChooseOutputFolder.state, ~, services", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 189, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "onExportImages.state, ~, services", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 200, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/image_measurement/image_match/+image_match/projectSpec.m", + "line": 11, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "image-match", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/image_measurement/image_match/+image_match/projectSpec.m", + "line": 29, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "Command", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "Id", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "Title", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "Family", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "Updated", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "Requirements", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "Project", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "Layout", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "Actions", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "Present", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "Renderers", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/image_measurement/image_match/+image_match/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 8, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 43, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 50, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 61, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 67, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 68, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 76, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 79, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 85, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 86, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 94, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 95, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 109, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 110, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 115, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 116, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 117, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 120, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 123, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 127, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 133, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 134, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 140, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 141, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m", + "line": 149, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 10, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 11, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 13, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 15, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 16, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 24, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 33, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "presentation-transport", + "value": "previews", + "source": "apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m", + "line": 34, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "project", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "project", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 52, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 60, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 65, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 72, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 109, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 145, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 155, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 169, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 178, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 190, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 202, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 219, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 230, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 233, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 234, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 241, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 284, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 285, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 340, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "image-match", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/image_match/+image_match/definitionActions.m", + "line": 343, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/createSession.m", + "line": 3, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "onSessionChosen.state, event, services", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 17, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "onProtocolChosen.state, event, services", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 48, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "onOutputFolderChosen.state, ~, services", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 70, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "onOutputFolderCleared.state, ~, ~", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 84, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "onSettingChanged.state, ~, ~", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 89, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "onPreviewModeChanged.state, event, ~", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 103, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "onRunAnalysis.state, ~, services", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 110, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "onExportAnalysis.state, ~, services", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 137, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "onResetWorkflow.~, ~, services", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 173, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "migrateProject.project, fromVersion", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/projectSpec.m", + "line": 24, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/projectSpec.m", + "line": 38, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "Command", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "Id", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "Title", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "Family", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "Updated", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "Requirements", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "Project", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "Layout", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "Actions", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "Present", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "Renderers", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 6, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 55, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 57, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 66, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 67, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 72, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 78, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 84, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "field", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 85, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "group", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 88, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 89, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 91, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 96, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "group", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 97, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 98, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 102, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 108, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "group", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 109, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 110, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 117, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 118, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 125, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 126, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 131, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m", + "line": 132, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m", + "line": 16, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "presentation-transport", + "value": "previews", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "diagnostics", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "project", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "dialogs", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 44, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "project", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 54, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 66, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "dialogs", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 80, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "diagnostics", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 121, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 125, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 134, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "results", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 154, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "results", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 162, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 169, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "project", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 174, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 175, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "events", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 220, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "nerve-response-analysis", + "category": "service-call", + "value": "events", + "source": "apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m", + "line": 222, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/createSession.m", + "line": 3, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "callback-signature", + "value": "onInputChosen.state, event, services", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 16, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "callback-signature", + "value": "onOutputFolderChosen.state, ~, services", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 34, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "callback-signature", + "value": "onOutputFolderCleared.state, ~, ~", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 48, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "callback-signature", + "value": "onSettingChanged.state, ~, services", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 53, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "callback-signature", + "value": "onPreviewModeChanged.state, event, ~", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 65, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "callback-signature", + "value": "onLoadMetrics.state, ~, services", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 72, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "callback-signature", + "value": "onExportMetrics.state, ~, services", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 106, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "callback-signature", + "value": "onResetWorkflow.~, ~, services", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 141, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "callback-signature", + "value": "migrateProject.project, fromVersion", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/projectSpec.m", + "line": 24, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/projectSpec.m", + "line": 36, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "Command", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "Id", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "Title", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "Family", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "Updated", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "Requirements", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "Project", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 14, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "Layout", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "Actions", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "Present", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "Renderers", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definition.m", + "line": 21, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 6, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 33, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 34, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 50, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "rangeField", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 51, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "rangeField", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 56, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "field", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 61, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 67, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "field", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 68, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "group", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 72, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 74, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 79, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "group", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 80, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 81, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 85, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 91, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "group", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 93, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 100, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 101, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 108, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 109, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m", + "line": 115, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/presentWorkbench.m", + "line": 9, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/presentWorkbench.m", + "line": 10, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/presentWorkbench.m", + "line": 12, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/presentWorkbench.m", + "line": 13, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/presentWorkbench.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/presentWorkbench.m", + "line": 15, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/presentWorkbench.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "presentation-transport", + "value": "previews", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "project", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "dialogs", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "dialogs", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 44, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "diagnostics", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 87, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 93, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "results", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 123, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "results", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 131, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 137, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "project", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 142, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 143, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "events", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 148, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "response-review-stats", + "category": "service-call", + "value": "events", + "source": "apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m", + "line": 150, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/createSession.m", + "line": 3, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onRhsChosen.state, event, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 24, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onProtocolChosen.state, event, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 69, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onFolderChosen.state, event, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 91, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onFolderRemoved.state, event, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 117, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onFolderCleared.state, ~, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 139, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onSettingChanged.state, event, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 150, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onPreviewChannelEdited.state, event, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 164, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onFileFilterEdited.state, event, ~", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 179, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onRefreshPreviewWindow.state, ~, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 188, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onRefreshFolderFiles.state, ~, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 192, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onPreviewRoiEdited.state, event, ~", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 217, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onPreviewWindowScrolled.state, event, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 226, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onZoomToRoi.state, ~, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 253, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onSaveProtocol.state, ~, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 272, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onSaveFilterRecord.state, ~, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 296, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "onResetWorkflow.~, ~, services", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 318, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "migrateProject.project, fromVersion", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/projectSpec.m", + "line": 30, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/projectSpec.m", + "line": 43, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "Command", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "Id", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "Title", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "Family", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "Updated", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "Requirements", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "Project", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "Layout", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "Actions", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "Present", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "Renderers", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "interaction-kind", + "value": "interval", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 54, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 6, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 13, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 34, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "tab", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 57, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 58, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 72, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 81, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "field", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 82, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "panner", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 89, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "field", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 98, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "field", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 101, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "field", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 106, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 112, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 124, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "group", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 125, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 126, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 130, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 133, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 139, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "group", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 141, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 142, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 149, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "group", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 150, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 151, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "action", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 155, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 161, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 162, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 172, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 173, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "section", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 180, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 181, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 186, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m", + "line": 187, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 24, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 33, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 44, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "controls", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "previews", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 50, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "presentation-transport", + "value": "interactions", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m", + "line": 53, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "events", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "events", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "project", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 63, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "events", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 70, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "events", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 72, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "project", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 79, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 87, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "events", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "events", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 94, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "diagnostics", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "events", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 119, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 136, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 147, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "diagnostics", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 204, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 214, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "dialogs", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 278, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 293, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "dialogs", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 302, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 314, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "project", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 319, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 320, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "workflow", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 356, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "results", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 424, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "results", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 430, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "rhs-preview", + "category": "service-call", + "value": "project", + "source": "apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m", + "line": 455, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/createSession.m", + "line": 2, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onOpenSource.state, event, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 27, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onClearSource.state, ~, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 56, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onSheetChanged.state, event, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 69, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onSourceSelectionChanged.state, event, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 96, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onAnalysisSelectionChanged.state, event, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 113, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onCaptureGroup.state, ~, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 121, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onAssignRowsToGroup.state, ~, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 162, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onDeleteSelectedRows.state, ~, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 178, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onGroupsEdited.state, event, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 200, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onClearGroups.state, ~, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 226, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onTestSettingsChanged.state, ~, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 239, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onRunComparisons.state, ~, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 249, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onPlotChanged.state, ~, ~", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 272, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onExportData.state, ~, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 276, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "onExportResult.state, ~, services", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 302, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/projectSpec.m", + "line": 16, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "migrateProject.project, fromVersion", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/projectSpec.m", + "line": 53, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/projectSpec.m", + "line": 74, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "Command", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "Id", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "Title", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "Family", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 14, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "Updated", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "Requirements", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "Project", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "Layout", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "Actions", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 21, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "Present", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "Renderers", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 23, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definition.m", + "line": 25, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 10, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "tab", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "section", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 44, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "section", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 49, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "action", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 53, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 57, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "action", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 62, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "action", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 65, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "action", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 68, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "tab", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 76, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "section", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 78, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 84, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 91, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 95, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "action", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 98, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "section", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 102, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "statusPanel", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 106, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "section", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 111, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 112, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 115, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 120, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 125, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 129, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 134, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 138, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "tab", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 145, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "section", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 146, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "action", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 147, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "action", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 150, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 153, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "field", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 156, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "tab", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 162, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "section", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 163, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 164, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "tab", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 169, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 170, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 176, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "tab", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 184, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 185, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m", + "line": 189, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 21, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 25, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 34, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 50, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 51, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 52, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 54, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "controls", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 58, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "presentation-transport", + "value": "previews", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m", + "line": 62, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "events", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 28, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "events", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 30, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "diagnostics", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 40, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "workflow", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "project", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "workflow", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 52, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "project", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 57, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "workflow", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 65, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "diagnostics", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 84, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 85, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "workflow", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 93, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "events", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 97, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "events", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 125, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 133, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "workflow", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 157, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "workflow", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 173, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "workflow", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 196, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 203, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 211, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "workflow", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 220, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "workflow", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 232, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 243, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 252, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "workflow", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 262, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 267, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 279, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 283, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "diagnostics", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 293, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 294, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "workflow", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 298, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 305, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 309, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "diagnostics", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 319, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "dialogs", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 320, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "ttest-wizard", + "category": "service-call", + "value": "workflow", + "source": "apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m", + "line": 324, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onUseSkeletonPreset.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 21, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onKeypointEdited.state, event, services", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 39, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onKeypointSelected.state, event, services", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 61, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onAddKeypoint.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 66, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onRemoveKeypoint.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 80, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onConnectionSelected.state, event, services", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 112, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onConnectionEndpointChanged.state, event, ~", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 117, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onAddConnection.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 127, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onConnectInOrder.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 148, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onRemoveConnection.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 161, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/image_measurement/video_marker/+video_marker/createSession.m", + "line": 3, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onShape.annotations, info", + "source": "apps/image_measurement/video_marker/+video_marker/createSession.m", + "line": 47, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onOpenVideo.state, event, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 28, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onFrameChanged.state, event, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 77, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onPreviousFrame.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 85, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onNextFrame.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 89, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onPointsEdited.state, event, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 139, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onUndoPoint.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 167, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onClearFramePoints.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 178, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onMeasureScaleReference.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 202, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onScaleReferenceEdited.state, event, ~", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 213, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onScaleCalibrationChanged.state, event, ~", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 229, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onScaleBarSettingChanged.state, ~, ~", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 251, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onPlaceScaleBar.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 257, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onImportMarkerCsv.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 279, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onExportMarkerCsv.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 337, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onExportSettingChanged.state, ~, ~", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 368, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onExportCoordinateCsv.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 377, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onSaveAutosave.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 418, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "onNewSetup.state, ~, services", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 441, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/image_measurement/video_marker/+video_marker/projectSpec.m", + "line": 16, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "migrateProject.project, fromVersion", + "source": "apps/image_measurement/video_marker/+video_marker/projectSpec.m", + "line": 41, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "importLegacyProject.legacy", + "source": "apps/image_measurement/video_marker/+video_marker/projectSpec.m", + "line": 69, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "createResume.session, ~", + "source": "apps/image_measurement/video_marker/+video_marker/projectSpec.m", + "line": 110, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "applyResume.session, resume, project", + "source": "apps/image_measurement/video_marker/+video_marker/projectSpec.m", + "line": 115, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/image_measurement/video_marker/+video_marker/projectSpec.m", + "line": 131, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "Command", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "Id", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "Title", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "Family", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "Updated", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "Requirements", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "Project", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 14, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "Layout", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "Actions", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "Present", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "Renderers", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/image_measurement/video_marker/+video_marker/definition.m", + "line": 21, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "interaction-kind", + "value": "scaleBarReference", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "interaction-kind", + "value": "anchors", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 80, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 4, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 16, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 23, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 43, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 54, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 55, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 56, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 60, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 62, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 68, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 69, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 71, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 73, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 75, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 77, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 78, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 82, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 86, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 87, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 89, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 91, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 95, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 97, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 103, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 112, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 113, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 120, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 126, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 127, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 133, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 134, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 136, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 141, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 142, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 143, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 145, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 147, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 153, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 154, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 155, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 156, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 158, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 160, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 161, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 166, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 171, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 176, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 177, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 181, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 185, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 187, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 188, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 195, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "group", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 196, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 197, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 199, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "action", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 201, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "tab", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 206, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "section", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 207, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 208, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 213, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 214, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 229, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "panner", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 235, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "layout-constructor", + "value": "field", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m", + "line": 241, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 18, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 19, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 22, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 24, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 26, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 27, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 36, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 37, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 42, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 43, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 50, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 51, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 52, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 53, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 55, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 57, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "previews", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 66, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "interactions", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 70, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "interactions", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 79, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 107, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 109, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 111, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 112, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 133, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 137, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 139, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 141, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 143, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 144, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 145, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 146, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 148, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "presentation-transport", + "value": "controls", + "source": "apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m", + "line": 149, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 35, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 43, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 44, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 53, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 56, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 57, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 62, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 75, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 93, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 108, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 113, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 135, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 144, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 157, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m", + "line": 172, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 29, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "events", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 34, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 48, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "project", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 52, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 74, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 113, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 136, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 163, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 174, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 183, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 274, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 280, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 282, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 284, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 290, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "project", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 305, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 322, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 334, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 343, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 348, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 360, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 365, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 390, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 395, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 409, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 414, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 422, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "project", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 431, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "diagnostics", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 433, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 434, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 435, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 438, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 443, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 450, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "project", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 454, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 456, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 461, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "resources", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 464, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "project", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 465, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "workflow", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 466, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "resources", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 473, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "resources", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 495, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 520, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 527, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "results", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 536, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "video-marker", + "category": "service-call", + "value": "dialogs", + "source": "apps/image_measurement/video_marker/+video_marker/definitionActions.m", + "line": 548, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "callback-signature", + "value": "createSession.project", + "source": "apps/electrochem/vt_resistance/+vt_resistance/createSession.m", + "line": 4, + "confidence": "probable", + "semanticRole": "session-factory", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "callback-signature", + "value": "onOpenFilesChosen.state, event, services", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 14, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "callback-signature", + "value": "onRemoveSelected.state, event, services", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 55, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "callback-signature", + "value": "onClearAll.state, ~, services", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 77, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "callback-signature", + "value": "onFileSelectionChanged.state, event, services", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 88, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "callback-signature", + "value": "onAnalysisChanged.state, ~, services", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 100, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "callback-signature", + "value": "onExportResults.state, ~, services", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 119, + "confidence": "probable", + "semanticRole": "ui-command", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "callback-signature", + "value": "createProject.", + "source": "apps/electrochem/vt_resistance/+vt_resistance/projectSpec.m", + "line": 12, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "callback-signature", + "value": "validateProject.project", + "source": "apps/electrochem/vt_resistance/+vt_resistance/projectSpec.m", + "line": 34, + "confidence": "probable", + "semanticRole": "project-callback", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "Command", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 6, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "Id", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 7, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "Title", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 8, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "DisplayName", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 9, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "Family", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 10, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "AppVersion", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 11, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "Updated", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 12, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "Requirements", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 13, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "Project", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 15, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "CreateSession", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 16, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "Layout", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 17, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "Actions", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 18, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "Present", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 19, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "Renderers", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 20, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "definition-field", + "value": "DebugSample", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definition.m", + "line": 22, + "confidence": "exact", + "semanticRole": "metadata", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "workbench", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 13, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 31, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 39, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "tab", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 45, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 46, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "logPanel", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 47, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 51, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "filePanel", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 52, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "group", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 63, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "action", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 64, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 70, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 80, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 81, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 85, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 92, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 102, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "section", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 119, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "resultTable", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 120, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "workspace", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 128, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "previewArea", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 129, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 137, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 147, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 155, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "layout-constructor", + "value": "field", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m", + "line": 162, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/presentWorkbench.m", + "line": 12, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/presentWorkbench.m", + "line": 13, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/presentWorkbench.m", + "line": 14, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "presentation-transport", + "value": "previews", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/presentWorkbench.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "presentation-transport", + "value": "previews", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/presentWorkbench.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "presentation-transport", + "value": "controls", + "source": "apps/electrochem/vt_resistance/+vt_resistance/+userInterface/presentWorkbench.m", + "line": 105, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 15, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 17, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 20, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 32, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 38, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "project", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 41, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 49, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 57, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "project", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 66, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 73, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "project", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 78, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 85, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "events", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 89, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 114, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 121, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 130, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 133, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 139, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "results", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 143, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "results", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 151, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 154, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 160, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 165, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 202, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 204, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 210, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 213, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "workflow", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 227, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + }, + { + "appId": "vt-resistance", + "category": "service-call", + "value": "dialogs", + "source": "apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m", + "line": 229, + "confidence": "exact", + "semanticRole": "ui-boundary", + "reviewed": true + } + ], + "callPatternClassifications": [ + { + "category": "callback-signature", + "value": "applyResume.session, resume, project", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain as a typed ProjectContract callback.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "createProject.", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain as a typed ProjectContract callback.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "createResume.session, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain as a typed ProjectContract callback.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "createSession.project", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain as the declared Application session factory.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "importLegacyProject.legacy", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain as a typed ProjectContract callback.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "migrateProject.project, fromVersion", + "appCount": 10, + "occurrenceCount": 10, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain as a typed ProjectContract callback.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onAddBoundaryToMask.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onAddConnection.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onAddKeypoint.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onAnalysisChanged.state, ~, services", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onAnalysisSelectionChanged.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onAnalyze.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onApplyCropRoi.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onApplyMatch.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onApplyPointAlignment.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onApplyTool.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onAssignRowsToGroup.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onAutoAlign.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onAutoRange.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onBatchModeChanged.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onBoundaryStyleChanged.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onCancelCropRoi.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onCancelPointMatching.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onCaptureGroup.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onCenterChanged.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onChannelChanged.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onChooseOutputFolder.state, ~, services", + "appCount": 6, + "occurrenceCount": 6, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onClearAll.state, ~, services", + "appCount": 5, + "occurrenceCount": 5, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onClearCurve.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onClearFigures.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onClearFiles.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onClearFramePoints.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onClearGroups.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onClearImages.state, ~, services", + "appCount": 4, + "occurrenceCount": 4, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onClearMaskBoundary.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onClearMaskCanvas.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onClearSource.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onConnectInOrder.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onConnectionEndpointChanged.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onConnectionSelected.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onCropCenterEdited.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onCropGeometryChanged.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onCropRectMoved.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onCurvePointsEdited.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onDeleteSelectedRows.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onDisplaySettingChanged.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onDuplicateImage.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportAnalysis.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportCSV.state, ~, services", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportCoordinateCsv.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportCrops.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportCsv.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportCurrent.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportData.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportFocusMap.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportFused.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportImages.state, ~, services", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportJpg.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportMarkerCsv.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportMetrics.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportOverlay.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportPng.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportResult.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportResults.state, ~, services", + "appCount": 4, + "occurrenceCount": 4, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportSegments.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportSettingChanged.state, event, ~", + "appCount": 3, + "occurrenceCount": 3, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportSettingChanged.state, ~, ~", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportSummary.state, ~, services", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportSvg.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportVoltageCurrent.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onExportWaveform.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFiguresChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFiguresRemoved.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFileFilterEdited.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFileSelectionChanged.state, event, services", + "appCount": 3, + "occurrenceCount": 3, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFilesChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFitCurvature.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFitSettingChanged.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFolderChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFolderCleared.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFolderRemoved.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFrameChanged.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFusionOptionsChanged.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onFusionPresetChanged.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onGenerate.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onGroupRange.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onGroupsEdited.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onImageChosen.state, event, services, role", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onImageCleared.state, services, role", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onImageSelectionChanged.state, event, services", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onImagesChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onImportMarkerCsv.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onImportOptionChanged.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onInputChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onKeypointEdited.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onKeypointSelected.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onLoadMetrics.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onMaskChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onMaskPointsEdited.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onMatChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onMatchSettingChanged.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onMeasureCurveLength.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onMeasureScaleReference.state, ~, services", + "appCount": 3, + "occurrenceCount": 3, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onMovingChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onMovingCleared.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onNewSetup.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onNextFrame.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onNextImage.state, ~, services", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onNextStep.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onOff.tf", + "appCount": 1, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onOpenFilesChosen.state, event, services", + "appCount": 6, + "occurrenceCount": 6, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onOpenImage.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onOpenPoseFile.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onOpenSource.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onOpenVideo.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onOptionsChanged.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onOptionsChanged.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onOutputFolderChosen.state, ~, services", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onOutputFolderCleared.state, ~, ~", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPaddingChanged.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPerImageRange.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPlaceScaleBar.state, ~, services", + "appCount": 3, + "occurrenceCount": 3, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPlotChanged.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPointPairsEdited.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPointsEdited.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPresetChanged.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPreviewChanged.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPreviewChannelEdited.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPreviewHeader.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPreviewMaskRoi.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPreviewModeChanged.state, event, ~", + "appCount": 4, + "occurrenceCount": 4, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPreviewRoiEdited.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPreviewWindowScrolled.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPreviousFrame.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPreviousImage.state, ~, services", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onPreviousStep.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onProtocolChosen.state, event, services", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onQuickExport.state, services, format, mediaType", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRangeChanged.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRangePresetChanged.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRecordingChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onReferenceChosen.state, event, services", + "appCount": 3, + "occurrenceCount": 3, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onReferenceCleared.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRefreshFolderFiles.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRefreshImport.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRefreshPreviewWindow.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onReloadSelected.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRemoveConnection.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRemoveFiles.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRemoveImages.state, event, services", + "appCount": 4, + "occurrenceCount": 4, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRemoveKeypoint.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRemoveSelected.state, event, services", + "appCount": 5, + "occurrenceCount": 5, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onResetHistory.state, ~, services", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onResetToOriginals.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onResetWorkflow.~, ~, services", + "appCount": 3, + "occurrenceCount": 3, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRhsChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRotationChanged.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRoundRange.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRunAnalysis.state, ~, services", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRunComparisons.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onRunFocusStack.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSaveAutosave.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSaveCurrentImages.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSaveFig.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSaveFilterRecord.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSaveMask.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSaveOverlays.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSaveProtocol.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onScaleBarSettingChanged.state, ~, ~", + "appCount": 3, + "occurrenceCount": 3, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onScaleCalibrationChanged.state, event, ~", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onScaleCalibrationFieldChanged.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onScaleReferenceEdited.state, event, ~", + "appCount": 3, + "occurrenceCount": 3, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onScaleSettingChanged.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSelectionChanged.state, event, services", + "appCount": 5, + "occurrenceCount": 5, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSessionChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSetWhiteRoi.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSettingChanged.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSettingChanged.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSettingChanged.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onShape.annotations, info", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSheetChanged.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSourceFolderChosen.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSourceImagesChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSourceSelectionChanged.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSourcesChosen.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onStartCropRoi.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onStartMaskEdit.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onStartPointMatching.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onStepSelected.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onStyleChanged.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onStyleParameterChanged.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onSubtractBoundaryFromMask.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onTemperaturePointSelected.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onTemperatureRegionSelected.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onTestSettingsChanged.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onToggleCurveEdit.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onToolChanged.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onToolSettingChanged.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onUndoCurvePoint.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onUndoEdit.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onUndoHistory.state, ~, services", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onUndoMaskAnchor.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onUndoMaskEdit.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onUndoPoint.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onUndoPointPair.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onUseImageCenter.state, ~, services, mode", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onUseImageCenterX.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onUseImageCenterXY.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onUseImageCenterY.state, event, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onUseSkeletonPreset.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onViewSettingChanged.state, ~, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onWhiteRoiEdited.state, event, ~", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "onZoomToRoi.state, ~, services", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Declare and validate the UI command role before launch.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "callback-signature", + "value": "validateProject.project", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain as a typed ProjectContract callback.", + "testStrategy": "callback-role compilation test" + }, + { + "category": "definition-field", + "value": "Actions", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "AppVersion", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "Command", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "CreateSession", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "DebugSample", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "DisplayName", + "appCount": 20, + "occurrenceCount": 20, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "Family", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "Id", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "Layout", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "Present", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "Project", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "Renderers", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "Requirements", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "Start", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "Title", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "definition-field", + "value": "Updated", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace the open definition struct field with an explicit product contract.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "interaction-kind", + "value": "anchors", + "appCount": 3, + "occurrenceCount": 3, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace string Kind dispatch and Options bags with named interactions.", + "testStrategy": "interaction constructor, lifecycle, and GUI gesture test" + }, + { + "category": "interaction-kind", + "value": "interval", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace string Kind dispatch and Options bags with named interactions.", + "testStrategy": "interaction constructor, lifecycle, and GUI gesture test" + }, + { + "category": "interaction-kind", + "value": "pairedAnchors", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace string Kind dispatch and Options bags with named interactions.", + "testStrategy": "interaction constructor, lifecycle, and GUI gesture test" + }, + { + "category": "interaction-kind", + "value": "pointSlots", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace string Kind dispatch and Options bags with named interactions.", + "testStrategy": "interaction constructor, lifecycle, and GUI gesture test" + }, + { + "category": "interaction-kind", + "value": "rectangle", + "appCount": 2, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace string Kind dispatch and Options bags with named interactions.", + "testStrategy": "interaction constructor, lifecycle, and GUI gesture test" + }, + { + "category": "interaction-kind", + "value": "regionSelection", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace string Kind dispatch and Options bags with named interactions.", + "testStrategy": "interaction constructor, lifecycle, and GUI gesture test" + }, + { + "category": "interaction-kind", + "value": "scaleBarReference", + "appCount": 3, + "occurrenceCount": 3, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace string Kind dispatch and Options bags with named interactions.", + "testStrategy": "interaction constructor, lifecycle, and GUI gesture test" + }, + { + "category": "layout-constructor", + "value": "action", + "appCount": 21, + "occurrenceCount": 136, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "field", + "appCount": 20, + "occurrenceCount": 155, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "filePanel", + "appCount": 21, + "occurrenceCount": 28, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "group", + "appCount": 17, + "occurrenceCount": 46, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "logPanel", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "panner", + "appCount": 14, + "occurrenceCount": 34, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "previewArea", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "rangeField", + "appCount": 1, + "occurrenceCount": 2, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "resultTable", + "appCount": 17, + "occurrenceCount": 26, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "section", + "appCount": 21, + "occurrenceCount": 147, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "statusPanel", + "appCount": 14, + "occurrenceCount": 17, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "tab", + "appCount": 21, + "occurrenceCount": 73, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "workbench", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "layout-constructor", + "value": "workspace", + "appCount": 21, + "occurrenceCount": 21, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the semantic UI concept through a strict explicit constructor.", + "testStrategy": "strict constructor/compiler contract test" + }, + { + "category": "presentation-transport", + "value": "controls", + "appCount": 21, + "occurrenceCount": 314, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace raw view transport structs with target-checked operations.", + "testStrategy": "GUI-free presentation compilation and transaction test" + }, + { + "category": "presentation-transport", + "value": "interactions", + "appCount": 7, + "occurrenceCount": 12, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace raw view transport structs with target-checked operations.", + "testStrategy": "GUI-free presentation compilation and transaction test" + }, + { + "category": "presentation-transport", + "value": "previews", + "appCount": 21, + "occurrenceCount": 34, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Replace raw view transport structs with target-checked operations.", + "testStrategy": "GUI-free presentation compilation and transaction test" + }, + { + "category": "service-call", + "value": "debug", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "remove", + "owner": "labkit.ui", + "rationale": "Do not expose concrete UI, debug, or launch-request internals.", + "testStrategy": "runtime-context interface and test-double parity test" + }, + { + "category": "service-call", + "value": "diagnostics", + "appCount": 16, + "occurrenceCount": 44, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the capability behind one documented runtime context.", + "testStrategy": "runtime-context interface and test-double parity test" + }, + { + "category": "service-call", + "value": "dialogs", + "appCount": 21, + "occurrenceCount": 155, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the capability behind one documented runtime context.", + "testStrategy": "runtime-context interface and test-double parity test" + }, + { + "category": "service-call", + "value": "events", + "appCount": 21, + "occurrenceCount": 72, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the capability behind one documented runtime context.", + "testStrategy": "runtime-context interface and test-double parity test" + }, + { + "category": "service-call", + "value": "previews", + "appCount": 1, + "occurrenceCount": 1, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the capability behind one documented runtime context.", + "testStrategy": "runtime-context interface and test-double parity test" + }, + { + "category": "service-call", + "value": "project", + "appCount": 21, + "occurrenceCount": 39, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the capability behind one documented runtime context.", + "testStrategy": "runtime-context interface and test-double parity test" + }, + { + "category": "service-call", + "value": "request", + "appCount": 1, + "occurrenceCount": 2, + "disposition": "remove", + "owner": "labkit.ui", + "rationale": "Do not expose concrete UI, debug, or launch-request internals.", + "testStrategy": "runtime-context interface and test-double parity test" + }, + { + "category": "service-call", + "value": "resources", + "appCount": 2, + "occurrenceCount": 4, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the capability behind one documented runtime context.", + "testStrategy": "runtime-context interface and test-double parity test" + }, + { + "category": "service-call", + "value": "results", + "appCount": 20, + "occurrenceCount": 56, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the capability behind one documented runtime context.", + "testStrategy": "runtime-context interface and test-double parity test" + }, + { + "category": "service-call", + "value": "workflow", + "appCount": 21, + "occurrenceCount": 272, + "disposition": "replace", + "owner": "labkit.ui", + "rationale": "Retain the capability behind one documented runtime context.", + "testStrategy": "runtime-context interface and test-double parity test" + } + ], + "frameworkMatches": [ + { + "category": "callback-arity-probe", + "value": "nargin", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 443 + }, + { + "category": "callback-arity-probe", + "value": "nargin", + "source": "+labkit/+ui/+runtime/private/createV2State.m", + "line": 20 + }, + { + "category": "callback-arity-probe", + "value": "nargout", + "source": "+labkit/+ui/+runtime/private/validateV2State.m", + "line": 39 + }, + { + "category": "control-presentation-field", + "value": "Axes", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 69 + }, + { + "category": "control-presentation-field", + "value": "Axes", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 336 + }, + { + "category": "control-presentation-field", + "value": "Axis", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 404 + }, + { + "category": "control-presentation-field", + "value": "Enabled", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 196 + }, + { + "category": "control-presentation-field", + "value": "Files", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 176 + }, + { + "category": "control-presentation-field", + "value": "Items", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 140 + }, + { + "category": "control-presentation-field", + "value": "Items", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 188 + }, + { + "category": "control-presentation-field", + "value": "Limits", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 144 + }, + { + "category": "control-presentation-field", + "value": "Limits", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 192 + }, + { + "category": "control-presentation-field", + "value": "Model", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 95 + }, + { + "category": "control-presentation-field", + "value": "Model", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 393 + }, + { + "category": "control-presentation-field", + "value": "Renderer", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 94 + }, + { + "category": "control-presentation-field", + "value": "Renderer", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 392 + }, + { + "category": "control-presentation-field", + "value": "Selection", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 180 + }, + { + "category": "control-presentation-field", + "value": "Status", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 184 + }, + { + "category": "control-presentation-field", + "value": "Text", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 204 + }, + { + "category": "control-presentation-field", + "value": "Visible", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 200 + }, + { + "category": "definition-field", + "value": "actions", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 35 + }, + { + "category": "definition-field", + "value": "actions", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 47 + }, + { + "category": "definition-field", + "value": "createSession", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 27 + }, + { + "category": "definition-field", + "value": "createSession", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 27 + }, + { + "category": "definition-field", + "value": "debugSample", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 49 + }, + { + "category": "definition-field", + "value": "debugSample", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 49 + }, + { + "category": "definition-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 22 + }, + { + "category": "definition-field", + "value": "layout", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 31 + }, + { + "category": "definition-field", + "value": "present", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 36 + }, + { + "category": "definition-field", + "value": "product", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 24 + }, + { + "category": "definition-field", + "value": "project", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 26 + }, + { + "category": "definition-field", + "value": "renderers", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 40 + }, + { + "category": "definition-field", + "value": "requirements", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 25 + }, + { + "category": "definition-field", + "value": "start", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 41 + }, + { + "category": "definition-field", + "value": "start", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 41 + }, + { + "category": "definition-field", + "value": "start", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 42 + }, + { + "category": "definition-field", + "value": "start", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 42 + }, + { + "category": "definition-field", + "value": "start", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 42 + }, + { + "category": "definition-field", + "value": "start", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 46 + }, + { + "category": "definition-field", + "value": "start", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 46 + }, + { + "category": "definition-field", + "value": "start", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 47 + }, + { + "category": "definition-field", + "value": "title", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 23 + }, + { + "category": "definition-field", + "value": "type", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 18 + }, + { + "category": "definition-field", + "value": "type", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 20 + }, + { + "category": "definition-field", + "value": "utilities", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 53 + }, + { + "category": "event-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/semanticEvent.m", + "line": 7 + }, + { + "category": "event-field", + "value": "kind", + "source": "+labkit/+ui/+runtime/private/semanticEvent.m", + "line": 8 + }, + { + "category": "event-field", + "value": "previousValue", + "source": "+labkit/+ui/+runtime/private/semanticEvent.m", + "line": 11 + }, + { + "category": "event-field", + "value": "rawEvent", + "source": "+labkit/+ui/+runtime/private/semanticEvent.m", + "line": 13 + }, + { + "category": "event-field", + "value": "source", + "source": "+labkit/+ui/+runtime/private/semanticEvent.m", + "line": 9 + }, + { + "category": "event-field", + "value": "ui", + "source": "+labkit/+ui/+runtime/private/semanticEvent.m", + "line": 12 + }, + { + "category": "event-field", + "value": "value", + "source": "+labkit/+ui/+runtime/private/semanticEvent.m", + "line": 10 + }, + { + "category": "interaction-field", + "value": "BackgroundEvent", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 164 + }, + { + "category": "interaction-field", + "value": "BackgroundEvent", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 167 + }, + { + "category": "interaction-field", + "value": "BackgroundEvent", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 337 + }, + { + "category": "interaction-field", + "value": "BackgroundEvent", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 359 + }, + { + "category": "interaction-field", + "value": "BackgroundEvent", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 363 + }, + { + "category": "interaction-field", + "value": "BackgroundEvent", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 543 + }, + { + "category": "interaction-field", + "value": "ChangePolicy", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 148 + }, + { + "category": "interaction-field", + "value": "ChangePolicy", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 160 + }, + { + "category": "interaction-field", + "value": "ChangePolicy", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 340 + }, + { + "category": "interaction-field", + "value": "Event", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 148 + }, + { + "category": "interaction-field", + "value": "Event", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 160 + }, + { + "category": "interaction-field", + "value": "Event", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 336 + }, + { + "category": "interaction-field", + "value": "Event", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 336 + }, + { + "category": "interaction-field", + "value": "Event", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 355 + }, + { + "category": "interaction-field", + "value": "Event", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 355 + }, + { + "category": "interaction-field", + "value": "Event", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 363 + }, + { + "category": "interaction-field", + "value": "ImageSize", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 58 + }, + { + "category": "interaction-field", + "value": "ImageSize", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 67 + }, + { + "category": "interaction-field", + "value": "ImageSize", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 73 + }, + { + "category": "interaction-field", + "value": "ImageSize", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 91 + }, + { + "category": "interaction-field", + "value": "ImageSize", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 234 + }, + { + "category": "interaction-field", + "value": "ImageSize", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 342 + }, + { + "category": "interaction-field", + "value": "ImageSize", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 342 + }, + { + "category": "interaction-field", + "value": "Instruction", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 344 + }, + { + "category": "interaction-field", + "value": "Instruction", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 344 + }, + { + "category": "interaction-field", + "value": "Instruction", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 351 + }, + { + "category": "interaction-field", + "value": "Instruction", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 399 + }, + { + "category": "interaction-field", + "value": "Kind", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 50 + }, + { + "category": "interaction-field", + "value": "Kind", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 97 + }, + { + "category": "interaction-field", + "value": "Kind", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 333 + }, + { + "category": "interaction-field", + "value": "Kind", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 333 + }, + { + "category": "interaction-field", + "value": "Kind", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 346 + }, + { + "category": "interaction-field", + "value": "Kind", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 346 + }, + { + "category": "interaction-field", + "value": "Kind", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 377 + }, + { + "category": "interaction-field", + "value": "Kind", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 380 + }, + { + "category": "interaction-field", + "value": "Kind", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 403 + }, + { + "category": "interaction-field", + "value": "Kind", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 532 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 92 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 235 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 236 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 237 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 238 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 343 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 343 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 404 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 421 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 422 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 423 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 530 + }, + { + "category": "interaction-field", + "value": "Options", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 541 + }, + { + "category": "interaction-field", + "value": "ScrollEvent", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 172 + }, + { + "category": "interaction-field", + "value": "ScrollEvent", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 173 + }, + { + "category": "interaction-field", + "value": "ScrollEvent", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 339 + }, + { + "category": "interaction-field", + "value": "ScrollEvent", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 339 + }, + { + "category": "interaction-field", + "value": "ScrollEvent", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 359 + }, + { + "category": "interaction-field", + "value": "ScrollEvent", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 363 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 57 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 62 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 63 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 66 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 73 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 79 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 85 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 91 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 115 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 168 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 199 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 200 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 216 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 217 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 334 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 334 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 345 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 345 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 347 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 347 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 371 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 377 + }, + { + "category": "interaction-field", + "value": "Targets", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 380 + }, + { + "category": "interaction-field", + "value": "Value", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 44 + }, + { + "category": "interaction-field", + "value": "Value", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 74 + }, + { + "category": "interaction-field", + "value": "Value", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 335 + }, + { + "category": "interaction-field", + "value": "Value", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 335 + }, + { + "category": "interaction-field", + "value": "Value", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 420 + }, + { + "category": "interaction-field", + "value": "color", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 235 + }, + { + "category": "interaction-field", + "value": "color", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 421 + }, + { + "category": "interaction-field", + "value": "faceAlpha", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 423 + }, + { + "category": "interaction-field", + "value": "faceColor", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 422 + }, + { + "category": "interaction-field", + "value": "lineStyle", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 237 + }, + { + "category": "interaction-field", + "value": "lineWidth", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 236 + }, + { + "category": "interaction-field", + "value": "mode", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 404 + }, + { + "category": "interaction-field", + "value": "pointThreshold", + "source": "+labkit/+ui/+runtime/private/reconcileV2Interactions.m", + "line": 238 + }, + { + "category": "layout-binding-field", + "value": "Bind", + "source": "+labkit/+ui/+runtime/private/prepareV2Layout.m", + "line": 110 + }, + { + "category": "layout-binding-field", + "value": "Event", + "source": "+labkit/+ui/+runtime/private/prepareV2Layout.m", + "line": 111 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 36 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 37 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 84 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 88 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 90 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 95 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 96 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 107 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 116 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 124 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 148 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 149 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 169 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 170 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 190 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 191 + }, + { + "category": "layout-field", + "value": "children", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 191 + }, + { + "category": "layout-field", + "value": "controlTabs", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 7 + }, + { + "category": "layout-field", + "value": "controlTabs", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 30 + }, + { + "category": "layout-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 29 + }, + { + "category": "layout-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 93 + }, + { + "category": "layout-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 109 + }, + { + "category": "layout-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 119 + }, + { + "category": "layout-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 127 + }, + { + "category": "layout-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 137 + }, + { + "category": "layout-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 142 + }, + { + "category": "layout-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 163 + }, + { + "category": "layout-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 173 + }, + { + "category": "layout-field", + "value": "kind", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 30 + }, + { + "category": "layout-field", + "value": "kind", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 57 + }, + { + "category": "layout-field", + "value": "kind", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 181 + }, + { + "category": "layout-field", + "value": "kind", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 183 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 7 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 8 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 8 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 12 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 13 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 13 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 18 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 30 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 31 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 32 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 34 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 59 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 60 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 61 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 63 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 132 + }, + { + "category": "layout-field", + "value": "props", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 158 + }, + { + "category": "layout-field", + "value": "workspace", + "source": "+labkit/+ui/+runtime/private/validateWorkbenchLayout.m", + "line": 12 + }, + { + "category": "presentation-root", + "value": "controls", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 18 + }, + { + "category": "presentation-root", + "value": "controls", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 22 + }, + { + "category": "presentation-root", + "value": "controls", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 42 + }, + { + "category": "presentation-root", + "value": "interactions", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 34 + }, + { + "category": "presentation-root", + "value": "previews", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 26 + }, + { + "category": "presentation-root", + "value": "previews", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 56 + }, + { + "category": "preview-field", + "value": "Axes", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 69 + }, + { + "category": "preview-field", + "value": "Axes", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 336 + }, + { + "category": "preview-field", + "value": "Model", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 95 + }, + { + "category": "preview-field", + "value": "Model", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 393 + }, + { + "category": "preview-field", + "value": "Renderer", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 94 + }, + { + "category": "preview-field", + "value": "Renderer", + "source": "+labkit/+ui/+runtime/private/commitV2Presentation.m", + "line": 392 + }, + { + "category": "project-field", + "value": "ApplyResume", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 155 + }, + { + "category": "project-field", + "value": "ApplyResume", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 156 + }, + { + "category": "project-field", + "value": "CreateResume", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 160 + }, + { + "category": "project-field", + "value": "CreateResume", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 161 + }, + { + "category": "project-field", + "value": "Create", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 130 + }, + { + "category": "project-field", + "value": "LegacyImports", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 149 + }, + { + "category": "project-field", + "value": "LegacyImports", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 150 + }, + { + "category": "project-field", + "value": "LegacyImports", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 150 + }, + { + "category": "project-field", + "value": "LegacyImports", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 151 + }, + { + "category": "project-field", + "value": "Migrate", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 140 + }, + { + "category": "project-field", + "value": "Migrate", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 140 + }, + { + "category": "project-field", + "value": "Migrate", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 141 + }, + { + "category": "project-field", + "value": "Migrations", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 135 + }, + { + "category": "project-field", + "value": "RelinkSources", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 165 + }, + { + "category": "project-field", + "value": "RelinkSources", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 166 + }, + { + "category": "project-field", + "value": "Validate", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 131 + }, + { + "category": "project-field", + "value": "Version", + "source": "+labkit/+ui/+runtime/private/validateAppDefinition.m", + "line": 124 + }, + { + "category": "resource-field", + "value": "cleanup", + "source": "+labkit/+ui/+runtime/private/v2ResourceRegistry.m", + "line": 60 + }, + { + "category": "resource-field", + "value": "cleanup", + "source": "+labkit/+ui/+runtime/private/v2ResourceRegistry.m", + "line": 140 + }, + { + "category": "resource-field", + "value": "disposed", + "source": "+labkit/+ui/+runtime/private/v2ResourceRegistry.m", + "line": 61 + }, + { + "category": "resource-field", + "value": "disposed", + "source": "+labkit/+ui/+runtime/private/v2ResourceRegistry.m", + "line": 136 + }, + { + "category": "resource-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/v2ResourceRegistry.m", + "line": 58 + }, + { + "category": "resource-field", + "value": "scope", + "source": "+labkit/+ui/+runtime/private/v2ResourceRegistry.m", + "line": 57 + }, + { + "category": "resource-field", + "value": "value", + "source": "+labkit/+ui/+runtime/private/v2ResourceRegistry.m", + "line": 59 + }, + { + "category": "resource-field", + "value": "value", + "source": "+labkit/+ui/+runtime/private/v2ResourceRegistry.m", + "line": 140 + }, + { + "category": "result-field", + "value": "Format", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 200 + }, + { + "category": "result-field", + "value": "app", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 17 + }, + { + "category": "result-field", + "value": "bytes", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 87 + }, + { + "category": "result-field", + "value": "extensions", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 30 + }, + { + "category": "result-field", + "value": "formatVersion", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 16 + }, + { + "category": "result-field", + "value": "format", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 15 + }, + { + "category": "result-field", + "value": "id", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 82 + }, + { + "category": "result-field", + "value": "inputs", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 21 + }, + { + "category": "result-field", + "value": "labkitUiVersion", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 26 + }, + { + "category": "result-field", + "value": "matlabRelease", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 27 + }, + { + "category": "result-field", + "value": "mediaType", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 62 + }, + { + "category": "result-field", + "value": "mediaType", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 85 + }, + { + "category": "result-field", + "value": "message", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 63 + }, + { + "category": "result-field", + "value": "message", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 88 + }, + { + "category": "result-field", + "value": "outputs", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 23 + }, + { + "category": "result-field", + "value": "parameters", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 22 + }, + { + "category": "result-field", + "value": "platform", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 28 + }, + { + "category": "result-field", + "value": "provenance", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 25 + }, + { + "category": "result-field", + "value": "relativePath", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 84 + }, + { + "category": "result-field", + "value": "revision", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 33 + }, + { + "category": "result-field", + "value": "role", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 83 + }, + { + "category": "result-field", + "value": "run", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 19 + }, + { + "category": "result-field", + "value": "status", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 20 + }, + { + "category": "result-field", + "value": "summary", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 24 + }, + { + "category": "result-field", + "value": "version", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 18 + }, + { + "category": "result-field", + "value": "warnings", + "source": "+labkit/+ui/+runtime/private/writeV2ResultManifest.m", + "line": 29 + }, + { + "category": "service-group", + "value": "debug", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 8 + }, + { + "category": "service-group", + "value": "diagnostics", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 14 + }, + { + "category": "service-group", + "value": "dialogs", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 21 + }, + { + "category": "service-group", + "value": "dispatch", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 10 + }, + { + "category": "service-group", + "value": "events", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 17 + }, + { + "category": "service-group", + "value": "figure", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 7 + }, + { + "category": "service-group", + "value": "previews", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 45 + }, + { + "category": "service-group", + "value": "project", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 37 + }, + { + "category": "service-group", + "value": "request", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 9 + }, + { + "category": "service-group", + "value": "resources", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 48 + }, + { + "category": "service-group", + "value": "results", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 54 + }, + { + "category": "service-group", + "value": "workflow", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 11 + }, + { + "category": "service-operation", + "value": "alert", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 22 + }, + { + "category": "service-operation", + "value": "axes", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 46 + }, + { + "category": "service-operation", + "value": "choice", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 34 + }, + { + "category": "service-operation", + "value": "clearScope", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 53 + }, + { + "category": "service-operation", + "value": "defaultFolder", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 24 + }, + { + "category": "service-operation", + "value": "defaultOutputFolder", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 25 + }, + { + "category": "service-operation", + "value": "emptyOutputs", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 55 + }, + { + "category": "service-operation", + "value": "entries", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 18 + }, + { + "category": "service-operation", + "value": "get", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 51 + }, + { + "category": "service-operation", + "value": "indices", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 20 + }, + { + "category": "service-operation", + "value": "inputFile", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 26 + }, + { + "category": "service-operation", + "value": "inputFolder", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 28 + }, + { + "category": "service-operation", + "value": "log", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 12 + }, + { + "category": "service-operation", + "value": "newState", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 38 + }, + { + "category": "service-operation", + "value": "outputFile", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 30 + }, + { + "category": "service-operation", + "value": "outputFolder", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 32 + }, + { + "category": "service-operation", + "value": "output", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 56 + }, + { + "category": "service-operation", + "value": "paths", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 19 + }, + { + "category": "service-operation", + "value": "reconcileSources", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 41 + }, + { + "category": "service-operation", + "value": "remove", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 52 + }, + { + "category": "service-operation", + "value": "report", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 15 + }, + { + "category": "service-operation", + "value": "saveAutosave", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 43 + }, + { + "category": "service-operation", + "value": "saveState", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 42 + }, + { + "category": "service-operation", + "value": "set", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 49 + }, + { + "category": "service-operation", + "value": "sourceRecord", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 39 + }, + { + "category": "service-operation", + "value": "upsertSource", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 40 + }, + { + "category": "service-operation", + "value": "writeManifest", + "source": "+labkit/+ui/+runtime/private/buildV2RuntimeServices.m", + "line": 57 + }, + { + "category": "state-bucket", + "value": "annotations", + "source": "+labkit/+ui/+runtime/private/createV2State.m", + "line": 6 + }, + { + "category": "state-bucket", + "value": "cache", + "source": "+labkit/+ui/+runtime/private/createV2State.m", + "line": 10 + }, + { + "category": "state-bucket", + "value": "extensions", + "source": "+labkit/+ui/+runtime/private/createV2State.m", + "line": 6 + }, + { + "category": "state-bucket", + "value": "inputs", + "source": "+labkit/+ui/+runtime/private/createV2State.m", + "line": 6 + }, + { + "category": "state-bucket", + "value": "parameters", + "source": "+labkit/+ui/+runtime/private/createV2State.m", + "line": 6 + }, + { + "category": "state-bucket", + "value": "results", + "source": "+labkit/+ui/+runtime/private/createV2State.m", + "line": 6 + }, + { + "category": "state-bucket", + "value": "selection", + "source": "+labkit/+ui/+runtime/private/createV2State.m", + "line": 10 + }, + { + "category": "state-bucket", + "value": "view", + "source": "+labkit/+ui/+runtime/private/createV2State.m", + "line": 10 + }, + { + "category": "state-bucket", + "value": "workflow", + "source": "+labkit/+ui/+runtime/private/createV2State.m", + "line": 10 + }, + { + "category": "state-root", + "value": "project", + "source": "+labkit/+ui/+runtime/private/validateV2State.m", + "line": 10 + }, + { + "category": "state-root", + "value": "session", + "source": "+labkit/+ui/+runtime/private/validateV2State.m", + "line": 10 + }, + { + "category": "state-root", + "value": "session", + "source": "+labkit/+ui/+runtime/private/validateV2State.m", + "line": 15 + } + ], + "versionNamedPaths": [ + { + "path": "+labkit/+biosignal/version.m", + "classification": "allowed-facade-version", + "rationale": "Dedicated facade version metadata." + }, + { + "path": "+labkit/+dta/version.m", + "classification": "allowed-facade-version", + "rationale": "Dedicated facade version metadata." + }, + { + "path": "+labkit/+image/version.m", + "classification": "allowed-facade-version", + "rationale": "Dedicated facade version metadata." + }, + { + "path": "+labkit/+rhs/version.m", + "classification": "allowed-facade-version", + "rationale": "Dedicated facade version metadata." + }, + { + "path": "+labkit/+thermal/version.m", + "classification": "allowed-facade-version", + "rationale": "Dedicated facade version metadata." + }, + { + "path": "+labkit/+ui/version.m", + "classification": "allowed-facade-version", + "rationale": "Dedicated facade version metadata." + }, + { + "path": "docs/history/records/2026/06/LK-20260606-v1-foundation.md", + "classification": "allowed-history", + "rationale": "Immutable structured component history." + }, + { + "path": "docs/history/records/2026/06/LK-20260615-v2-launcher-and-ui2.md", + "classification": "allowed-history", + "rationale": "Immutable structured component history." + }, + { + "path": "docs/history/records/2026/06/LK-20260621-v2-1-rhs-and-runtime-stability.md", + "classification": "allowed-history", + "rationale": "Immutable structured component history." + }, + { + "path": "docs/history/records/2026/06/LK-20260621-v2-2-v2-3-image-workflows.md", + "classification": "allowed-history", + "rationale": "Immutable structured component history." + }, + { + "path": "docs/history/records/2026/06/LK-20260623-v2-3-performance-and-release-contracts.md", + "classification": "allowed-history", + "rationale": "Immutable structured component history." + }, + { + "path": "docs/history/records/2026/06/LK-20260623-version-metadata-baseline.md", + "classification": "allowed-history", + "rationale": "Immutable structured component history." + }, + { + "path": "docs/history/records/2026/06/LK-20260629-ui-diagnostics-and-release-v3-0-0.md", + "classification": "allowed-history", + "rationale": "Immutable structured component history." + }, + { + "path": "docs/history/records/2026/07/LK-20260713-launcher-app-version-history.md", + "classification": "allowed-history", + "rationale": "Immutable structured component history." + }, + { + "path": "docs/history/records/2026/07/LK-20260715-runtime-v2-app-migration.md", + "classification": "allowed-history", + "rationale": "Immutable structured component history." + } + ], + "summary": { + "appCount": 21, + "publicUiSymbolCount": 35, + "matchCount": 2430, + "frameworkMatchCount": 310, + "frameworkCategoryCount": 16, + "versionNamedPathCount": 15, + "prohibitedArchitectureNameCount": 0 + } +} diff --git a/.agents/migration/ui-explicit-contract/behavior-classification.md b/.agents/migration/ui-explicit-contract/behavior-classification.md new file mode 100644 index 000000000..32df25e75 --- /dev/null +++ b/.agents/migration/ui-explicit-contract/behavior-classification.md @@ -0,0 +1,19 @@ +# Current UI call-pattern classification + +Generated by `tools/migration/auditLabKitUiMigration.m`. Every distinct pattern is classified in `baseline.json`; this file is the token-efficient decision summary. + +| Category | Decision | Patterns | Apps | Uses | Owner | Test evidence | +| --- | --- | ---: | ---: | ---: | --- | --- | +| callback-signature | replace | 214 | 21 | 349 | labkit.ui | callback-role compilation test | +| definition-field | replace | 16 | 21 | 315 | labkit.ui | strict constructor/compiler contract test | +| interaction-kind | replace | 7 | 7 | 12 | labkit.ui | interaction constructor, lifecycle, and GUI gesture test | +| layout-constructor | replace | 14 | 21 | 748 | labkit.ui | strict constructor/compiler contract test | +| presentation-transport | replace | 3 | 21 | 360 | labkit.ui | GUI-free presentation compilation and transaction test | +| service-call | remove | 2 | 1 | 3 | labkit.ui | runtime-context interface and test-double parity test | +| service-call | replace | 8 | 21 | 643 | labkit.ui | runtime-context interface and test-double parity test | + +## Decision policy + +- `replace` retains the required capability through the accepted strict contract, not through a production adapter. +- `remove` identifies accidental boundary exposure that the new runtime must reject. +- Detailed rationale and every source location are machine-readable in `baseline.json`. diff --git a/.agents/migration/ui-explicit-contract/capability-matrix.md b/.agents/migration/ui-explicit-contract/capability-matrix.md new file mode 100644 index 000000000..3b6129a6b --- /dev/null +++ b/.agents/migration/ui-explicit-contract/capability-matrix.md @@ -0,0 +1,35 @@ +# UI explicit-contract Phase 0 capability matrix + +Generated by `tools/migration/auditLabKitUiMigration.m`. Do not hand-edit counts. + +| App | Definition | Layout | Presentation | Interactions | Services | Event meta | Registry | Callbacks | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| BatchImageCrop | 15 (15) | 13 (45) | 3 (39) | 2 (2) | 6 (35) | 0 (0) | 0 (0) | 29 (29) | +| CIC | 15 (15) | 12 (31) | 2 (7) | 0 (0) | 5 (29) | 0 (0) | 0 (0) | 10 (10) | +| CSC | 15 (15) | 11 (26) | 2 (13) | 0 (0) | 5 (32) | 0 (0) | 0 (0) | 10 (10) | +| ChronoOverlay | 15 (15) | 11 (16) | 2 (6) | 0 (0) | 5 (20) | 0 (0) | 0 (0) | 9 (9) | +| CurvatureMeasurement | 15 (15) | 13 (37) | 3 (27) | 2 (2) | 6 (38) | 0 (0) | 0 (0) | 20 (20) | +| DICPostprocess | 15 (15) | 11 (22) | 2 (9) | 0 (0) | 6 (33) | 0 (0) | 0 (0) | 10 (10) | +| DICPreprocess | 15 (15) | 11 (45) | 3 (31) | 3 (3) | 6 (49) | 0 (0) | 0 (0) | 34 (34) | +| ECGPrint | 15 (15) | 12 (40) | 2 (14) | 0 (0) | 6 (28) | 0 (0) | 0 (0) | 12 (12) | +| EIS | 15 (15) | 12 (24) | 2 (3) | 0 (0) | 5 (20) | 0 (0) | 0 (0) | 8 (8) | +| FLIRThermal | 15 (15) | 13 (43) | 3 (27) | 1 (1) | 6 (28) | 0 (0) | 0 (0) | 20 (20) | +| FigureStudio | 15 (15) | 11 (29) | 2 (6) | 0 (0) | 10 (37) | 0 (0) | 0 (0) | 17 (18) | +| FocusStack | 15 (15) | 12 (27) | 2 (10) | 0 (0) | 6 (26) | 0 (0) | 0 (0) | 13 (13) | +| GaitAnalysis | 15 (15) | 11 (46) | 2 (15) | 0 (0) | 6 (25) | 0 (0) | 0 (0) | 12 (12) | +| ImageEnhance | 15 (15) | 13 (36) | 3 (22) | 1 (1) | 6 (28) | 0 (0) | 0 (0) | 19 (19) | +| ImageMatch | 15 (15) | 13 (34) | 2 (20) | 0 (0) | 6 (28) | 0 (0) | 0 (0) | 16 (16) | +| NerveResponseAnalysis | 15 (15) | 12 (34) | 2 (10) | 0 (0) | 6 (19) | 0 (0) | 0 (0) | 13 (13) | +| RHSPreview | 15 (15) | 13 (42) | 3 (21) | 1 (1) | 6 (28) | 0 (0) | 0 (0) | 20 (20) | +| ResponseReviewStats | 15 (15) | 13 (31) | 2 (9) | 0 (0) | 6 (15) | 0 (0) | 0 (0) | 12 (12) | +| TTestWizard | 15 (15) | 11 (47) | 2 (23) | 0 (0) | 5 (37) | 0 (0) | 0 (0) | 19 (19) | +| VTResistance | 15 (15) | 11 (24) | 2 (6) | 0 (0) | 5 (29) | 0 (0) | 0 (0) | 9 (9) | +| VideoMarker | 15 (15) | 12 (69) | 3 (42) | 2 (2) | 7 (62) | 0 (0) | 0 (0) | 36 (36) | + +## Inventory totals + +- Public `labkit.ui` symbols: 35 +- App source matches: 2430 +- Framework shadow-contract matches: 310 across 16 categories +- Version-bearing paths requiring classification: 15 +- Prohibited version-bearing architecture paths: 0 diff --git a/.agents/migration/ui-explicit-contract/migration-examples.md b/.agents/migration/ui-explicit-contract/migration-examples.md new file mode 100644 index 000000000..bc6a1a911 --- /dev/null +++ b/.agents/migration/ui-explicit-contract/migration-examples.md @@ -0,0 +1,120 @@ +# App SDK explicit-contract migration examples + +These examples are source-edit guidance for the accepted RFC. They are not a +runtime adapter and do not make generated edits scientifically correct. + +## Definition + +Before: + +```matlab +def = labkit.ui.runtime.define( ... + "Id", "example", ... + "Project", projectSpec(), ... + "Actions", struct("run", @onRun), ... + "Renderers", struct("preview", @drawPreview), ... + "Layout", @buildLayout, ... + "Present", @present); +``` + +After: + +```matlab +layout = labkit.app.layout.workbench({ ... + labkit.app.layout.button("run", "Run", @onRun)}, ... + Workspace=labkit.app.layout.workspace( ... + labkit.app.layout.plotArea( ... + "preview", @drawPreview))); +app = labkit.app.Definition( ... + Entrypoint="labkit_Example_app", AppId="example", ... + Title="Example", Family="Examples", AppVersion="1.0.0", ... + Updated="2026-07-19", ... + Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + ProjectSchema=projectSpec(), Workbench=layout, ... + PresentWorkbench=@present); +``` + +Layout controls and plot areas own their concrete callbacks directly; no +handler or renderer registry is maintained by the App. + +## View snapshot + +Before: + +```matlab +view.controls.run = struct("Enabled", state.session.canRun); +view.controls.status = struct("Text", state.session.status); +view.previews.image = struct( ... + "Renderer", "preview", "Model", state.session.preview); +``` + +After: + +```matlab +view = labkit.app.view.Snapshot(); +view = view.enabled("run", state.session.canRun); +view = view.text("status", state.session.status); +view = view.renderPlot("image", state.session.preview); +``` + +## Interaction + +Before: + +```matlab +view.interactions.roi = struct( ... + "Kind", "rectangle", ... + "Targets", "image", ... + "Value", state.session.roi, ... + "Event", "roiChanged", ... + "Options", struct("color", [1 1 1])); +``` + +Accepted semantic target (not production syntax until Phase 4 closes): + +```matlab +roi = labkit.app.interaction.rectangle( ... + Target="image", ... + Bounds=state.session.roi, ... + Changed=commands.roiChanged, ... + Color=[1 1 1]); +view = view.interaction(roi); +``` + +The interaction example remains deliberately labelled as a target because the +production interaction constructors and `view.Snapshot.interaction` operation +are not implemented yet. It must become an executable example when Phase 4 +closes; it is not currently an API promise. + +## Callback and context + +Before: + +```matlab +function state = onFilesSelected(state, event, services) + indices = services.events.indices( ... + event, "selectedFiles", numel(state.project.inputs.sources)); + state.session.selection.files = indices; + state = services.workflow.log(state, "Selection changed."); +end +``` + +After: + +```matlab +function state = onFilesSelected(state, selection, context) + state.session.selection.files = selection.Indices; + context.appendStatus("Selection changed."); +end +``` + +The handler is declared with `Event="listSelection"`, so compilation validates this +signature and the originating control before figure creation. + +## Review rule + +Mechanical edits may replace exact constructor or operation spelling only +after the production vocabulary is approved. A reviewer still verifies App +state ownership, handler event, payload meaning, project compatibility, +scientific results, cancellation, cleanup, and focused GUI behavior. No +analyzer output is accepted as semantic proof. diff --git a/.agents/migration/ui-explicit-contract/migration-worksheet.md b/.agents/migration/ui-explicit-contract/migration-worksheet.md new file mode 100644 index 000000000..086a6adf5 --- /dev/null +++ b/.agents/migration/ui-explicit-contract/migration-worksheet.md @@ -0,0 +1,21 @@ +# UI migration worksheet + +Generated by `analyzeLabKitUiMigration`. Source locations and every distinct pattern are in `migration-analysis.json`. + +| Category | Retired | Patterns | Apps | Uses | Replacement direction | +| --- | --- | ---: | ---: | ---: | --- | +| callback-signature | false | 214 | 21 | 349 | Application session factory; labkit.ui.command with declared callback Role; typed ProjectContract callback | +| definition-call | true | 1 | 21 | 21 | labkit.ui.application | +| definition-field | false | 16 | 21 | 315 | labkit.ui.application owned constructor/composition | +| event-decoding | true | 3 | 21 | 72 | role-specific typed signal payload | +| interaction-field | true | 9 | 12 | 112 | named strict interaction constructor parameter | +| interaction-kind | true | 7 | 7 | 12 | labkit.ui.interaction.anchorPath; labkit.ui.interaction.interval; labkit.ui.interaction.pairedAnchors; labkit.ui.interaction.pointSlots; labkit.ui.interaction.rectangle; labkit.ui.interaction.regionSelection; labkit.ui.interaction.scaleReference | +| interaction-option | true | 9 | 9 | 27 | named strict interaction constructor parameter | +| layout-constructor | false | 14 | 21 | 748 | strict labkit.ui.layout.action value; strict labkit.ui.layout.field value; strict labkit.ui.layout.filePanel value; strict labkit.ui.layout.group value; strict labkit.ui.layout.logPanel value; strict labkit.ui.layout.panner value; strict labkit.ui.layout.previewArea value; strict labkit.ui.layout.rangeField value; strict labkit.ui.layout.resultTable value; strict labkit.ui.layout.section value; strict labkit.ui.layout.statusPanel value; strict labkit.ui.layout.tab value; strict labkit.ui.layout.workbench value; strict labkit.ui.layout.workspace value | +| presentation-transport | true | 3 | 21 | 360 | closed labkit.ui.presentation operation | +| raw-presentation-path | true | 3 | 21 | 360 | closed labkit.ui.presentation operation | +| retired-callback-context | true | 2 | 21 | 317 | role-specific value/selection/edit payload and RuntimeContext | +| service-call | true | 10 | 21 | 646 | context.appendStatus; context.reportError; declared preview target or interaction; remove exposed runtime internals; typed context dialog/chooser method; typed context project/source method; typed context resource lifecycle method; typed signal payload; validated result value and context.writeResultPackage | +| undocumented-alias | true | 4 | 1 | 8 | exact canonical spelling validated at construction | + +Mechanical candidates: 0. All generated edits remain reviewed source; this analyzer does not guarantee semantic correctness. diff --git a/.agents/migration/ui-explicit-contract/performance-baseline.json b/.agents/migration/ui-explicit-contract/performance-baseline.json new file mode 100644 index 000000000..3683339c1 --- /dev/null +++ b/.agents/migration/ui-explicit-contract/performance-baseline.json @@ -0,0 +1,344 @@ +{ + "schemaVersion": 1, + "capturedAtUtc": "2026-07-19T10:47:43Z", + "matlabRelease": "2025b", + "platform": "PCWIN64", + "guiMode": "hidden", + "launchRepetitions": 3, + "presentationRepetitions": 25, + "scenarios": [ + { + "command": "labkit_TTestWizard_app", + "title": "TTestWizard", + "startupSeconds": [ + 11.4097122, + 3.2588992, + 2.5389636 + ], + "startupMedianSeconds": 3.2588992, + "firstStandalonePresenterSeconds": [ + 0.0067486, + 0.0011782, + 0.0007437 + ], + "firstStandalonePresenterMedianSeconds": 0.0011782, + "repeatedStandalonePresenterSeconds": [ + [ + 0.0029799, + 0.0019772, + 0.0187495, + 0.0007944, + 0.0008575, + 0.0003795, + 0.0003151, + 0.0002745, + 0.000257, + 0.0002528, + 0.0011098, + 0.0013547, + 0.0004691, + 0.0004933, + 0.0004693, + 0.0003433, + 0.0002927, + 0.0002489, + 0.0002454, + 0.0002758, + 0.0002504, + 0.0002346, + 0.0002288, + 0.0002474, + 0.0002425 + ], + [ + 0.0004229, + 0.0003145, + 0.0003028, + 0.0002833, + 0.0003375, + 0.0003867, + 0.0003158, + 0.000286, + 0.0002811, + 0.0002757, + 0.0005584, + 0.0009823, + 0.0004024, + 0.0003564, + 0.0004758, + 0.0007812, + 0.0004502, + 0.0003447, + 0.0003126, + 0.0003713, + 0.0003104, + 0.0009574, + 0.0018654, + 0.0009122, + 0.000876 + ], + [ + 0.0003574, + 0.0002638, + 0.0002447, + 0.0006675, + 0.000826, + 0.0003674, + 0.000301, + 0.0002698, + 0.0002733, + 0.000266, + 0.0003426, + 0.0003106, + 0.0002481, + 0.0002527, + 0.0002372, + 0.0002398, + 0.0002495, + 0.0002734, + 0.0003764, + 0.0002566, + 0.0002326, + 0.000328, + 0.0002535, + 0.0002363, + 0.0002346 + ] + ], + "repeatedStandalonePresenterMedianSeconds": 0.0003145, + "startupPresentationCommits": [ + 1, + 1, + 1 + ], + "closeSeconds": [ + 0.3129054, + 0.1571381, + 0.1673394 + ], + "closeMedianSeconds": 0.1673394 + }, + { + "command": "labkit_CurvatureMeasurement_app", + "title": "CurvatureMeasurement", + "startupSeconds": [ + 3.3641662, + 2.7669203, + 2.8126429 + ], + "startupMedianSeconds": 2.8126429, + "firstStandalonePresenterSeconds": [ + 0.0033189, + 0.0009541, + 0.0003655 + ], + "firstStandalonePresenterMedianSeconds": 0.0009541, + "repeatedStandalonePresenterSeconds": [ + [ + 0.001538, + 0.0013341, + 0.0110943, + 0.0003256, + 0.0001625, + 0.0001572, + 0.0001436, + 0.0001308, + 0.0001253, + 0.0001218, + 0.0001227, + 0.0001226, + 0.0001232, + 0.0001184, + 0.0001319, + 0.0001321, + 0.0001191, + 0.0001127, + 0.0001079, + 0.0001077, + 0.0001094, + 0.0001077, + 0.0001318, + 0.0001171, + 0.0001114 + ], + [ + 0.0002898, + 0.0002644, + 0.0001849, + 0.0001583, + 0.0001451, + 0.0001306, + 0.0001287, + 0.0001264, + 0.0003022, + 0.0003342, + 0.0002159, + 0.0001714, + 0.0001815, + 0.0001775, + 0.0001577, + 0.0001885, + 0.0001783, + 0.0001593, + 0.00016, + 0.0001485, + 0.0001347, + 0.0001359, + 0.0001474, + 0.0001395, + 0.0001648 + ], + [ + 0.0001888, + 0.000139, + 0.0001322, + 0.0001254, + 0.0001228, + 0.0001179, + 0.0001168, + 0.0001286, + 0.0001292, + 0.0001224, + 0.0001162, + 0.000113, + 0.0001148, + 0.0001138, + 0.000114, + 0.0001346, + 0.0001288, + 0.0001945, + 0.0001358, + 0.0001227, + 0.0001173, + 0.0001964, + 0.0001482, + 0.0001287, + 0.000114 + ] + ], + "repeatedStandalonePresenterMedianSeconds": 0.0001321, + "startupPresentationCommits": [ + 1, + 1, + 1 + ], + "closeSeconds": [ + 0.1419315, + 0.1732414, + 0.140476 + ], + "closeMedianSeconds": 0.1419315 + }, + { + "command": "labkit_VideoMarker_app", + "title": "VideoMarker", + "startupSeconds": [ + 3.5730767, + 3.0661137, + 3.5291368 + ], + "startupMedianSeconds": 3.5291368, + "firstStandalonePresenterSeconds": [ + 0.0090476, + 0.0028938, + 0.000616 + ], + "firstStandalonePresenterMedianSeconds": 0.0028938, + "repeatedStandalonePresenterSeconds": [ + [ + 0.006262, + 0.0039263, + 0.0222701, + 0.000788, + 0.0003651, + 0.0003739, + 0.0003491, + 0.0004515, + 0.000367, + 0.0003305, + 0.0003345, + 0.0003272, + 0.000304, + 0.0002996, + 0.0003185, + 0.0003152, + 0.0009536, + 0.0005554, + 0.00047, + 0.0004127, + 0.0003892, + 0.0003397, + 0.000346, + 0.0003751, + 0.0003623 + ], + [ + 0.0005334, + 0.0003423, + 0.0002964, + 0.0002829, + 0.0002806, + 0.0002757, + 0.0002835, + 0.000299, + 0.0002723, + 0.000265, + 0.0002566, + 0.0002509, + 0.0002591, + 0.0002728, + 0.0002981, + 0.000273, + 0.0002654, + 0.0002608, + 0.0002579, + 0.0002614, + 0.0002589, + 0.0003601, + 0.0002825, + 0.0002631, + 0.0002631 + ], + [ + 0.0003753, + 0.0002989, + 0.000317, + 0.0002818, + 0.0002676, + 0.0002646, + 0.0002625, + 0.0002592, + 0.0002615, + 0.0002887, + 0.0002766, + 0.0002525, + 0.0002493, + 0.0002494, + 0.0002449, + 0.0009199, + 0.0003915, + 0.0008927, + 0.0003668, + 0.0003453, + 0.0003052, + 0.0002915, + 0.0002805, + 0.0002818, + 0.0002905 + ] + ], + "repeatedStandalonePresenterMedianSeconds": 0.0002989, + "startupPresentationCommits": [ + 1, + 1, + 1 + ], + "closeSeconds": [ + 0.1704642, + 0.143305, + 0.1759653 + ], + "closeMedianSeconds": 0.1704642 + } + ] +} diff --git a/.agents/migration/ui-explicit-contract/phase-0-evidence.md b/.agents/migration/ui-explicit-contract/phase-0-evidence.md new file mode 100644 index 000000000..4c270e95d --- /dev/null +++ b/.agents/migration/ui-explicit-contract/phase-0-evidence.md @@ -0,0 +1,162 @@ +# UI explicit-contract redesign: Phase 0 evidence + +Captured from `main` at `7b906cbb721d81282f40a45502c7082a85fca25f` +on 2026-07-19. This folder is the compact evidence source for later phases; +generated `site/` output and ignored profiler artifacts are not design state. + +After merging the release-blocking main CI repair at `a13e91d8`, the audit was +refreshed. A later Phase 0 hardening pass removed lexical false positives, +deduplicated identical source matches, and added `confidence` and +`semanticRole` plus explicit review state to every App-side match. Callback +roles inferred from the bounded naming/file convention are reviewed +`probable`; direct syntax matches are `exact`. No new public symbol or +transport field was introduced. + +## Freeze + +Until the replacement contract is accepted, do not add a public +`labkit.ui` symbol, an App-authored UI transport field, or a new field read +through the current definition, project, layout, presentation, interaction, +event, service, resource, result, or registry structs. A release-blocking fix +must update the audit and explain why it cannot wait for the replacement. + +## Inventory + +- `baseline.json` is the machine-readable source-location inventory. It covers + 35 public `labkit.ui` callables, 2,430 App-side uses, 310 framework + shadow-contract reads across 16 categories, and all 21 public Apps. +- `capability-matrix.md` is the compact per-App view. +- `behavior-classification.md` classifies each distinct App call pattern as + `replace` or `remove`, gives its future owner, and names its test strategy. +- The 15 version-bearing paths are six dedicated facade `version.m` files and + nine structured history records. No prohibited version-bearing architecture + name was found. Generated `site/` mirrors are intentionally excluded. + +The audit is regenerated with: + +```matlab +addpath(fullfile(pwd, "tools", "migration")) +auditLabKitUiMigration(pwd, Write=true); +``` + +`UiMigrationBaselineTest` compares a fresh audit with the tracked JSON and +fails if an App disappears, a required shadow-contract category is missed, a +call pattern lacks a decision, or a prohibited architecture name appears. +`MigrationSummaryConsistencyTest` additionally proves that audit and analyzer +category totals/App unions agree. It guards the reviewed facts that interaction +kinds span seven Apps, definition fields are the 16 actual contract keys, and +ordinary local helpers are not UI callbacks. Lifecycle callbacks retain their +own semantic roles instead of being mislabeled as Commands. + +## Required behavior and accidental behavior + +Required behavior remains owned by `labkit.ui` unless the Phase 1 RFC assigns +it to an already permitted facade: + +- semantic product, project, layout, presentation, signal, interaction, + context, resource, and result capabilities; +- FIFO actions, whole-state validation, transactional presentation and + rollback; +- saved payload version and legacy-import behavior independent of the UI + boundary; +- deterministic cleanup and viewport preservation; +- GUI-free presenter and scientific computation paths. + +The raw structs, string joins, registry/component access, callback-arity +probing, concrete container assertions, exposed figure/debug/request values, +silent fallback, and MATLAB-handle validation are accidental implementation +behavior. They are not compatibility requirements. + +Every App-side source pattern has an explicit decision in +`behavior-classification.md`. `replace` means retain the capability through the +strict contract, never through a production adapter. `remove` means the new +compiler or boundary guard rejects the access. + +## Late-failure baseline + +The current field constructor accepts a dropdown with `items={"A"}` and +`value="B"`. Only `uidropdown` construction rejects it, with +`MATLAB:ui:DropDown:valueNotInText`. This is accidental behavior: the +replacement constructor must reject the incompatible value before any figure +exists. + +`GuiLayoutUiRuntimeV2Test/workspace_tabs_build_one_native_page_group` asserts +`rightGrid`, native tab, page-grid, row-height, column-width, parent, and +selected-tab details. Those assertions are concrete-container coupling. The +semantic page membership and selection behavior are retained; the MATLAB +container shape and registry traversal are removed from App-facing tests. + +The guide's third suspected case—empty or malformed factory state reaching an +action before normalization—is not reproducible at this baseline. +`createV2State` supplies required project/session buckets and +`validateV2State` runs before layout construction or startup actions. +`GuiLayoutUiRuntimeV2Test/minimal_definition_launches_without_optional_components` +proves the normalized empty state. An action can return malformed state, but +`runtime_v2_commits_queued_events_atomically` proves it is rejected before +commit and the prior state/view are restored. The replacement must preserve or +move this rejection earlier; it must not introduce the suspected late case. + +## Behavior, persistence, and result baseline + +The following focused hidden-GUI run passed 21 of 21 tests in 1 minute +47 seconds on MATLAB R2025b/Windows: + +- `UiLayoutTest` +- `GuiLayoutUiRuntimeV2Test` +- `GuiLayoutUiRuntimeV2ProjectTest` +- `GuiLayoutTTestWizardTest` +- `GuiLayoutCurvatureTest` +- `GuiLayoutVideoMarkerTest` + +Together these cover semantic layout construction; queued actions, +presentation, rollback, resources, invalid state and references; project +round-trip/migration/recovery; validated result manifests; T-Test table/edit/ +plot/export behavior; Curvature point/scale/overlay/viewport behavior; and +Video resource/recovery/current-and-legacy project behavior. All files and +values are synthetic and temporary. + +The final migration-evidence run passed all seven focused tests: two baseline +tests, the analyzer test, the cross-summary consistency test, and three +representation-prototype tests. After adding explicit review metadata, the +narrow baseline/summary rerun passed 3 of 3 tests. + +## Performance baseline and thresholds + +`performance-baseline.json` contains three launch samples and 25 standalone +presenter samples per launch in hidden mode: + +| App | Startup median | First presenter median | Repeated presenter median | Close median | +| --- | ---: | ---: | ---: | ---: | +| T-Test Wizard | 3.2589 s | 1.1782 ms | 0.3145 ms | 0.1673 s | +| Curvature Measurement | 2.8126 s | 0.9541 ms | 0.1321 ms | 0.1419 s | +| Video Marker | 3.5291 s | 2.8938 ms | 0.2989 ms | 0.1705 s | + +The cold first T-Test launch was 11.4097 seconds; the table uses medians so +one-time MATLAB UI initialization does not become an architecture target. + +Valid `profileLabKitTarget` startup captures (`run_error: none`) are local +under `artifacts/profile/`: + +- `migration-phase0-ttestwizard-startup.{html,json}`: 5.0149 s entrypoint + total; +- `migration-phase0-curvaturemeasurement-startup.{html,json}`: 2.1461 s; +- `migration-phase0-videomarker-startup.{html,json}`: 2.4390 s. + +The profiles identify native component creation and current layout building as +the dominant inclusive costs. They do not justify an optimization by +themselves. + +On the same MATLAB release, platform, GUI mode, and representative scenario, +the accepted prototype must satisfy: + +- three-run startup median no greater than `1.20 * baseline + 0.25 s`; +- first standalone presenter median no greater than the larger of twice the + baseline and 5 ms; +- repeated standalone presenter median no greater than the larger of twice the + baseline and 1 ms; +- no regression in the focused behavior, persistence, result, rollback, or + cleanup tests. + +These are prototype rejection thresholds, not claims about interactive visual +quality. Native dialogs, pointer feel, scientific validity, and full manual +workflows remain unverified by Phase 0. diff --git a/.agents/migration/ui-explicit-contract/phase-1-contract-rfc.md b/.agents/migration/ui-explicit-contract/phase-1-contract-rfc.md new file mode 100644 index 000000000..184a837f9 --- /dev/null +++ b/.agents/migration/ui-explicit-contract/phase-1-contract-rfc.md @@ -0,0 +1,325 @@ +# UI explicit-contract replacement RFC + +> Production amendment (2026-07-19): experiments removed public Command/ +> StateHandler objects and every App-authored handler/renderer registry. +> Production `labkit.app` layout nodes own direct callbacks and renderers; +> `Definition`, `layout.*`, `view.Snapshot`, `project.Schema`, and +> `CallbackContext` are the stable concepts. Historical prototype symbols +> below are evidence only and are not compatibility aliases. + +Status: Phase 1A representation and Phase 1B end-to-end contract gates +accepted on 2026-07-19. Phase 2 production implementation may begin. This is +not yet a released API; the contract remains migration-scoped until the Phase +2 kernel gate passes. The first production slice now implements the accepted +`Application`, `Command`, `Layout`, and `Presentation` values without +connecting them to the retired runtime. + +## Decision summary + +The replacement uses a small set of sealed immutable value classes composed +through strict constructors. Role-specific callbacks are chosen over one +generic payload callback. Project and result coordination remain owned by +`labkit.ui` because they participate in runtime transactions, recovery, +source relinking, and App-scoped provenance; they do not justify a new public +`data`, `io`, or `util` facade. + +The current runtime stays active until every App is migrated and the retired +surface can be deleted atomically. Prototype code lives only under +`tools/migration/` and must be deleted before Phase 8. + +## Public vocabulary + +The intended public concepts are deliberately fewer than the current struct +fields and service operations: + +| Concept | Construction and readable surface | Purpose | +| --- | --- | --- | +| Application | `labkit.ui.Application(...)` with Layout-collected Commands, optional programmatic commands, renderers, startup, and debug sample | One validated App definition and product contract | +| Project contract | `labkit.ui.ProjectContract(...)` | Payload creation, validation, migration, resume, relink, and named legacy import | +| Command | `labkit.ui.Command(id, callback, Role=...)` | Declared callback role and stable reference | +| Layout | `labkit.ui.Layout.(...)` static constructors returning immutable values | Controls, sections, pages, workspace, and declarative signals | +| Workspace | `workspace(content)` or `workspace().page(...).initialPage(...)` | Single or multi-page right-side ownership | +| Presentation | `labkit.ui.Presentation()` and role-specific methods | Deterministic target-checked visible state | +| Interactions | Named functions under `labkit.ui.interaction` | One value/signal contract per editor type | +| Runtime context | `labkit.ui.RuntimeContext` readable method surface | App-neutral dialogs, dispatch, workflow, sources, resources, persistence, and results | +| Result output/manifest | `labkit.ui.ResultOutput(...)`, `labkit.ui.Result(...)` | Validated output and provenance values | +| Named payload values | Table edit, selection, dialog result, and source selection | Multi-field signal results without generic event structs | + +The aggregate classes are `Application`, `ProjectContract`, `Command`, +`Layout`, `Presentation`, `RuntimeContext`, `ResultOutput`, and `Result`. +Small payload classes are added only when a signal carries more than one +named value. Interaction functions return one internal immutable interaction +value; there is no public base-class hierarchy. + +All properties are read-only. Apps construct and compose values but cannot +mutate or inspect a backing struct. Runtime compilation may convert validated +values to private structs. + +## Application and project contract + +Proposed definition: + +```matlab +function app = definition() + app = labkit.ui.Application( ... + Command="labkit_TTestWizard_app", ... + Id="ttest_wizard", ... + Title="T-Test Wizard", ... + DisplayName="T-Test Wizard", ... + Family="Statistics", ... + AppVersion="1.0.0", ... + Updated="2026-07-19", ... + Requirements=labkit.contract.requirements("ui", ">=8 <9"), ... + Project=ttest_wizard.projectContract(), ... + Session=@ttest_wizard.createSession, ... + Layout=ttest_wizard.userInterface.layout(), ... + Present=@ttest_wizard.userInterface.present, ... + Renderers=struct("resultPreview", ... + @ttest_wizard.userInterface.drawResultPreview)); +end +``` + +`Application` collects Commands from Layout signals and Start, then accepts +`ExtraCommands` only for programmatic dispatch. It rejects an unknown name, +conflicting Command values sharing an ID, duplicate renderer IDs, empty +required metadata, invalid version/date, unsupported requirement, callback +role mismatch, and missing renderer before figure creation. Static Apps omit +commands, session, presenter, renderer, and startup work. + +The one session factory shape is +`session = createSession(project,context)`. Portable source records remain +opaque; a source-backed App resolves paths through `context.resolveSourcePaths` +while rebuilding transient decoded data. The runtime uses the same fixed +shape during initial construction and project restore. + +`ProjectContract` accepts only named `Version`, `Create`, `Validate`, `Migrate`, +`CreateResume`, `ApplyResume`, `RelinkSources`, and `LegacyImport` operations. +Payload version is independent of the `labkit.ui` facade range. Existing valid +payloads and legacy imports retain their current meaning. + +## Commands, signals, and payloads + +Role-specific signatures are fixed: + +| Role | Signature | +| --- | --- | +| Invoke/start | `state = callback(state, context)` | +| Scalar value | `state = callback(state, value, context)` | +| Table edit | `state = callback(state, edit, context)` | +| Selection | `state = callback(state, selection, context)` | +| Interaction | `state = callback(state, value, context)` where the named interaction documents `value` | + +`TableEdit` exposes row ID/index, column ID/index, previous value, and new +value. `Selection` exposes stable item IDs/indices. Dialog results expose +`Cancelled` and the typed chosen value. No callback receives an open event, +`meta`, registry, source handle, or raw MATLAB event. + +Layout signals bind directly to a `Command` value; raw string command IDs are +not part of the App-facing contract. Callback role is declared by the +originating control and +must match the command. Construction uses MATLAB's read-only `nargin(handle)` +and `nargout(handle)` definition queries to require the role's one fixed input +and output shape; variable-arity handles are rejected. Runtime dispatch does +not inspect arity, retry after an exception, or guess an alternate shape. + +Programmatic dispatch receives a compiled command reference and its declared +payload. FIFO order, whole-state validation, presentation transaction, +rollback, diagnostics, and event-scope cleanup remain required. + +## Layout and workspace + +The audited constructors remain stable semantic concepts: + +`action`, `field`, `filePanel`, `group`, `logPanel`, `panner`, `previewArea`, +`rangeField`, `resultTable`, `section`, `statusPanel`, `tab`, `workbench`, and +`workspace`. + +Each has an `arguments`-checked complete option set; unknown spelling and an +option irrelevant to the chosen control kind fail at construction. Layout +values own one parent. Compilation checks global IDs, legal nesting, command +roles, bindings, renderer references, initial values, choices/limits, and +workspace page references without creating a figure. + +Workspace shapes: + +```matlab +workspace = labkit.ui.Layout.workspace(content); + +workspace = labkit.ui.Layout.workspace(); +workspace = workspace.page("data", "Data", dataContent); +workspace = workspace.page("plot", "Plot", plotContent); +workspace = workspace.initialPage("data"); +``` + +`workspace` owns `page`; no parallel global page constructor is introduced. +Concrete grids, rows, columns, tabs, panels, pixels, and registry paths are +private platform-adapter choices. + +## Presentation + +Presentation is a complete snapshot with a closed operation set. Every +presentation call describes the full current visible state; the runtime owns +diffing and reconciliation. Apps do not send incremental patches: + +```matlab +view = labkit.ui.Presentation(); +view = view.value("worksheet", state.session.sheet); +view = view.choices("group", state.session.groupNames); +view = view.limits("gain", [0 10]); +view = view.enabled("runTest", state.session.canRun); +view = view.visible("status", state.session.showStatus); +view = view.text("status", state.session.statusText); +view = view.files("sources", state.project.inputs.sources); +view = view.selection("sources", state.session.selection.sourceIds); +view = view.table("dataTable", state.session.tableModel); +view = view.plot("resultPlot", "groupComparison", state.session.plotModel); +view = view.workspacePage("plot", Enabled=state.session.hasResult, ... + Status=state.session.plotStatus); +view = view.interaction(scale); +``` + +There is no generic `set(target, property, value)`. Compilation resolves each +target and static capability. Commit validates dynamic type/range/model values +before applying any operation, then applies the complete presentation +transaction. Unknown targets, unsupported operations, renderers, axes, or +workspace pages fail without a partial view. Renderers receive platform-owned +axes and the declared App model. A plot placeholder/stale status is explicit. + +## Interactions + +The seven audited interaction contracts become named strict functions: + +- `anchorPath` +- `pairedAnchors` +- `pointSlots` +- `rectangle` +- `regionSelection` +- `interval` +- `scaleReference` + +Each declares target, value, limits, change command, commit policy, viewport +policy, and its small closed visual option set. `scaleReference` owns scale +points/calibration/bar editing as one lifecycle. Unknown style names fail. +Editable overlays are runtime-owned resources; renderer graphics are +display-only with hit testing disabled. + +There is no public `Kind`, `Targets`, `Value`, `Event`, `Options`, +`BackgroundEvent`, `ScrollEvent`, or `ChangePolicy` transport object. + +## Runtime context + +The context has discoverable direct methods grouped by ownership, not nested +open structs: + +- `dispatch`, `appendStatus`, and `reportError`; +- `alert`, `choose`, `chooseInputFile`, `chooseInputFolder`, + `chooseOutputFile`, and `chooseOutputFolder`; +- `saveProject`, `saveRecovery`, `sourceRecord`, `upsertSource`, and + `reconcileSources`; +- `acquireRenderSurface` for a declared renderer target; the returned + event-scoped surface cannot be stored in App state or resources; +- `setResource`, `getResource`, `removeResource`, and `clearResourceScope`; +- `writeResult`. + +Each Application declares the context capabilities it needs. Adding a context +method requires a fresh cross-App inventory and an ownership decision; the +context is not an open service collection. + +The runtime constructs the concrete context; tests construct a contract-equal +test context. It never exposes figure, debug object, launch request, registry, +component handle, or arbitrary test-only field. File/dialog cancellation is a +normal typed result. Resource scopes are `event`, `interaction`, `document`, +and `application`; replacement and scope end dispose exactly once. + +## Results + +`resultOutput` requires ID, role, relative path, media type, and status. +Optional message/warnings are typed text. `result` requires outputs, inputs, +parameters, and summary; provenance, platform, App/facade versions, project +document/revision, checksums, and creation IDs/times are runtime-owned. + +An extension requires an explicitly named namespace and schema version. +Unknown fields, duplicate output IDs/paths, escaping paths, absent successful +outputs, invalid media type/status, or manifest/output mismatch fail before +the existing manifest is replaced. + +## Errors, cancellation, and recovery + +Stable public groups: + +- `labkit:ui:contract:UnknownArgument` +- `labkit:ui:contract:InvalidValue` +- `labkit:ui:contract:DuplicateId` +- `labkit:ui:contract:UnknownReference` +- `labkit:ui:contract:CallbackRoleMismatch` +- `labkit:ui:contract:UnsupportedOperation` +- `labkit:ui:runtime:ActionFailed` +- `labkit:ui:runtime:InvariantFailure` + +Messages include the public symbol/parameter/ID. Optional absence selects a +documented default; a supplied malformed value never does. Native exceptions +may be wrapped only with the public operation and original cause preserved. +Action failure restores prior state and presentation and reports diagnostics. +Valid saved payloads are not rewritten merely because this UI boundary +changes. + +## Ownership and lifecycle + +```text +Application value + +-- Project contract + +-- Layout-collected Commands, optional ExtraCommands, and renderers + `-- Immutable layout + `-- owned targets/signals + | + v + compiled private plan + | + v +Application runtime context + +-- document/project scope + +-- interaction scopes + +-- event scope + `-- private MATLAB UI adapter +``` + +Scope teardown is inside-out. Replacing a resource disposes the prior value; +event completion/failure clears event scope; interaction removal clears its +scope; document replacement clears document scope; figure/application close +clears everything once. + +## Source compatibility + +Within one compatible `labkit.ui` major range, valid constructors, callback +roles, defaults, payload fields, ownership, and error groups retain meaning. +Additions are optional. Breaking changes require the next major facade range. +Saved App payload versions remain governed by each App project contract. + +The migration is source-breaking once, with analyzer diagnostics and codemod +help but no production translation layer, alias table, dual runtime, hidden +fallback, or version-named namespace. The released boundary changes only after +all 21 Apps compile and pass their focused tests. + +## Representation comparison gate + +Executable disposable prototypes must compare this value-class form with +opaque strict function values for T-Test Wizard, Curvature Measurement, and +Video Marker. Measure: + +- public concepts and source lines at framework seams; +- help/introspection discoverability; +- unknown argument/target/signal/interaction failures; +- GUI-free deterministic presentation; +- callback-role validation before launch; +- ability to prevent App mutation/inspection of backing representation; +- Phase 0 performance thresholds. + +The accepted representation is the sealed immutable value-class form. The +representation comparison uses identical target graphs and stable public +unknown-argument errors. The Phase 1B hidden T-Test-style experiment proves a +complete snapshot, private diff/reconciliation, declared context +capabilities, transaction rollback, render-surface escape rejection, and all +Phase-0-derived timing gates. Static layout compilation is cached at +Application construction; repeated presentation does not re-flatten the +layout. Phase 2 production implementation may proceed. This approval does not +authorize a mutable handle model or public inheritance hierarchy. diff --git a/.agents/migration/ui-explicit-contract/phase-1-end-to-end-evidence.json b/.agents/migration/ui-explicit-contract/phase-1-end-to-end-evidence.json new file mode 100644 index 000000000..6ca813181 --- /dev/null +++ b/.agents/migration/ui-explicit-contract/phase-1-end-to-end-evidence.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "scenario": "T-Test-style hidden end-to-end contract", + "transaction": { + "commitChangedState": true, + "commitRolledBack": false, + "commitError": "", + "failureRolledBack": true, + "failurePreservedState": true, + "failurePreservedPresentation": true, + "failureError": "prototype:ui:ActionFailed", + "missingCapabilityRolledBack": true, + "missingCapabilityError": "prototype:ui:UndeclaredCapability", + "surfaceEscapeRolledBack": true, + "surfaceEscapeError": "prototype:ui:EscapedRenderSurface" + }, + "reconciliation": { + "snapshotTargetCount": 4, + "initialAppliedOperationCount": 5, + "updatedAppliedOperationCount": 3, + "updatedAppliedKeys": [ + "value|group", + "enabled|runTest", + "plot|resultPlot" + ], + "runtimeOwnedDiff": true + }, + "timings": { + "startupMedianSeconds": 2.2505758, + "firstPresentationMedianSeconds": 0.0017349, + "repeatedPresentationMedianSeconds": 0.0004246, + "commitMedianSeconds": 0.2830067, + "closeMedianSeconds": 0.0867633 + }, + "thresholds": { + "startupSeconds": 4.16067904, + "firstPresentationSeconds": 0.005, + "repeatedPresentationSeconds": 0.001, + "closeSeconds": 0.45080728000000003 + }, + "timingAccepted": true, + "behaviorAccepted": true, + "accepted": true +} diff --git a/.agents/migration/ui-explicit-contract/phase-1-end-to-end-evidence.md b/.agents/migration/ui-explicit-contract/phase-1-end-to-end-evidence.md new file mode 100644 index 000000000..295198a6a --- /dev/null +++ b/.agents/migration/ui-explicit-contract/phase-1-end-to-end-evidence.md @@ -0,0 +1,14 @@ +# Phase 1B end-to-end contract evidence + +Scenario: hidden T-Test-style contract from immutable values through compile, complete snapshot, private reconciliation, transaction, rollback, and close. + +- Behavior accepted: true +- Timing accepted: true +- Phase 1B accepted: true +- Startup median/threshold: 2.2506/4.1607 s +- First presentation median/threshold: 0.001735/0.005000 s +- Repeated presentation median/threshold: 0.000425/0.001000 s +- Close median/threshold: 0.0868/0.4508 s +- Runtime diff applied 3 of 5 snapshot operations after the committed action. + +This is disposable migration evidence, not a released runtime. diff --git a/.agents/migration/ui-explicit-contract/phase-1-prototype-evidence.json b/.agents/migration/ui-explicit-contract/phase-1-prototype-evidence.json new file mode 100644 index 000000000..d5dc51d53 --- /dev/null +++ b/.agents/migration/ui-explicit-contract/phase-1-prototype-evidence.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": 2, + "valueClass": { + "publicPrototypeFileCount": 5, + "scenarios": [ + { + "name": "T-Test Wizard", + "targetCount": 4, + "operationCount": 4, + "deterministic": true, + "compileMedianSeconds": 7.19E-5 + }, + { + "name": "Curvature Measurement", + "targetCount": 2, + "operationCount": 3, + "deterministic": true, + "compileMedianSeconds": 0.0001453 + }, + { + "name": "Video Marker", + "targetCount": 3, + "operationCount": 3, + "deterministic": true, + "compileMedianSeconds": 0.0001126 + } + ], + "failures": { + "unknownTarget": "prototype:ui:UnknownReference", + "callbackRole": "prototype:ui:CallbackRoleMismatch", + "variableArity": "prototype:ui:CallbackRoleMismatch", + "callbackOutput": "prototype:ui:CallbackRoleMismatch", + "unknownArgument": "labkit:ui:contract:UnknownArgument" + }, + "backingRepresentationPubliclyMutable": false, + "helpAndMethodsDiscoverable": true + }, + "opaqueFunction": { + "publicPrototypeFileCount": 15, + "scenarios": [ + { + "name": "T-Test Wizard", + "targetCount": 4, + "operationCount": 4, + "deterministic": true, + "compileMedianSeconds": 0.0004929 + }, + { + "name": "Curvature Measurement", + "targetCount": 2, + "operationCount": 3, + "deterministic": true, + "compileMedianSeconds": 0.000463 + }, + { + "name": "Video Marker", + "targetCount": 3, + "operationCount": 3, + "deterministic": true, + "compileMedianSeconds": 0.0004736 + } + ], + "failures": { + "unknownTarget": "prototype:ui:UnknownReference", + "callbackRole": "prototype:ui:CallbackRoleMismatch", + "variableArity": "prototype:ui:CallbackRoleMismatch", + "callbackOutput": "prototype:ui:CallbackRoleMismatch", + "unknownArgument": "labkit:ui:contract:UnknownArgument" + }, + "backingRepresentationVisibleThroughFunctions": true, + "helpAndMethodsDiscoverable": false + }, + "seamComparison": { + "currentDistinctSeamLines": 506, + "valuePrototypeCoveredSeamLines": 52, + "opaquePrototypeCoveredSeamLines": 48 + }, + "decision": "sealed-immutable-value-classes", + "decisionRationale": "Both forms can reject invalid contracts and compile deterministic plans. Value methods are discoverable and compose without a large global function vocabulary. MATLAB functions() exposes closure workspaces, so function-handle tokens are conventionally opaque rather than representation-safe." +} diff --git a/.agents/migration/ui-explicit-contract/phase-1-prototype-evidence.md b/.agents/migration/ui-explicit-contract/phase-1-prototype-evidence.md new file mode 100644 index 000000000..4c0b1cf42 --- /dev/null +++ b/.agents/migration/ui-explicit-contract/phase-1-prototype-evidence.md @@ -0,0 +1,16 @@ +# Phase 1 representation prototype evidence + +| Representation | Public files | Deterministic Apps | Strict failures | +| --- | ---: | ---: | ---: | +| Sealed value classes | 5 | 3/3 | 5/5 | +| Opaque function values | 15 | 3/3 | 5/5 | + +Decision: `sealed-immutable-value-classes`. + +Both forms can reject invalid contracts and compile deterministic plans. Value methods are discoverable and compose without a large global function vocabulary. MATLAB functions() exposes closure workspaces, so function-handle tokens are conventionally opaque rather than representation-safe. + +GUI-free compile medians (value/opaque, milliseconds): T-Test Wizard 0.072/0.493; Curvature Measurement 0.145/0.463; Video Marker 0.113/0.474. + +Prototype-covered seam lines (current/value/opaque): 506/52/48. + +Opaque closure backing state visible through MATLAB `functions`: true. diff --git a/.agents/migration/ui-explicit-contract/phase-2-kernel-evidence.md b/.agents/migration/ui-explicit-contract/phase-2-kernel-evidence.md new file mode 100644 index 000000000..5d6abe42d --- /dev/null +++ b/.agents/migration/ui-explicit-contract/phase-2-kernel-evidence.md @@ -0,0 +1,55 @@ +# UI explicit-contract Phase 2 kernel evidence + +> Naming amendment (2026-07-19): this evidence predates the final +> `labkit.app` package split. Its `labkit.ui` explicit-value names are +> historical prototype names, not the SDK surface. + +Status: accepted on 2026-07-19. + +The first production slices establish the GUI-free static contract graph. They +add sealed `labkit.ui.Application`, `Command`, `Layout`, and `Presentation` +values, strict canonical Name-Value parsing, fixed callback-role arity, +single-parent layout ownership, global ID/reference checks, target capability +checks, renderer ownership, and complete presentation-snapshot validation. +`ProjectContract`, `ResultOutput`, and `Result` now own the durable callback +and manifest boundaries without interpreting App scientific payloads. +`TableEdit`, `Selection`, and `DialogResult` replace ambiguous multi-field +event and cancellation transport. +`RuntimeContext` is a sealed direct-method capability boundary backed by a +private operation implementation. It enforces the Application allow-list +before every call and exposes neither the backend nor a nested service bag. +The public zero-capability constructor supports callbacks that must remain +pure; runtime construction is hidden. + +`Layout` now implements the fourteen audited semantic concepts through one +class: `action`, `field`, `rangeField`, `panner`, `filePanel`, `previewArea`, +`resultTable`, `logPanel`, `statusPanel`, `group`, `section`, `tab`, +`workspace`, and `workbench`. The disposable `root`, `table`, and `preview` +prototype vocabulary was removed instead of becoming a compatibility alias. +Workspace pages are owned fluent values with compile-time initial-page checks. + +The slice deliberately does not call `labkit.ui.runtime.define`, reuse current +layout structs, or introduce a compatibility adapter. Static layout flattening +is performed once by `Application`; repeated presentation validation consumes +the cached target graph. + +Focused evidence: + +- `UiExplicitContractKernelTest`: 5 of 5 tests passed. +- `UiExplicitContractValueTest`: 5 of 5 tests passed. +- `UiRuntimeContextContractTest`: 2 of 2 tests passed. +- `UiExplicitContractClosureTest`: 4 of 4 tests passed. +- `PublicApiDocumentationContractTest`: new class help contracts passed. +- `PackagePublicSurfaceTest`: every replacement class is explicitly governed. +- The documentation renderer discovers public `classdef` files from source + paths and emits their local HTML reference pages without catalog entries. +- Narrative API tables are derived from exact source mentions, so adding one + public symbol no longer injects the full facade list into unrelated history + pages. + +The Phase 2 gate is closed. Malformed definitions fail before a compiled +Application is returned; canonical spelling, fixed callback shapes, direct +Command bindings, typed multi-field payloads, complete snapshots, layout +ownership, capabilities, references, and deterministic contract diagnostics +all have focused negative coverage. Phase 3 may connect this compiled plan to +the transactional runtime and private MATLAB platform adapter. diff --git a/.agents/migration/ui-explicit-contract/phase-3-runtime-evidence.md b/.agents/migration/ui-explicit-contract/phase-3-runtime-evidence.md new file mode 100644 index 000000000..461e9c6df --- /dev/null +++ b/.agents/migration/ui-explicit-contract/phase-3-runtime-evidence.md @@ -0,0 +1,88 @@ +# UI explicit-contract Phase 3 runtime evidence + +Status: runtime, native platform, project, result, and basic table paths +accepted on 2026-07-19; managed interactions remain in progress. + +The replacement runtime now executes a compiled `Application` without the +retired dispatcher, service bag, presentation commit schema, or control +registry. `RuntimeKernel` owns a FIFO command queue, validates the +project/session boundary after every callback, derives a complete immutable +`Presentation`, and commits it transactionally through a replaceable platform +adapter. A failed callback, state validation, presentation, or adapter commit +restores the previous state and presentation and reports the stable +`labkit:ui:runtime:ActionFailed` boundary. + +`ResourceStore` establishes runtime-owned event, interaction, document, and +application lifetimes. Replacement and removal dispose exactly once. Scope +cleanup removes entries before invoking cleanup callbacks, continues after +individual failures, and reports the collected failure only after all selected +resources have been attempted. Closing a runtime is idempotent. + +`ProjectDocumentStore` now writes and restores the accepted +`labkit.project` envelope without exposing document metadata to App callbacks. +It accepts only the current envelope or an import explicitly declared by +`ProjectContract`, migrates payloads sequentially, rebuilds session/resume +state, and publishes restored state and document identity only after +presentation reconciliation succeeds. `ResultWriter` verifies declared output +files, records byte counts and SHA-256 values, derives aggregate status, and +atomically writes the fixed `Result.ManifestName`; output creation remains +App-owned. + +The platform inventory found one contract omission before App migration: +opaque portable records had creation/reconciliation methods but no supported +way for App code to obtain resolved paths. `RuntimeContext.sourcePaths` +therefore joins the existing declared `project` capability after a cross-App +inventory confirmed that source-path reads are required by the file-backed +Apps. It returns a column string array and does not expose the portable +reference representation. + +The authoring-ergonomics hardening pass also establishes the paved road: +Layout signals are collected automatically instead of being repeated in +Application, strict `project.*`/`session.*` bindings remove mechanical +callbacks and presenter operations, runtime defaults plus bindings form the +complete snapshot, `ProjectContract()` supplies the simple version-1 scalar +struct contract, omitted capability metadata selects the standard capability +set, and `appendStatus(message)` is unambiguously framework-owned. The +executable budgets live in `UiAuthoringErgonomicsTest` and +`authoring-ergonomics.md`. + +The implementation classes are sealed and hidden. MATLAB does not permit +package class definitions in a `private` directory, so the files live at the +`labkit.ui` package root with constructors and adapter operations restricted +to their owning replacement classes. Documentation discovery explicitly +excludes hidden classes, and the package surface guard records their presence +without treating them as App-facing contracts. + +The private MATLAB adapter now constructs the semantic workbench, fields, +file panels, workspace pages, tables, previews, and axes. Table presentation +uses strict named options instead of a raw struct. Existing `Selection` and +`TableEdit` values carry N-by-2 cell selection and complete proposed table +data, so Apps do not learn native event fields and no additional public table +model types are required. + +Focused evidence: + +- `UiRuntimeKernelTest`: 7 of 7 tests passed, covering FIFO reentrancy, + commit rollback, typed dispatch payloads, replacement/close cleanup, and + cleanup-failure continuation, bound updates, and typed table events. +- `UiRuntimeContextContractTest`: 2 of 2 tests passed. +- `UiAuthoringErgonomicsTest`: 5 of 5 executable paved-road budgets passed. +- `UiPortableSourceStoreTest`: 3 of 3 runtime-boundary tests passed. +- `UiProjectDocumentStoreTest`: 7 of 7 tests passed, including atomic save, + migration/import, wrong/newer rejection, recovery identity, failed-save + metadata isolation, adapter-commit rollback, bound-source rebasing, and + missing-required-source rejection. +- `UiResultWriterTest`: 4 of 4 tests passed, covering verified output + metadata, missing output failure, aggregate status, and atomic cleanup. +- `PublicApiDocumentationContractTest`: 8 of 8 tests passed. +- `ProjectDocumentationGuardrailTest`: the public-function contract check + distinguishes public value classes from function files. +- `PackagePublicSurfaceTest`: the three hidden implementation files are + explicitly governed. +- `UiMatlabPlatformAdapterTest`: 4 of 4 tests passed, covering semantic + construction, typed native control/table callbacks, viewport preservation, + and native rollback after renderer failure. + +The next slice uses T-Test Wizard as the real table/workspace consumer, then +adds managed interactions with Curvature as a narrow native sample before +Video Marker exercises resource and recovery ownership. diff --git a/.agents/migration_guide.md b/.agents/migration_guide.md index 4f807fdcf..7e5f9e900 100644 --- a/.agents/migration_guide.md +++ b/.agents/migration_guide.md @@ -8,23 +8,26 @@ component history. ## Active debt -Last audited: 2026-07-19. +Last audited: 2026-07-20. ```text toolbox-product-debt: none -ui-migration-debt: ui-explicit-contract-redesign +app-sdk-migration-debt: ui-explicit-contract-redesign ``` -## UI explicit-contract redesign +## App SDK explicit-contract migration ### Decision and scope - **Debt ID:** `ui-explicit-contract-redesign` -- **Owner:** `labkit.ui` -- **Target boundary:** the next incompatible `labkit.ui` contract and every - tracked App that consumes it -- **Status:** architecture and migration planning; no replacement - implementation is accepted yet +- **Owner:** `labkit.app` +- **Target boundary:** stable `labkit.app` 1.x and every tracked App formerly + consuming legacy `labkit.ui` +- **Status:** Phases 0-6 complete for all 21 tracked Apps; the retired + production facade and migration-only tools/tests are removed in Phase 7, + and Phase 8 public-boundary, documentation, version/history work is + complete; final repository gates and developer-led interactive validation + remain - **User-visible reason:** App authors must be able to discover the framework from function, constructor, method, and parameter names. Invalid App code must fail at the contract boundary instead of being ignored, guessed, or @@ -32,6 +35,173 @@ ui-migration-debt: ui-explicit-contract-redesign - **Release model:** a deliberate incompatible-contract replacement, not an additive compatibility layer on the current runtime +### User-visible UI parity audit + +The 2026-07-19 `main` baseline remains the behavioral and visual reference +until every tracked App has been reviewed in every control tab and workspace +page. Startup-only screenshots are insufficient. + +Restored in the replacement SDK worktree: + +- versioned window titles, project dirty markers, callback busy feedback, + guarded close behavior, keyboard close, delayed startup progress, and GUI + test visibility modes; +- fixed control-pane sizing, draggable column and row dividers, scrollable + mixed-content control tabs, full-height single-surface tabs, framework + utility menus, and complete-text fitting; +- old panner, range, readonly, adaptive action-grid, file/folder/recursive + file selection, friendly file labels, multiline control sizing, status, + log-follow, table, and distinct Usage presentations; +- task-oriented file lists that preserve distinct portable source records for + repeated paths and render App-owned per-row workflow statuses; +- transparent semantic groups that preserve full native button height, and + bound range controls whose initial values come from App state rather than + falling back to their legal limits; +- workspace pages and initial selection, multiple vertically composed + workspace-page surfaces, plot view modes, single/pair/stack axes layouts, + axis titles and labels, unequal axes sizing, per-axis wheel zoom, viewport + preservation, managed fixed-canvas resize reflow, and plot pop-out/export + behavior; +- typed interval-scroll payloads carrying the normalized data anchor and + scroll count instead of an undocumented runtime struct. + +Retired during Phase 7: + +- the complete 161-file `+labkit/+ui` implementation after source scans proved + that no production App or reusable facade still called it; +- the offline analyzer, baseline/prototype generators, prototype packages, and + migration-only contract/GUI tests that existed only to reach the replacement; +- current source comments, framework ownership rules, test-routing fixtures, + and documentation discovery that still named the retired facade. Historical + records and this active debt rationale remain until the final + cross-component history record is complete. + +Current public-boundary checkpoint: + +- `CallbackContext` is runtime-created and callback-injected; Apps can no + longer construct it as a second root object. +- Pure portable-source value creation moved to `labkit.app.project`, while + unused source mutation and render-surface operations were removed from the + callback port. +- Runtime construction no longer passes the complete `Definition` into + `CallbackContext`; the port retains only its named backend operations. +- Runtime creation and synthetic-sample execution now belong to the internal + `RuntimeFactory`; headless and MATLAB GUI tests construct runtimes there + instead of adding test-only construction methods to `Definition`. +- Layout targets, signal bindings, interaction targets, and native platform + plans now compile into internal `CompiledDefinition`; tests inspect that + result through internal `DefinitionInspector`. +- `Definition` is the sole author-created aggregate root and exposes only + immutable author metadata/configuration, snapshot validation, and launch. + `TargetIds` and every runtime/compiler/test-only method are absent from its + public and hidden method surface. +- Synchronous startup failures retain the App window and readiness surface, + show the deepest actionable failure message, release busy state, and keep + the complete exception chain in diagnostics; the native adapter contract + now covers this behavior alongside successful startup and versioned titles. +- `labkit.app` 1.0.0 is the stable replacement facade. All 21 App definitions + declare `>=1 <2`, advance exactly one product-version step from + `origin/main`, and use the 2026-07-20 completion date. +- Every App manual and the framework manuals describe direct layout callbacks, + sealed callback context, complete snapshots, and runtime-owned native + behavior. The sequence-138 cross-component history record lists the facade + and all 21 exact App version transitions; the generated site is synchronized + from an isolated source tree that excludes unrelated Launcher edits. +- The first full `origin/main`-to-branch `changedFast` routing pass exposed + documentation-only guardrail gaps rather than another runtime seam: + non-public App capability files and five private native adapters now carry + path-specific file-level ownership contracts, while the runtime and + complete-App guides name `definition.m` and `Snapshot.include` explicitly. + The four previously failing documentation methods pass; the full-diff gate + must be rebuilt from the new checkpoint and rerun. +- The root public layer remains deliberately limited to `Definition`, + runtime-injected `CallbackContext`, and `version`. `Definition` is the sole + App-authored aggregate root; `CallbackContext` is a sealed callback protocol + port, not a constructible service root. Optional concepts stay in named + `layout`, `view`, `event`, `interaction`, `plot`, `project`, `result`, + `dialog`, and `diagnostic` packages. The public-surface guardrail now locks + this replacement vocabulary and rejects any return of the retired + `labkit.ui` files. +- `LayoutNode` now owns immutable semantic layout composition while hidden + internal `LayoutNodeValues` owns the shared option normalization and strict + value validation used by those constructors. This reduces the two cohesive + files to 446 and 274 effective code lines without changing the qualified + `LayoutNode` name or adding an App-facing entry; the complete explicit-layout + contract suite passes this split. +- `RuntimeKernel` now keeps queueing, transaction order, lifecycle, resource, + document, and capability injection ownership, while hidden internal + `RuntimeContractBoundary`, `RuntimePresentation`, and `RuntimeStatePath` + own compiled-reference validation, default Snapshot derivation, and strict + project/session field paths. The four files measure 635, 149, 120, and 37 + effective lines; the runtime suite passed eight unaffected cases and the two + initially exposed source-access cases passed after source-store access was + kept behind a RuntimeKernel-owned callback. +- `MatlabPlatformAdapter` keeps lifecycle, callback entry, dialog, document, + and close behavior in a 571-line class definition; its class folder owns 40 + substantial native layout/reconciliation methods while hidden + `NativeAdapterValues` owns 482 lines of pure native value normalization. + Small lifecycle and callback glue is deliberately not externalized, and the + surface guardrail caps class-folder method count to prevent a return to + monolith or one-file-per-trivial-method fragmentation. All ten focused + native-adapter GUI tests pass with the same qualified class name and no new + App-facing API. +- The Launcher migration now targets the replacement SDK directly: ordinary + launch and profiling call the App entrypoint without runtime injection, + while **Open Debug** passes one typed verbose diagnostic configuration, + persists an isolated artifact session, and requests the App-owned synthetic + sample. Static catalog discovery still reads the single `definition.m` + metadata owner so a damaged SDK does not prevent Launcher repair actions. +- The first clean full-diff gate exposed one documentation-boundary defect in + the native-adapter class-folder split: external methods of a hidden internal + class were being treated as public APIs. Public help and site discovery now + exclude class-folder implementation methods consistently; the focused + documentation checks and rebuilt final gates remain to be rerun. + +Still open before compatibility retirement: + +- complete developer-led manual validation of native dialogs, editable tables, + pointer interactions, long-lived resources, and representative exports; +- complete visible Launcher debug-session validation and inspect one generated + diagnostic bundle; +- run the final `changedFast` and stable `buildtool changed` repository gates, + then record their exact evidence in the cross-component history record. + +Current product audit progress: + +| App scope | Status | Evidence | +| --- | --- | --- | +| DIC Preprocess | Complete | All three control tabs, workspace, controls, actions, notes, summary, details, log, and focused GUI workflow compared with `main` | +| CIC | Complete | All three control tabs, old panner geometry, files panel, summary rows, batch table, plot stack, menus, title, exports, project save/restore, and focused GUI workflow compared with `main` | +| Chrono Overlay | Complete | Both control tabs, file actions, Usage, panner, plot options, stacked labeled axes, export/restore workflow, and focused GUI tests compared with `main` | +| CSC | Complete | All three control tabs, file/curve/plot controls, readonly comparison summary, all-cycle table, paired exports, stacked axes, project workflow, and focused GUI test compared with `main` | +| EIS | Complete | All three control tabs, file actions, panners, plot choices, Usage, summary, labeled plot, export/restore workflow, and focused GUI test compared with `main` | +| VT Resistance | Complete | All three control tabs, file/export actions, analysis and plot controls, readonly summary, batch table, stacked axes, project workflow, and focused GUI tests compared with `main` | +| Figure Studio | Complete | Figures/Export/Log tabs, FIG source actions, multiline status, quick exports, Canvas and Style panners, linked font/aspect behavior, fixed-canvas preview, axes handoff, package exports/manifests, and focused unit/GUI tests compared with `main` | +| Response Review and Stats | Complete | Setup/Review/Export/Log tabs, numeric metric windows, status and action rows, nonduplicated summary/details, explicit output selection/clear, reset, default output subfolder, manifest name, Stats/Preview workspace, project restore, and focused unit/GUI tests compared with `main` | +| Nerve Response Analysis | Complete | Setup/Protocol/Review/Export/Log tabs, filter and optional protocol sources, analysis limits/status/actions, nonduplicated summary/details, explicit output selection/clear, reset, default output subfolder, manifest name, Counts/Issues preview, source-identity validation, project restore, and focused unit/GUI tests compared with `main` | +| RHS Preview | Complete | Setup/Protocol/Filter/Review/Log tabs, primary/filter/protocol source panels, preview panner/summary/status/actions, editable role and filter tables, lazy refresh, ROI edit/zoom and typed anchored scroll, full review details, standard protocol/filter manifests, annotation/source identity persistence, reset/restore, focused unit/GUI tests, and every-tab visual comparison with `main` | +| DIC Postprocess | Complete | Files + Analysis/Summary + Results/Log tabs, exact source actions and empty-state text, all 13 overlay and optical-image panners, summary table/status, stacked labeled strain overlays, parameter-driven overlay refresh, exports, project workflow, focused GUI test, and every-tab visual comparison with `main` | +| Focus Stack | Complete | Files + Analysis/Summary + Results/Log tabs, source summary, file/folder/tree actions and independent folder workflow, exact fusion presets and panners, workflow notes, full result summary/details, paired labeled previews, source-aware result invalidation, all three standard exports and manifest, durable-summary restore, focused unit/GUI tests, and every-tab visual comparison with `main` | +| Image Enhance | Complete | Library + Export/Tools + History/Log tabs, file/folder/tree source library, source/mode/tool/history/export status, output selection and format, dynamic preview modes and tool limits, managed per-image white ROI, shared and per-image apply/undo/reset histories, current-image metrics, deterministic batch export with CSV and standard manifests, project restore, focused unit/GUI tests, and every-tab visual comparison with `main` | +| Image Match | Complete | Library + Export/Match + History/Log tabs, compact reference and file/folder/tree source controls, selected-source lazy preview, source/reference-aware export invalidation, output selection and format, pending match preview, Match Flow, apply/undo/reset history, current-image metrics, Matched/Original/Before + After views, deterministic batch export with CSV and standard manifests, project restore, focused unit/GUI tests, and every-tab visual comparison with `main` | +| T-Test Wizard | Complete | Data/Test + Plot/Export/Log tabs, table and selection labels, Fast workflow, direct multi-surface Data workspace composition, equal growable source/analysis tables, exact table/plot/status/export titles, ordered multi-group editing, first-versus-each tests, plot freshness, paired CSV exports, focused framework/App unit and GUI tests, and every-tab visual comparison with `main` | +| Batch Image Crop | Complete | Files + Analysis/Scale/Summary + Results/Log tabs, task-oriented repeated-path file list with per-row readiness, exact file/task/navigation actions, old panners, Scale Mode and Current Image Scale sections, reference-edit action text, summary/details and preview titles, duplicate/navigation/single-task removal, physical calibration, export workflow, v1/v2-to-v3 project migration, focused framework/App unit and GUI tests, and every-tab visual comparison with `main` | +| Curvature Measurement | Complete | Files + Analysis/Summary + Results/Log tabs, exact image/curve/scale/fit/export controls, mutually exclusive curve/reference edit modes and dynamic action text, typed or measured calibration, scale-bar placement, densified circle fit, traced length, seven-row summary/details, complete residual/circle/center/scale overlay, paired exports and manifests, source-change invalidation, v1-to-v2 project migration, focused unit/GUI tests, and every-tab visual comparison with `main` | +| FLIR Thermal | Complete | Files + Display + Export/Details/Log tabs, FLIR source actions and navigation, exact palette/mapping/gamma/range controls, independent manual/hot/cold/mean readings, summary/details, paired clean-image/scale preview, current/all exports with CSV and standard manifests, transient decode/project restore, focused unit/GUI tests, and every-tab visual comparison with `main` | +| Gait Analysis | Complete | Source/Roles + Detection/Results + Export/Log tabs, compact Video Marker source, exact role/time/scale/detection fields, Workflow Notes, horizontal step navigation, summary/step tables, three labeled scroll-zoom previews, display-only plot graphics, current SamplePack debug fixture, CSV set and standard manifest, project restore, focused unit/GUI tests, and every-tab visual comparison with `main` | +| ECG Print | Complete | Files + Analysis/Summary + Results/Log tabs, exact Recording/Import Parsing/Channel + ROI/Signal Processing + SNR/Exports sections, compact readonly import state, ten paired numeric panners, Workflow Notes, four stacked labeled previews, complete analysis invalidation and parameter sanitation, recoverable refresh failure with atomic reconstruction failures, paired CSV/PNG exports with standard manifests, project restore, focused unit/framework/App GUI tests, and every-tab visual comparison with `main` | +| Video Marker | Complete | Setup + Scale/Video/Import + Export/Log tabs, exact skeleton and connection editing, old numeric panners and scale-bar controls, Session open/autosave/new-document actions, compact video source, frame navigation and prediction, ordered point editing, marker/coordinate imports and source-adjacent exports with manifests, document-scoped video reader/cache reuse, project restore, typed synthetic diagnostics, focused framework/App unit and GUI workflows, and every-tab visual comparison with `main` | + +The accepted public structure is capability-partitioned: +`labkit.app.Definition` and `CallbackContext` form the small root; `layout`, +`view`, `event`, `interaction`, `plot`, `project`, `result`, and `dialog` own +optional or specialized concepts. Layout nodes own direct callbacks and +renderers; Apps maintain no +parallel handler, renderer, or capability registries. Native adapters, +layout-node values, stores, queues, and reconciliation remain under +`labkit.app.internal`. There are no aliases from the new SDK back to old +`labkit.ui` symbols. + The replacement may rewrite the current runtime kernel, definition model, protocol, interaction system, event transport, capability injection, renderer commit path, and ownership model. Current source is evidence for required @@ -56,8 +226,8 @@ The following are explicitly rejected as end states: - new aliases that silently route old spellings or old interaction `Kind` values to new behavior; - a nominally small function list whose real public contract still lives in - arbitrary struct fields, string keys, callback arity probing, or private - implementation files; + arbitrary struct fields, string keys, runtime callback-shape probing, or + private implementation files; - keeping an unsuitable current boundary only to avoid editing tracked Apps; - a framework rewrite that also rewrites stable App science without cause; - two public UI runtimes shipped indefinitely. @@ -175,6 +345,17 @@ The following decisions apply before exact MATLAB symbol names are frozen: should learn a new optional constructor or method when using a genuinely new concept, not a new registry shape, dispatch convention, or hidden service protocol. +13. **The paved road minimizes App wiring.** Layout owns strict field + bindings, Application collects signal Commands, runtime combines defaults + and bindings with App-provided dynamic presentation, and ordinary Apps do + not repeat capability metadata. Internal SDK complexity is acceptable when + it removes repeated App callbacks, presenters, lifecycle code, or + synchronized registries. +14. **Injected types are visible at the function boundary.** Apps do not pass + SDK objects through untyped app-local parameters merely to assemble a + definition. Runtime callback `arguments` blocks declare + `CallbackContext` and the exact event payload type before the body uses + their methods or properties. ### Target architecture @@ -208,16 +389,15 @@ The App-facing layers should be limited to the following stable concepts. Exact symbol spelling is accepted only after the contract prototypes described below. -#### Application definition +#### App definition - Keep one obvious definition entry and one launch entry. - Required product metadata remains explicit in the definition signature. - Replace open `Project`, `Actions`, `Renderers`, and `Utilities` structs with validated public values or owned builder methods. -- A command has an ID, one callback role, and one documented signature. - Duplicate command IDs and missing command references fail during contract - compilation. -- Renderer IDs and preview targets are checked before UI creation. +- A layout control binds one concrete callback with one documented signature. + Plot areas bind their concrete renderer. Callback roles, target IDs, and + plot targets are checked before UI creation. - Static Apps do not need empty placeholder registries or presenter functions. #### Layout and the right-side workspace @@ -226,7 +406,7 @@ below. concrete `uigridlayout`, pixel, component, or registry fields. - Every layout constructor declares its complete name-value set and rejects unknown names. -- `labkit.ui.layout.workspace` remains the single public entry for organizing +- `labkit.app.layout.workspace` remains the single public entry for organizing the right-side workspace. A single-page App supplies one content layout. A multi-page App adds named pages through the returned workspace value or another operation owned by `workspace`; the replacement must not expose a parallel @@ -243,9 +423,9 @@ below. A provisional shape, used only to test the concept count, is: ```matlab -workspace = labkit.ui.layout.workspace(singleContent); +workspace = labkit.app.layout.workspace(singleContent); -workspace = labkit.ui.layout.workspace(); +workspace = labkit.app.layout.workspace(); workspace = workspace.page("data", "Data", dataContent); workspace = workspace.page("plot", "Plot", plotContent); workspace = workspace.initialPage("data"); @@ -254,9 +434,9 @@ workspace = workspace.initialPage("data"); This sketch recommends an owned `page` operation rather than another global constructor. It does not approve MATLAB classes by itself. -#### Presentation +#### View snapshot -- Replace arbitrary nested presenter structs with one immutable presentation +- Replace arbitrary nested presenter structs with one immutable view snapshot value. - Its public operations should cover stable semantic changes such as control value, enabled/visible state, choices, table model, text, plot model, @@ -276,12 +456,12 @@ A provisional discoverable value-style API is: ```matlab function view = present(state) - view = labkit.ui.presentation(); + view = labkit.app.view.Snapshot(); view = view.value("worksheet", state.session.sheet); view = view.choices("group", state.session.groupNames); - view = view.table("dataTable", state.session.tableModel); + view = view.tableData("dataTable", state.session.tableModel); view = view.enabled("runTest", state.session.canRun); - view = view.plot("resultPlot", "groupComparison", state.session.plotModel); + view = view.renderPlot("resultPlot", "groupComparison", state.session.plotModel); view = view.workspacePage("plot", Enabled=state.session.hasResult, ... Status=state.session.plotStatus); end @@ -350,6 +530,10 @@ equivalent in the replacement contract. transactional presentation, and rollback on failed actions. - Callback arity is not guessed by catching invocation errors. Any supported role variation is declared at construction and validated before launch. + A command may query the fixed function definition with `nargin(handle)` and + `nargout(handle)` exactly once during strict construction; negative + variable-arity results are rejected. Runtime dispatch never probes or + retries a callback with another shape. The contract prototype must choose one of these two explicit models: @@ -411,13 +595,15 @@ The contract prototype must compare: and methods; and - opaque values created only by strict public constructor/accessor functions. -The recommended direction is immutable value objects because MATLAB can expose -their methods and properties through help and introspection, and each object -can reject invalid state at construction. Repository policy requires explicit -approval before introducing production `classdef` APIs, so Phase 1 ends with a -representation decision and approval before implementation begins. The -function-only alternative is acceptable only if Apps never author, mutate, or -inspect its internal struct representation. +The accepted direction is a small set of sealed immutable value objects +because MATLAB exposes their methods and properties through help and +introspection, and each object rejects invalid state at construction. The +repository owner delegated the architecture decision after reviewing the +disposable value-class and opaque-function evidence on 2026-07-19. This +authorizes production `classdef` contract values for this migration, but not a +public inheritance hierarchy or mutable handle-state model. The function-only +alternative remains rejected because Apps could inspect closure backing state +through `functions`. ### Error, default, and recovery policy @@ -546,6 +732,10 @@ Gate: #### Phase 1 - contract RFC and executable prototypes +Phase 1A selects the public representation. Phase 1B proves the complete +contract through an end-to-end prototype and runtime/performance evidence. +Representation acceptance alone does not open the Phase 2 production gate. + Create a reviewed replacement-contract RFC with: - the complete proposed public vocabulary; @@ -580,10 +770,23 @@ Prototype acceptance: contract explicit; - presentation remains deterministic and testable without a visible GUI; - performance thresholds are set from Phase 0 evidence before implementation. +- value and alternative representations compile identical target graphs; +- public programming errors use stable `labkit:ui:contract:*` identifiers; +- presentation is a complete snapshot reconciled by the runtime, never an + App-authored patch protocol; +- Apps declare required runtime-context capabilities and cannot retain an + acquired render surface. The prototypes are evidence, not compatibility shims. Reject and redesign the contract if they require per-App exceptions. +Phase 1 acceptance evidence is +`.agents/migration/ui-explicit-contract/phase-1-prototype-evidence.*` and +`phase-1-end-to-end-evidence.*`. The end-to-end experiment establishes the +implementation route: compile the static Application graph once, validate +complete presentation snapshots against that graph, and keep diff/reconcile +private to the runtime. + #### Phase 2 - strict contract kernel Implement the accepted public values and compiler independently of concrete diff --git a/.agents/skills/labkit-app-builder/SKILL.md b/.agents/skills/labkit-app-builder/SKILL.md index a064b0ef9..8246636e1 100644 --- a/.agents/skills/labkit-app-builder/SKILL.md +++ b/.agents/skills/labkit-app-builder/SKILL.md @@ -42,46 +42,66 @@ Begin with the smallest complete shape: ```text labkit__app.m +/definition.m -+/+userInterface/buildWorkbenchLayout.m ++/+workbench/buildLayout.m ``` Add only capabilities the product needs: ```text -+/definitionActions.m +/projectSpec.m +/createSession.m -+/+userInterface/presentWorkbench.m -+/+userInterface/.m ++/+workbench/present.m +/+/... ``` -The entrypoint only launches one definition. `definition.m` owns identity, -version, requirements, layout, and references to optional capabilities. -`definitionActions.m` registers semantic commands and coordinates app-owned -workflow code. One `projectSpec.m` owns local create, validate, and +The entrypoint only calls `definition().launch(...)`. `definition.m` owns +identity, version, requirements, layout, and references to optional +capabilities. Layout controls bind concrete semantic callbacks directly; +there is no handler or renderer registry. `labkit.app.layout.*` bindings and +runtime lifecycle behavior require no placeholder callbacks. One `projectSpec.m` +returns a `labkit.app.project.Schema` owning +local create, validate, and version-aware migrate functions when durable state exists; Runtime owns the -migration loop. Root `createSession.m` rebuilds only App-specific transient -data. Layout is data-only; presentation is a pure state-to-view mapping. +migration loop. Root `createSession.m` uses the fixed `(project,context)` +signature and rebuilds only App-specific transient data; opaque source paths +are resolved with `context.resolveSourcePaths`. File lists bind portable +sources and selection directly. Layout nodes are data-only; +`labkit.app.view.Snapshot` is a pure state-to-view mapping. + +`+workbench/buildLayout.m` should read as the product's user workflow. For a +complex App it composes layout fragments from capability packages in user +order. `+workbench/present.m` composes their snapshot fragments with +`Snapshot.include`. Renderers live with the plot capability they draw. + +On the App SDK paved road, bind ordinary project/session fields directly in +`labkit.app.layout.*` and let runtime defaults complete the view snapshot. +Add a direct callback only for real business effects and a view operation only +for derived visible state. At callback boundaries name `applicationState`, +the exact typed event value, and `callbackContext`; delegate calculations, +state transforms, and exports through narrow explicit inputs rather than +passing the full state or context deeper than needed. Do not add separate `requirements.m`, `version.m`, generic `+appLifecycle` or -`+appState` packages, per-version migration files, or a Start callback that +`+appState` packages, per-version migration files, or a `StartupHandler` that only constructs default state. Add a semantically named Start function only for real post-layout request or resource initialization. Use concrete workflow packages such as `sourceFiles`, `analysisRun`, `cropGeometry`, or `resultFiles`. Keep small callback glue local. Do not create technical buckets, package-root runners, alternate interaction runtimes, -control mutation facades, or helpers merely to meet a line budget. +control mutation facades, or helpers merely to meet a line budget. Public SDK +names must state their capability directly; do not add general buckets such as +`Manager`, `Service`, `Helper`, or `Data`. ## Build order 1. Define identity, version, requirements, layout, and only the optional project/session capabilities the App needs. -2. Declare the semantic layout and action registry. +2. Declare the semantic layout with direct business callbacks. 3. Implement GUI-free readers/calculations/result builders with synthetic tests. -4. Implement the presenter, registered renderers, and managed interactions. +4. Implement feature-owned snapshot fragments, renderers, and managed + interactions. 5. Keep selection cheap and batch loading lazy; separate preview-resolution work from original-resolution Run/Export. 6. Add portable project references, relinking, current-envelope save, and only diff --git a/.agents/skills/labkit-boundary-guard/SKILL.md b/.agents/skills/labkit-boundary-guard/SKILL.md index d30ab56da..5fceb387e 100644 --- a/.agents/skills/labkit-boundary-guard/SKILL.md +++ b/.agents/skills/labkit-boundary-guard/SKILL.md @@ -23,20 +23,41 @@ Before moving code into `+labkit`, prove that it: - makes the public API easier to understand than app-local ownership. Otherwise keep it in the app. Duplication, helper length, and a desire to make -`definitionActions.m` shorter are not sufficient evidence. +an App callback file shorter are not sufficient evidence. -Keep domain facades GUI-free and app-free. Keep `labkit.ui` parser/science-free; -its public layers are `runtime`, `layout`, `plot`, `interaction`, and `debug`. +Keep domain facades GUI-free and app-free. `labkit.app` is the sole App SDK. Concrete controls, registries, queues, interaction runtimes, persistence storage, and lifecycle handles stay private. App metadata stays in app-owned `definition.m`; separate App `version.m` and requirements registries are retired. `labkit.contract` validates facade ranges; it is not an App metadata registry. +For the active App SDK migration, distinguish contract approval +from release approval. Sealed immutable values and the end-to-end contract +passed Phase 1; the production API remains migration-scoped until the Phase 2 +kernel gate. `labkit.app.view.Snapshot` is complete, layout nodes own direct +callbacks and renderers, acquired render surfaces cannot escape their event +scope, and static layout compilation is cached rather than repeated per +presentation. + Public API additions require a complete MATLAB help contract, focused tests, facade version update, owning docs, and component history. Internal refactors that preserve the public boundary do not require a new API or governance rule. +For the App SDK, also apply the authoring extension gate: require repeated need +from at least two Apps or one framework-owned lifecycle/consistency problem. +Measure the paved-road effect. Prefer a strict binding, inferred registration, +or framework default when it removes repeated App callbacks/presenter glue; +do not make ordinary authors maintain a second handler or capability list. +Keep the public root small and partition optional capabilities by purpose: +`layout`, `view`, `event`, `interaction`, `plot`, `project`, `result`, and +`dialog`. Reject names that require reading implementation code to distinguish +their purpose. + +Apply the same discoverability rule inside migrated Apps: do not forward SDK +values through untyped app-local parameters. Declare runtime-injected contexts +and event payloads by concrete type in callback `arguments` blocks. + ## Validation and handoff Run the owning framework suite, project boundary guardrails, and downstream app diff --git a/.agents/skills/labkit-documentation-maintainer/SKILL.md b/.agents/skills/labkit-documentation-maintainer/SKILL.md index 6df14a971..15502ebec 100644 --- a/.agents/skills/labkit-documentation-maintainer/SKILL.md +++ b/.agents/skills/labkit-documentation-maintainer/SKILL.md @@ -19,6 +19,11 @@ pages are discovered from `docs/`; public Apps come from from complete public help contracts. `site/` is tracked generated output. Never hand-edit HTML, CSS, JavaScript, or search indexes. +Migration evidence summaries are generated views of one machine-readable +inventory. When an audit schema or classifier changes, regenerate the +baseline, capability matrix, behavior classification, and worksheet together, +then run their aggregate-consistency test; do not reconcile counts by hand. + ## Page design - Organize by reader task and component ownership: getting started, apps, @@ -30,6 +35,11 @@ Never hand-edit HTML, CSS, JavaScript, or search indexes. APIs, limitations, troubleshooting, and component history. - Private implementation helpers do not need public reference pages. - Prefer contextual cross-links and map/index pages over duplicated prose. +- Organize the UI SDK progressively: minimal Application/Layout first, + Command/Presentation/RuntimeContext for normal dynamic Apps, and project, + result, resource, interaction, and payload details only in advanced paths. +- Canonical minimal, standard, and advanced examples must use exact production + symbols and be executable tests; do not preserve approximate RFC syntax. ## History diff --git a/.agents/skills/labkit-test-planner/SKILL.md b/.agents/skills/labkit-test-planner/SKILL.md index e5d4dd248..42dacc15c 100644 --- a/.agents/skills/labkit-test-planner/SKILL.md +++ b/.agents/skills/labkit-test-planner/SKILL.md @@ -42,6 +42,10 @@ value directly. Do not rerun the whole workflow until that narrower check passes. Combine corrected behaviors into one final GUI run instead of rerunning after every edit. +Migration source scanners require semantic fixtures: ordinary local helpers +must not become UI callbacks, definition values must not become field names, +and every generated aggregate must agree on category uses and App unions. + Common folder scopes are: ```text diff --git a/AGENTS.md b/AGENTS.md index fb9c69f3d..c66b04b21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,13 +26,22 @@ retirement. A zero-debt ledger is not an everyday checklist. - Apps own formulas, thresholds, units, workflow decisions, plots, results, exports, failures, and wording. Promote code into `+labkit` only when it is a stable domain-neutral contract useful beyond one app. -- App-facing packages are `labkit.ui`, `image`, `thermal`, `dta`, `rhs`, and - `biosignal`. Do not create public `analysis`, `data`, `io`, `util`, or - app-specific helper surfaces. -- Apps use `labkit.ui.runtime.launch/define`, semantic layouts, presenter - models, injected services, and managed interactions/resources. They do not - receive registries or own lifecycle timers, readiness, callback queues, or - concrete framework layout. +- App-facing packages are `labkit.app`, `image`, `thermal`, `dta`, `rhs`, + and `biosignal`. Do not + create public `analysis`, `data`, `io`, `util`, or app-specific helper + surfaces. +- Migrated Apps use `labkit.app.Definition`, `labkit.app.layout.*`, + `labkit.app.view.Snapshot`, typed `labkit.app.event.*` payloads, and + `labkit.app.CallbackContext`. Layout controls bind directly to concrete + semantic callbacks; there is no App-authored handler or renderer registry. + Entrypoints call `definition().launch(...)`. Apps do not own lifecycle + timers, readiness, callback queues, or concrete framework layout. +- `+workbench/buildLayout.m` is the visible product assembly boundary. + Complex Apps compose feature-owned layout and snapshot fragments from + capability packages; renderers live with the plot capability they draw. + Runtime callbacks name `applicationState`, typed event values, and + `callbackContext` explicitly, then delegate scientific work through narrow + inputs rather than forwarding SDK objects or a generic App object. - Keep app entrypoints thin and app helpers under the owning app package. Name packages and functions for the capability they own, not `helpers`, `utils`, `process`, `handle`, or `manage`. diff --git a/apps/AGENTS.md b/apps/AGENTS.md index ee6c5abff..94081432e 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -13,30 +13,46 @@ library manual for APIs the app actually uses. App tests live under ## Required app shape - Keep `labkit_*_app.m` as a thin wrapper around - `labkit.ui.runtime.launch`. + `definition().launch(...)`. - `definition.m` is the single product contract. It declares stable identity, version, requirements, layout, and references to optional project, session, - action, presenter, renderer, debug-sample, utility, and Start capabilities. + presenter, debug-sample, and Start capabilities. It performs no IO, computation, export, handle creation, or lifecycle mutation. - A static App needs only the entrypoint, definition, and - `+userInterface/buildWorkbenchLayout.m`. Add `definitionActions.m` only for - semantic commands or bound events, and keep short callback glue local. -- Add one `projectSpec.m` only for durable App-owned state. It owns local + `+workbench/buildLayout.m`. Layout controls reference concrete semantic + callbacks directly; do not create `definitionActions.m`, `stateHandlers.m`, + callback bags, or renderer registries. +- Add one `projectSpec.m` only for durable App-owned state. It returns a + `ProjectContract` owning local create, validate, version-aware migrate, resume, relink, and declared read-only legacy-import functions as needed; Runtime owns the migration loop. -- Add root `createSession.m` only to reconstruct App-specific transient data. - Runtime supplies the canonical selection, workflow, view, and cache buckets. -- `+userInterface/buildWorkbenchLayout.m` returns a data-only semantic layout. - Add `presentWorkbench.m` only for dynamic views; it maps state to control, - preview, renderer, and interaction models without IO or heavy computation. +- Add root `createSession.m` only to reconstruct App-specific transient data + with the fixed `(project,context)` signature. Resolve opaque portable + sources through `context.resolveSourcePaths`. +- `+workbench/buildLayout.m` returns the data-only product assembly. Add + `+workbench/present.m` only for dynamic views; it composes feature-owned + snapshot fragments without IO or heavy computation. Put each renderer in + the capability package that owns the plotted meaning. +- Treat complete runtime state as an adapter value, not a domain model. Only + `createSession`, `+workbench/present`, `OnStart`, and functions referenced + directly by layout signals may accept it. Name it `applicationState`, + destructure it immediately, and pass exact values into feature presenters, + calculations, renderers, and writers. +- Direct callbacks expose `applicationState`, then the typed event value when + present, then `callbackContext`. Keep short transactional mutation there; + do not forward the complete state or context into a generic action layer. - Do not add separate `requirements.m`, `version.m`, generic `+appLifecycle` or `+appState` packages, per-version migration files, or a Start callback that only constructs default state. - Workflow packages use capability names such as `sourceFiles`, `analysisRun`, `cropGeometry`, `thermalFrames`, or `resultFiles`. Do not create technical - buckets such as `actions`, `ops`, `io`, `ui`, `view`, `export`, `helpers`, - `utils`, `manager`, or `processor`. + buckets such as `actions`, `ops`, `io`, `ui`, `userInterface`, `view`, + `export`, `helpers`, `utils`, `manager`, or `processor`. +- The package tree follows the product: input capability, edit or analysis + capability, preview or result capability, then result files. A capability + package may own its layout fragment, direct actions, presentation fragment, + and renderer when those files change together. - Do not add package-root lifecycle `run.m`, `+ui/runApp.m`, app-family `private/` workflow helpers, string dispatchers, or app-specific packages outside the owning app tree. @@ -64,22 +80,24 @@ library manual for APIs the app actually uses. App tests live under - Repeatable Run/Export workflows use immutable task snapshots and deterministic fingerprints when stale or duplicated work is possible. -## UI and persistence +## Workbench and persistence -- Use framework callbacks, presenter-owned interactions, and injected services. +- Use direct semantic callbacks, strict bindings, complete + `labkit.app.view.Snapshot` values, and `labkit.app.CallbackContext`. + File lists own portable source and selection bindings; `createSession` + rebuilds transient data after source changes. Do not mutate registries, restore figure callbacks, create interaction runtimes, or add startup timers/readiness flags. - Interactive rectangles use managed `rectangle` or `regionSelection` specs. Display-only rectangles disable hit testing. - Placing or editing overlays must preserve the user's viewport unless the user explicitly requests fit/reset. -- File and folder dialogs outside `filePanel` use `services.dialogs`; alerts use - `services.dialogs.alert`. +- File and folder dialogs outside `fileList` and alerts use CallbackContext. - External files in saved projects use portable references and field-specific relinking. Current saves use the project envelope; compatibility importers are read-only. - Caught exceptions that allow the app to continue are reported through the - injected diagnostic service before alerting or logging recovery. + RuntimeContext before alerting or logging recovery. ## Version, docs, and tests diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/generate.m b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/generate.m new file mode 100644 index 000000000..2f2cd0a9f --- /dev/null +++ b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/generate.m @@ -0,0 +1,59 @@ +% App-owned implementation for dic_postprocess.analysisRun.generate within the dic_postprocess product workflow. +function applicationState = generate(applicationState, callbackContext) +%GENERATE Load strain data and prepare overlays plus the ROI summary. +cache = applicationState.session.cache; +matPath = pathForRole( ... + applicationState.project.inputs.sources, "strain", callbackContext); +if strlength(matPath) == 0 || isempty(cache.referenceImage) || ... + isempty(cache.maskImage) + callbackContext.alert( ... + "Load the DIC MAT file, reference image, and mask image first.", ... + "Missing inputs"); + return; +end +if ~validColorRange(applicationState.project.parameters) + callbackContext.alert( ... + "Color max must be greater than color min.", ... + "Invalid color range"); + return; +end +try + applicationState.session.cache.strain = ... + dic_postprocess.sourceFiles.loadNcorrStrain(matPath); + applicationState = prepare(applicationState); + callbackContext.appendStatus( ... + "Generated EXX/EYY overlays and ROI summary."); +catch exception + callbackContext.reportError("Generate failed", exception); + callbackContext.alert(exception.message, "DIC postprocess error"); + callbackContext.appendStatus("Generate failed: " + exception.message); +end +end + +function filepath = pathForRole(sources, role, context) +filepath = ""; +if isempty(sources) + return; +end +match = find(string({sources.role}) == role, 1); +if isempty(match) + return; +end +paths = context.resolveSourcePaths(sources(match)); +if ~isempty(paths) + filepath = paths(1); +end +end + +function state = prepare(state) +[summary, overlayExx, overlayEyy] = ... + dic_postprocess.analysisRun.prepareOutputs( ... + state.session.cache, state.project.parameters); +state.project.results.summaryTable = summary; +state.session.cache.overlayExx = overlayExx; +state.session.cache.overlayEyy = overlayEyy; +end + +function accepted = validColorRange(parameters) +accepted = parameters.colorMax > parameters.colorMin; +end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/refreshOutputs.m b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/refreshOutputs.m new file mode 100644 index 000000000..25c153953 --- /dev/null +++ b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/refreshOutputs.m @@ -0,0 +1,28 @@ +% App-owned implementation for dic_postprocess.analysisRun.refreshOutputs within the dic_postprocess product workflow. +function applicationState = refreshOutputs( ... + applicationState, ~, callbackContext) +%REFRESHOUTPUTS Recompute prepared overlays after a display option changes. +cache = applicationState.session.cache; +if ~isfield(cache.strain, "exx") || ... + isempty(cache.referenceImage) || isempty(cache.maskImage) + return; +end +if applicationState.project.parameters.colorMax <= ... + applicationState.project.parameters.colorMin + callbackContext.appendStatus( ... + "Option update skipped: Color max must exceed color min."); + return; +end +try + [summary, overlayExx, overlayEyy] = ... + dic_postprocess.analysisRun.prepareOutputs( ... + cache, applicationState.project.parameters); + applicationState.project.results.summaryTable = summary; + applicationState.session.cache.overlayExx = overlayExx; + applicationState.session.cache.overlayEyy = overlayEyy; +catch exception + callbackContext.reportError("Option update skipped", exception); + callbackContext.appendStatus( ... + "Option update skipped: " + exception.message); +end +end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+debug/writeSamplePack.m b/apps/dic/dic_postprocess/+dic_postprocess/+debug/writeSamplePack.m index 5df834d97..ca77a54e2 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+debug/writeSamplePack.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+debug/writeSamplePack.m @@ -1,19 +1,23 @@ -% Expected caller: dic_postprocess.definitionActions startup action and unit tests. -% Input is a LabKit debug context. Output is a deterministic synthetic Ncorr -% MAT/reference/mask sample pack. Side effects: writes anonymous debug inputs -% and records a session manifest when available. -function pack = writeSamplePack(debugLog) +% Expected caller: the App SDK debug-sample capability and unit tests. +% Input is a bounded diagnostic SampleContext. Output is a deterministic +% synthetic Ncorr SamplePack with a current project. Side effects: writes +% anonymous synthetic inputs beneath the context sample folder. +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write DIC postprocess debug files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "dic_postprocess"); - sampleFolder = fullfile(char(folders.sampleFolder), "dic_postprocess"); - ensureFolder(sampleFolder); - - matPath = string(fullfile(sampleFolder, "dic_valid_ncorr_strain_debug.mat")); - referencePath = string(fullfile(sampleFolder, "dic_reference_debug.png")); - maskPath = string(fullfile(sampleFolder, "dic_mask_debug.png")); - edgeMatPath = string(fullfile(sampleFolder, "dic_valid_edge_sparse_roi_debug.mat")); - malformedMatPath = string(fullfile(sampleFolder, "dic_malformed_missing_strains_debug.mat")); + matPath = sampleContext.samplePath( ... + "dic_postprocess/strain.mat"); + referencePath = sampleContext.samplePath( ... + "dic_postprocess/reference.png"); + maskPath = sampleContext.samplePath( ... + "dic_postprocess/mask.png"); + edgeMatPath = sampleContext.samplePath( ... + "dic_postprocess/sparse_roi.mat"); + malformedMatPath = sampleContext.samplePath( ... + "dic_postprocess/malformed.mat"); [reference, mask, exx, eyy] = syntheticDicPostprocessData(180, 240, false); imwrite(reference, char(referencePath)); @@ -25,22 +29,26 @@ data_dic_save = struct("metadata", struct("note", "malformed synthetic boundary")); save(char(malformedMatPath), "data_dic_save"); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_DICPostprocess_app", ... - "description", "Anonymous Ncorr-style DIC strain boundary pack for debug launch.", ... - "sampleFolder", string(sampleFolder), ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", struct("mat", matPath, "reference", referencePath, "mask", maskPath), ... - "boundaryFiles", struct( ... - "validEdgeSparseRoiMat", edgeMatPath, ... - "malformedMissingStrainsMat", malformedMatPath)); - recordManifest(debugLog, manifest); - - pack = manifest; - pack.matFile = matPath; - pack.referenceFile = referencePath; - pack.maskFile = maskPath; + project = dic_postprocess.projectSpec().Create(); + project.inputs.sources = [ ... + sampleContext.sourceRecord("dicMat", "strain", matPath, true), ... + sampleContext.sourceRecord( ... + "referenceImage", "reference", referencePath, true), ... + sampleContext.sourceRecord( ... + "maskImage", "mask", maskPath, true)]; + artifacts = { ... + sampleContext.artifact("strain", "strain", matPath), ... + sampleContext.artifact( ... + "referenceImage", "reference", referencePath), ... + sampleContext.artifact("maskImage", "mask", maskPath), ... + sampleContext.artifact( ... + "sparseRoiStrain", "boundaryInput", edgeMatPath), ... + sampleContext.artifact( ... + "malformedStrain", "boundaryInput", malformedMatPath, ... + Expectation="rejects")}; + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-strain-overlay", ... + InitialProject=project, Artifacts=artifacts); end function [reference, mask, exx, eyy] = syntheticDicPostprocessData(h, w, sparseRoi) @@ -67,33 +75,3 @@ function writeNcorrMat(filepath, exx, eyy, mask) "roi_ref_formatted", struct("mask", mask)); save(char(filepath), "data_dic_save"); end - -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+overlayPreview/draw.m b/apps/dic/dic_postprocess/+dic_postprocess/+overlayPreview/draw.m new file mode 100644 index 000000000..9ab9a649a --- /dev/null +++ b/apps/dic/dic_postprocess/+dic_postprocess/+overlayPreview/draw.m @@ -0,0 +1,19 @@ +% App-owned implementation for dic_postprocess.overlayPreview.draw within the dic_postprocess product workflow. +function draw(axesById, model) +%DRAW Render the EXX and EYY overlays on framework-owned axes. +drawImage(axesById.exx, model.exx); +drawImage(axesById.eyy, model.eyy); +end + +function drawImage(ax, model) +labkit.app.plot.clearAxes(ax, ResetScale=true); +if ~isempty(model.imageData) + image(ax, model.imageData); + axis(ax, "image"); + ax.YDir = "reverse"; +end +title(ax, model.title); +xlabel(ax, ""); +ylabel(ax, ""); +box(ax, "on"); +end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+overlayPreview/summaryTableData.m b/apps/dic/dic_postprocess/+dic_postprocess/+overlayPreview/summaryTableData.m new file mode 100644 index 000000000..61f21fb36 --- /dev/null +++ b/apps/dic/dic_postprocess/+dic_postprocess/+overlayPreview/summaryTableData.m @@ -0,0 +1,10 @@ +% App-owned implementation for dic_postprocess.overlayPreview.summaryTableData within the dic_postprocess product workflow. +function data = summaryTableData(summary) +%SUMMARYTABLEDATA Convert the scientific summary to display cell data. +if isempty(summary) || height(summary) == 0 + data = {}; + return; +end +data = [cellstr(summary.Metric), ... + num2cell(summary.EXX), num2cell(summary.EYY)]; +end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/exportSummary.m b/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/exportSummary.m new file mode 100644 index 000000000..731596962 --- /dev/null +++ b/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/exportSummary.m @@ -0,0 +1,52 @@ +% App-owned implementation for dic_postprocess.resultFiles.exportSummary within the dic_postprocess product workflow. +function applicationState = exportSummary( ... + applicationState, callbackContext) +%EXPORTSUMMARY Write the ROI strain summary CSV and result manifest. +summary = applicationState.project.results.summaryTable; +if isempty(summary) || height(summary) == 0 + callbackContext.alert( ... + "Generate a summary before exporting.", "Export summary"); + return; +end +matPath = pathForRole( ... + applicationState.project.inputs.sources, "strain", callbackContext); +[folder, name] = fileparts(matPath); +defaultName = fullfile(folder, name + "_strain_summary.csv"); +choice = callbackContext.chooseOutputFile( ... + ["*.csv", "CSV files (*.csv)"], defaultName); +if choice.Cancelled + callbackContext.appendStatus("Export summary cancelled."); + return; +end +filepath = string(choice.Value); +writetable(summary, filepath); +[folder, name, extension] = fileparts(filepath); +outputName = string(name) + string(extension); +output = labkit.app.result.File( ... + "strainSummary", "primary", outputName, MediaType="text/csv"); +package = labkit.app.result.Package( ... + Outputs={output}, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct("metricCount", height(summary)), ... + ManifestName=string(name) + ".labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.summaryManifestPath = ... + string(written.Value); +callbackContext.appendStatus("Exported summary CSV: " + filepath); +end + +function filepath = pathForRole(sources, role, context) +filepath = ""; +if isempty(sources) + return; +end +match = find(string({sources.role}) == role, 1); +if isempty(match) + return; +end +paths = context.resolveSourcePaths(sources(match)); +if ~isempty(paths) + filepath = paths(1); +end +end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/saveOverlays.m b/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/saveOverlays.m new file mode 100644 index 000000000..2533cc727 --- /dev/null +++ b/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/saveOverlays.m @@ -0,0 +1,59 @@ +% App-owned implementation for dic_postprocess.resultFiles.saveOverlays within the dic_postprocess product workflow. +function applicationState = saveOverlays( ... + applicationState, callbackContext) +%SAVEOVERLAYS Write both prepared overlay PNGs and their result manifest. +cache = applicationState.session.cache; +if isempty(cache.overlayExx) || isempty(cache.overlayEyy) + callbackContext.alert( ... + "Generate overlays before saving.", "Save overlays"); + return; +end +choice = callbackContext.chooseOutputFolder(""); +if choice.Cancelled + callbackContext.appendStatus("Save overlay PNGs cancelled."); + return; +end +folder = string(choice.Value); +matPath = pathForRole( ... + applicationState.project.inputs.sources, "strain", callbackContext); +tag = dic_postprocess.resultFiles.tagFromPath(matPath); +exxName = "overlay_exx_" + tag + ".png"; +eyyName = "overlay_eyy_" + tag + ".png"; +dic_postprocess.resultFiles.exportOverlayImage( ... + cache.overlayExx, fullfile(folder, exxName)); +dic_postprocess.resultFiles.exportOverlayImage( ... + cache.overlayEyy, fullfile(folder, eyyName)); +outputs = { ... + labkit.app.result.File( ... + "exxOverlay", "primary", exxName, MediaType="image/png"), ... + labkit.app.result.File( ... + "eyyOverlay", "primary", eyyName, MediaType="image/png")}; +package = labkit.app.result.Package( ... + Outputs=outputs, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct("metricCount", ... + height(applicationState.project.results.summaryTable)), ... + ManifestName="dic_overlays_" + tag + ".labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.overlayManifestPath = ... + string(written.Value); +callbackContext.appendStatus( ... + "Saved clean overlay PNGs: " + ... + fullfile(folder, exxName) + " and " + fullfile(folder, eyyName)); +end + +function filepath = pathForRole(sources, role, context) +filepath = ""; +if isempty(sources) + return; +end +match = find(string({sources.role}) == role, 1); +if isempty(match) + return; +end +paths = context.resolveSourcePaths(sources(match)); +if ~isempty(paths) + filepath = paths(1); +end +end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/tagFromPath.m b/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/tagFromPath.m new file mode 100644 index 000000000..5a4f2205b --- /dev/null +++ b/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/tagFromPath.m @@ -0,0 +1,11 @@ +% App-owned implementation for dic_postprocess.resultFiles.tagFromPath within the dic_postprocess product workflow. +function tag = tagFromPath(filepath) +%TAGFROMPATH Derive a safe export tag from the final millimeter token. +tokens = regexp(filepath, '(\d+(?:\.\d+)?mm)', 'tokens'); +if isempty(tokens) + tag = "unknown_mm"; +else + tag = string(tokens{end}{1}); +end +tag = regexprep(tag, '[^A-Za-z0-9_.-]', '_'); +end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+sourceFiles/invalidateResults.m b/apps/dic/dic_postprocess/+dic_postprocess/+sourceFiles/invalidateResults.m new file mode 100644 index 000000000..ecf9fd19e --- /dev/null +++ b/apps/dic/dic_postprocess/+dic_postprocess/+sourceFiles/invalidateResults.m @@ -0,0 +1,10 @@ +% App-owned implementation for dic_postprocess.sourceFiles.invalidateResults within the dic_postprocess product workflow. +function applicationState = invalidateResults( ... + applicationState, ~, callbackContext) +%INVALIDATERESULTS Clear outputs after any source collection changes. +applicationState.project.results.summaryTable = table(); +applicationState.session.cache.strain = struct(); +applicationState.session.cache.overlayExx = []; +applicationState.session.cache.overlayEyy = []; +callbackContext.appendStatus("Updated DIC postprocess inputs."); +end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+sourceFiles/loadProjectInputs.m b/apps/dic/dic_postprocess/+dic_postprocess/+sourceFiles/loadProjectInputs.m index 37af2330e..1965d3351 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+sourceFiles/loadProjectInputs.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+sourceFiles/loadProjectInputs.m @@ -1,22 +1,22 @@ -% Expected caller: dic_postprocess.createSession. Inputs are -% resolved source records and whether saved results require strain reload. +% Expected caller: dic_postprocess.createSession. Inputs are resolved paths +% by semantic role and whether saved results require strain reload. % Output is rebuildable decoded cache data; missing records stay empty. -function cache = loadProjectInputs(sources, loadStrain) +function cache = loadProjectInputs(paths, loadStrain) cache = struct( ... "strain", struct(), ... - "referenceImage", readImage(sources, "referenceImage"), ... - "maskImage", readImage(sources, "maskImage"), ... + "referenceImage", readImage(paths.referenceImage), ... + "maskImage", readImage(paths.maskImage), ... "overlayExx", [], ... "overlayEyy", []); - filepath = labkit.ui.runtime.sourcePaths(sources, "dicMat"); + filepath = string(paths.dicMat); if loadStrain && strlength(filepath) > 0 && isfile(filepath) cache.strain = dic_postprocess.sourceFiles.loadNcorrStrain(filepath); end end -function imageData = readImage(sources, id) +function imageData = readImage(filepath) imageData = []; - filepath = labkit.ui.runtime.sourcePaths(sources, id); + filepath = string(filepath); if strlength(filepath) > 0 && isfile(filepath) imageData = imread(filepath); end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m b/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 17651e85c..000000000 --- a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,111 +0,0 @@ -% Expected caller: dic_postprocess.definition. Input is a callback struct whose -% fields are app-owned callback handles. Output is a data-only UI 5 workbench -% layout for the DIC Postprocess app. -function layout = buildWorkbenchLayout(callbacks, ~) - - layout = labkit.ui.layout.workbench("dicPostprocessApp", ... - "DIC Strain Postprocess", ... - "controlTabs", controlTabs(callbacks), ... - "workspace", strainWorkspace()); -end - -function tabs = controlTabs(callbacks) - tabs = {filesAnalysisTab(callbacks), summaryResultsTab(), logTab()}; -end - -function tab = filesAnalysisTab(callbacks) - tab = labkit.ui.layout.tab("filesAnalysis", "Files + Analysis", { ... - inputsSection(callbacks), ... - overlayOptionsSection(), ... - imageOptionsSection(), ... - exportsSection(callbacks)}); -end - -function tab = summaryResultsTab() - tab = labkit.ui.layout.tab("summaryResults", "Summary + Results", { ... - labkit.ui.layout.section("summarySection", "ROI Strain Summary", { ... - labkit.ui.layout.resultTable("resultTable", ... - "ROI Strain Summary", ... - "columns", {'Metric', 'EXX', 'EYY'}), ... - labkit.ui.layout.statusPanel("summaryText", "Summary", ... - "value", {'No DIC result loaded.'})})}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log", ... - "value", {'Ready.'})})}); -end - -function section = inputsSection(callbacks) - section = labkit.ui.layout.section("inputsSection", "Inputs", { ... - labkit.ui.layout.filePanel("matFile", "DIC MAT file", ... - "mode", "single", ... - "filters", {'*.mat', 'MAT files (*.mat)'}, ... - "chooseLabel", "Choose DIC MAT", ... - "status", "No MAT file loaded", ... - "onChoose", callbacks.matChosen), ... - labkit.ui.layout.filePanel("referenceFile", "Reference image", ... - "mode", "single", ... - "filters", {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', ... - 'Image files'}, ... - "chooseLabel", "Choose reference", ... - "status", "No reference image loaded", ... - "onChoose", callbacks.referenceChosen), ... - labkit.ui.layout.filePanel("maskFile", "Mask image", ... - "mode", "single", ... - "filters", {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', ... - 'Image files'}, ... - "chooseLabel", "Choose mask", ... - "status", "No mask image loaded", ... - "onChoose", callbacks.maskChosen), ... - labkit.ui.layout.action("generate", "Generate overlays + summary", ... - callbacks.generate)}); -end - -function section = overlayOptionsSection() - section = labkit.ui.layout.section("overlayOptions", "Overlay Options", { ... - optionPanner("alpha", "Alpha:", [0 1], 0.05), ... - optionPanner("colorMin", "Color min:", [-10 10], 0.01), ... - optionPanner("colorMax", "Color max:", [-10 10], 0.01), ... - optionPanner("oversample", "Oversample:", [1 20], 1), ... - optionPanner("smoothSigma", "Smooth sigma:", [0 100], 0.1), ... - optionPanner("edgeTrim", "Edge trim:", [0 5], 1)}); -end - -function section = imageOptionsSection() - section = labkit.ui.layout.section("imageOptions", ... - "Optical Image Enhancement", { ... - optionPanner("brightness", "Brightness:", [-1 1], 0.05), ... - optionPanner("contrast", "Contrast:", [0.05 5], 0.05), ... - optionPanner("gamma", "Gamma:", [0.05 5], 0.05), ... - optionPanner("saturation", "Saturation:", [0 5], 0.05), ... - optionPanner("redGain", "Red gain:", [0 5], 0.05), ... - optionPanner("greenGain", "Green gain:", [0 5], 0.05), ... - optionPanner("blueGain", "Blue gain:", [0 5], 0.05)}); -end - -function section = exportsSection(callbacks) - section = labkit.ui.layout.section("exportsSection", "Exports", { ... - labkit.ui.layout.action("saveOverlays", "Save overlay PNGs", ... - callbacks.saveOverlays), ... - labkit.ui.layout.action("exportSummary", "Export summary CSV", ... - callbacks.exportSummary)}); -end - -function workspace = strainWorkspace() - workspace = labkit.ui.layout.workspace("strainOverlays", ... - "Strain Overlays", { ... - labkit.ui.layout.previewArea("overlayAxes", "Strain Overlays", ... - "layout", "stack", "count", 2, ... - "axisIds", {'exx', 'eyy'}, ... - "axisTitles", {'EXX Overlay', 'EYY Overlay'})}); -end - -function layoutNode = optionPanner(id, labelText, limits, step) - layoutNode = labkit.ui.layout.panner(id, labelText, ... - "step", step, "limits", limits, ... - "Bind", "project.parameters." + id, ... - "Event", "optionsChanged"); -end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/displayPath.m b/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/displayPath.m deleted file mode 100644 index 9f71ecca2..000000000 --- a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/displayPath.m +++ /dev/null @@ -1,9 +0,0 @@ -% DIC Postprocess view helper. Expected caller: labkit_DICPostprocess_app. -% Input is a string-like path. Output is display text. Side effects: none. -function txt = displayPath(pathValue) - if strlength(pathValue) == 0 - txt = 'none'; - else - txt = char(pathValue); - end -end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/presentWorkbench.m b/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/presentWorkbench.m deleted file mode 100644 index 5dbae5867..000000000 --- a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,56 +0,0 @@ -% Expected caller: the LabKit V2 runtime. Input is canonical DIC Postprocess -% state. Output is a deterministic control, table, text, and paired-preview -% presentation without access to the UI registry. -function view = presentWorkbench(state) - project = state.project; - sources = project.inputs.sources; - view = struct(); - view.controls.matFile = fileSpec( ... - labkit.ui.runtime.sourcePaths(sources, "dicMat")); - view.controls.referenceFile = fileSpec(labkit.ui.runtime.sourcePaths( ... - sources, "referenceImage")); - view.controls.maskFile = fileSpec( ... - labkit.ui.runtime.sourcePaths(sources, "maskImage")); - view.controls.resultTable = struct(); - view.controls.resultTable.Data = ... - dic_postprocess.userInterface.summaryTableData( ... - project.results.summaryTable); - view.controls.summaryText = struct(); - view.controls.summaryText.Value = summaryLines(project); - view.previews.overlayAxes.Axes.exx = struct( ... - "Renderer", "overlay", ... - "Model", imageModel(state.session.cache.overlayExx, "EXX Overlay")); - view.previews.overlayAxes.Axes.eyy = struct( ... - "Renderer", "overlay", ... - "Model", imageModel(state.session.cache.overlayEyy, "EYY Overlay")); -end - -function spec = fileSpec(pathValue) - spec = struct(); - spec.Files = string(pathValue); -end - -function lines = summaryLines(project) - parameters = project.parameters; - lines = { ... - sprintf('DIC MAT: %s', dic_postprocess.userInterface.displayPath( ... - labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "dicMat"))); ... - sprintf('Reference image: %s', ... - dic_postprocess.userInterface.displayPath( ... - labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "referenceImage"))); ... - sprintf('Mask image: %s', dic_postprocess.userInterface.displayPath( ... - labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "maskImage"))); ... - sprintf('Overlays: %s', dic_postprocess.userInterface.ternary( ... - ~isempty(project.results.summaryTable), ... - 'available', 'not generated')); ... - sprintf(['Optical image: brightness %.3g, contrast %.3g, ' ... - 'gamma %.3g, saturation %.3g'], parameters.brightness, ... - parameters.contrast, parameters.gamma, parameters.saturation)}; -end - -function model = imageModel(imageData, titleText) - model = struct("imageData", imageData, "title", string(titleText)); -end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/renderOverlayImage.m b/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/renderOverlayImage.m deleted file mode 100644 index c6866944e..000000000 --- a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/renderOverlayImage.m +++ /dev/null @@ -1,20 +0,0 @@ -% Expected caller: the registered DIC Postprocess V2 renderer. Inputs are a -% target axes and prepared RGB overlay model. Side effects are limited to the -% supplied axes. -function renderOverlayImage(ax, model) - labkit.ui.plot.clear(ax, "ResetScale", true); - if isempty(model.imageData) - title(ax, char(model.title)); - xlabel(ax, ''); - ylabel(ax, ''); - box(ax, 'on'); - return; - end - image(ax, model.imageData); - axis(ax, 'image'); - ax.YDir = 'reverse'; - title(ax, char(model.title)); - xlabel(ax, ''); - ylabel(ax, ''); - box(ax, 'on'); -end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/summaryTableData.m b/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/summaryTableData.m deleted file mode 100644 index 3751fcadd..000000000 --- a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/summaryTableData.m +++ /dev/null @@ -1,10 +0,0 @@ -% DIC Postprocess view helper. Expected caller: labkit_DICPostprocess_app. -% Input is the ROI strain summary table. Output is UI table cell data. -% Side effects: none. -function data = summaryTableData(T) - if isempty(T) || height(T) == 0 - data = {}; - return; - end - data = [cellstr(T.Metric), num2cell(T.EXX), num2cell(T.EYY)]; -end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/tagFromPath.m b/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/tagFromPath.m deleted file mode 100644 index 517a817f0..000000000 --- a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/tagFromPath.m +++ /dev/null @@ -1,11 +0,0 @@ -% DIC Postprocess view helper. Expected caller: labkit_DICPostprocess_app. -% Input is a MAT filepath. Output is a safe export tag. Side effects: none. -function tag = tagFromPath(filepath) - tokens = regexp(filepath, '(\d+(?:\.\d+)?mm)', 'tokens'); - if isempty(tokens) - tag = 'unknown_mm'; - else - tag = tokens{end}{1}; - end - tag = regexprep(tag, '[^A-Za-z0-9_.-]', '_'); -end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/ternary.m b/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/ternary.m deleted file mode 100644 index cd37d7f1d..000000000 --- a/apps/dic/dic_postprocess/+dic_postprocess/+userInterface/ternary.m +++ /dev/null @@ -1,10 +0,0 @@ -% DIC Postprocess view helper. Expected caller: labkit_DICPostprocess_app. -% Inputs are a condition and display alternatives. Output is the selected text. -% Side effects: none. -function txt = ternary(cond, trueText, falseText) - if cond - txt = trueText; - else - txt = falseText; - end -end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+workbench/buildLayout.m b/apps/dic/dic_postprocess/+dic_postprocess/+workbench/buildLayout.m new file mode 100644 index 000000000..7d896813a --- /dev/null +++ b/apps/dic/dic_postprocess/+dic_postprocess/+workbench/buildLayout.m @@ -0,0 +1,91 @@ +% App-owned implementation for dic_postprocess.workbench.buildLayout within the dic_postprocess product workflow. +function layout = buildLayout() +%BUILDLAYOUT Assemble DIC inputs, overlay controls, results, and exports. +inputs = { ... + sourceList("matFile", "DIC MAT file", ... + ["*.mat", "MAT files (*.mat)"], "strain", "dicMat", ... + "Choose DIC MAT", "No MAT file loaded"), ... + sourceList("referenceFile", "Reference image", ... + imageFilters(), "reference", "referenceImage", ... + "Choose reference", "No reference image loaded"), ... + sourceList("maskFile", "Mask image", ... + imageFilters(), "mask", "maskImage", ... + "Choose mask", "No mask image loaded"), ... + labkit.app.layout.button("generate", ... + "Generate overlays + summary", ... + @dic_postprocess.analysisRun.generate, ... + Tooltip="Intersect finite EXX/EYY samples with the ROI mask, apply the selected processing, then build overlays and strain summaries.")}; +controls = { ... + labkit.app.layout.tab("filesAnalysis", "Files + Analysis", { ... + labkit.app.layout.section("inputsSection", "Inputs", inputs), ... + labkit.app.layout.section("overlayOptions", "Overlay Options", { ... + optionSlider("alpha", "Alpha:", [0 1], 0.05), ... + optionSlider("colorMin", "Color min:", [-10 10], 0.01), ... + optionSlider("colorMax", "Color max:", [-10 10], 0.01), ... + optionSlider("oversample", "Oversample:", [1 20], 1), ... + optionSlider("smoothSigma", "Smooth sigma:", [0 100], 0.1), ... + optionSlider("edgeTrim", "Edge trim:", [0 5], 1)}), ... + labkit.app.layout.section("imageOptions", ... + "Optical Image Enhancement", { ... + optionSlider("brightness", "Brightness:", [-1 1], 0.05), ... + optionSlider("contrast", "Contrast:", [0.05 5], 0.05), ... + optionSlider("gamma", "Gamma:", [0.05 5], 0.05), ... + optionSlider("saturation", "Saturation:", [0 5], 0.05), ... + optionSlider("redGain", "Red gain:", [0 5], 0.05), ... + optionSlider("greenGain", "Green gain:", [0 5], 0.05), ... + optionSlider("blueGain", "Blue gain:", [0 5], 0.05)}), ... + labkit.app.layout.section("exportsSection", "Exports", { ... + labkit.app.layout.button("saveOverlays", ... + "Save overlay PNGs", ... + @dic_postprocess.resultFiles.saveOverlays, ... + Tooltip="Save clean EXX and EYY overlay images using the current optical-image and strain display settings."), ... + labkit.app.layout.button("exportSummary", ... + "Export summary CSV", ... + @dic_postprocess.resultFiles.exportSummary, ... + Tooltip="Export EXX and EYY descriptive statistics calculated over the final finite ROI samples.")})}), ... + labkit.app.layout.tab("summaryResults", "Summary + Results", { ... + labkit.app.layout.section("summarySection", ... + "ROI Strain Summary", { ... + labkit.app.layout.dataTable("resultTable", ... + Title="ROI Strain Summary", ... + Columns=["Metric" "EXX" "EYY"]), ... + labkit.app.layout.statusPanel( ... + "summaryText", Title="Summary")})}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("appLog", Title="Log")})})}; +workspace = labkit.app.layout.workspace( ... + labkit.app.layout.plotArea("overlayAxes", ... + @dic_postprocess.overlayPreview.draw, ... + Title="Strain Overlays", Layout="stack", ... + AxisIds=["exx" "eyy"], ... + AxisTitles=["EXX Overlay" "EYY Overlay"]), ... + Title="Strain Overlays"); +layout = labkit.app.layout.workbench(controls, Workspace=workspace); +end + +function node = sourceList( ... + id, label, filters, role, prefix, chooseLabel, emptyText) +tooltips = struct( ... + "matFile", "Choose an Ncorr MAT file containing readable EXX and EYY strain fields.", ... + "referenceFile", "Choose the optical reference image registered to the DIC strain domain.", ... + "maskFile", "Choose a mask whose nonzero pixels define the requested strain-summary ROI."); +node = labkit.app.layout.fileList(id, ... + Label=label, Filters=filters, SelectionMode="single", MaxFiles=1, ... + ChooseLabel=chooseLabel, EmptyText=emptyText, ... + Bind="project.inputs.sources", ... + SelectionBind="session.selection." + id, ... + SourceRole=role, SourceIdPrefix=prefix, Required=true, ... + ChooseTooltip=tooltips.(char(id)), ... + OnSelectionChanged=@dic_postprocess.sourceFiles.invalidateResults); +end + +function node = optionSlider(id, label, limits, step) +node = labkit.app.layout.slider(id, Label=label, ... + Limits=limits, Step=step, Bind="project.parameters." + id, ... + OnValueChanged=@dic_postprocess.analysisRun.refreshOutputs); +end + +function filters = imageFilters() +filters = ["*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp", "Image files"]; +end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+workbench/present.m b/apps/dic/dic_postprocess/+dic_postprocess/+workbench/present.m new file mode 100644 index 000000000..1e9d2643c --- /dev/null +++ b/apps/dic/dic_postprocess/+dic_postprocess/+workbench/present.m @@ -0,0 +1,62 @@ +% App-owned implementation for dic_postprocess.workbench.present within the dic_postprocess product workflow. +function view = present(applicationState) +%PRESENT Map DIC state into one complete semantic snapshot fragment. +project = applicationState.project; +cache = applicationState.session.cache; +view = labkit.app.view.Snapshot() ... + .tableData("resultTable", ... + dic_postprocess.overlayPreview.summaryTableData( ... + project.results.summaryTable), ... + Columns=["Metric" "EXX" "EYY"]) ... + .text("summaryText", summaryText(project)) ... + .renderPlot("overlayAxes", struct( ... + "exx", imageModel(cache.overlayExx, "EXX Overlay"), ... + "eyy", imageModel(cache.overlayEyy, "EYY Overlay"))); +end + +function text = summaryText(project) +paths = pathsByRole(project.inputs.sources); +availability = "not generated"; +if ~isempty(project.results.summaryTable) + availability = "available"; +end +lines = [ ... + "DIC MAT: " + displayPath(paths.dicMat) + "Reference image: " + displayPath(paths.referenceImage) + "Mask image: " + displayPath(paths.maskImage) + "Overlays: " + availability + sprintf("Optical image: brightness %.3g, contrast %.3g, gamma %.3g, saturation %.3g", ... + project.parameters.brightness, project.parameters.contrast, ... + project.parameters.gamma, project.parameters.saturation)]; +text = join(lines, newline); +end + +function paths = pathsByRole(sources) +paths = struct("dicMat", "", "referenceImage", "", "maskImage", ""); +if isempty(sources) + return; +end +roles = string({sources.role}); +paths.dicMat = originalPath(sources, find(roles == "strain", 1)); +paths.referenceImage = originalPath( ... + sources, find(roles == "reference", 1)); +paths.maskImage = originalPath(sources, find(roles == "mask", 1)); +end + +function path = originalPath(sources, index) +path = ""; +if ~isempty(index) + path = string(sources(index).reference.originalPath); +end +end + +function text = displayPath(path) +text = string(path); +if strlength(text) == 0 + text = "none"; +end +end + +function model = imageModel(imageData, titleText) +model = struct("imageData", imageData, "title", string(titleText)); +end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/createSession.m b/apps/dic/dic_postprocess/+dic_postprocess/createSession.m index 3778c8ce1..da7a2d3eb 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/createSession.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/createSession.m @@ -1,16 +1,40 @@ %CREATESESSION Rebuild transient DIC inputs and prepared overlay caches. -% Expected caller: Runtime V2 through dic_postprocess.definition. Input is a -% validated durable project; file-read failures are handled by sourceFiles. -function session = createSession(project) +function session = createSession(project, context) + paths = resolvedPaths(project.inputs.sources, context); cache = dic_postprocess.sourceFiles.loadProjectInputs( ... - project.inputs.sources, ~isempty(project.results.summaryTable)); + paths, ~isempty(project.results.summaryTable)); [cache.overlayExx, cache.overlayEyy] = preparedOverlays( ... cache, project.parameters); session = struct( ... - "selection", struct("preview", "both"), ... + "selection", struct( ... + "matFile", labkit.app.event.ListSelection(), ... + "referenceFile", labkit.app.event.ListSelection(), ... + "maskFile", labkit.app.event.ListSelection()), ... "cache", cache); end +function paths = resolvedPaths(sources, context) + paths = struct( ... + "dicMat", pathForRole(sources, "strain", context), ... + "referenceImage", pathForRole(sources, "reference", context), ... + "maskImage", pathForRole(sources, "mask", context)); +end + +function filepath = pathForRole(sources, role, context) + filepath = ""; + if isempty(sources) + return; + end + match = find(string({sources.role}) == role, 1); + if isempty(match) + return; + end + resolved = context.resolveSourcePaths(sources(match)); + if ~isempty(resolved) + filepath = resolved(1); + end +end + function [overlayExx, overlayEyy] = preparedOverlays(cache, parameters) overlayExx = []; overlayEyy = []; diff --git a/apps/dic/dic_postprocess/+dic_postprocess/definition.m b/apps/dic/dic_postprocess/+dic_postprocess/definition.m index 240b7e68e..50689e369 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/definition.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/definition.m @@ -1,23 +1,18 @@ -% App-owned runtime definition for labkit_DICPostprocess_app. Expected -% caller: the public app entrypoint. Output is a declarative LabKit app -% definition; side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_DICPostprocess_app", ... - "Id", "dic_postprocess", ... - "Title", "DIC Strain Postprocess", ... - "DisplayName", "DIC Postprocess", ... - "Family", "DIC", ... - "AppVersion", "1.4.7", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "image", ">=2.0 <3"), ... - "Project", dic_postprocess.projectSpec(), ... - "CreateSession", @dic_postprocess.createSession, ... - "Layout", @dic_postprocess.userInterface.buildWorkbenchLayout, ... - "Actions", dic_postprocess.definitionActions(), ... - "Present", @dic_postprocess.userInterface.presentWorkbench, ... - "Renderers", struct( ... - "overlay", @dic_postprocess.userInterface.renderOverlayImage), ... - "DebugSample", @dic_postprocess.debug.writeSamplePack); +% App-owned product definition for labkit_DICPostprocess_app. +function app = definition() + app = labkit.app.Definition( ... + Entrypoint="labkit_DICPostprocess_app", ... + AppId="dic_postprocess", ... + Title="DIC Strain Postprocess", ... + DisplayName="DIC Postprocess", ... + Family="DIC", ... + AppVersion="1.5.1", ... + Updated="2026-07-20", ... + Requirements=labkit.contract.requirements( ... + "app", ">=1 <2", "image", ">=2.0 <3"), ... + ProjectSchema=dic_postprocess.projectSpec(), ... + CreateSession=@dic_postprocess.createSession, ... + Workbench=dic_postprocess.workbench.buildLayout(), ... + PresentWorkbench=@dic_postprocess.workbench.present, ... + BuildDebugSample=@dic_postprocess.debug.writeSamplePack); end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m b/apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m deleted file mode 100644 index bac6d9859..000000000 --- a/apps/dic/dic_postprocess/+dic_postprocess/definitionActions.m +++ /dev/null @@ -1,207 +0,0 @@ -% App-owned V2 action registry for DIC Postprocess. Handlers receive canonical -% state/events/services and own source loading, overlay generation, and export -% side effects without reading or mutating UI controls. -function actions = definitionActions() - actions = struct( ... - "matChosen", @onMatChosen, ... - "referenceChosen", @onReferenceChosen, ... - "maskChosen", @onMaskChosen, ... - "generate", @onGenerate, ... - "optionsChanged", @onOptionsChanged, ... - "saveOverlays", @onSaveOverlays, ... - "exportSummary", @onExportSummary); -end - -function state = onMatChosen(state, event, services) - filepath = firstEventPath(event, services); - if strlength(filepath) == 0 - state = services.workflow.log(state, "DIC MAT selection cancelled."); - return; - end - state.project.inputs.sources = services.project.upsertSource( ... - state.project.inputs.sources, "dicMat", "strain", filepath, true); - state.session.cache.strain = struct(); - state = clearPreparedOutputs(state); - state = services.workflow.log(state, "Selected DIC MAT: " + filepath); -end - -function state = onReferenceChosen(state, event, services) - filepath = firstEventPath(event, services); - if strlength(filepath) == 0 - state = services.workflow.log(state, ... - "Reference image selection cancelled."); - return; - end - state.session.cache.referenceImage = imread(filepath); - state.project.inputs.sources = services.project.upsertSource( ... - state.project.inputs.sources, "referenceImage", "reference", ... - filepath, true); - state = clearPreparedOutputs(state); - state = services.workflow.log(state, "Loaded reference image: " + filepath); -end - -function state = onMaskChosen(state, event, services) - filepath = firstEventPath(event, services); - if strlength(filepath) == 0 - state = services.workflow.log(state, "Mask image selection cancelled."); - return; - end - state.session.cache.maskImage = imread(filepath); - state.project.inputs.sources = services.project.upsertSource( ... - state.project.inputs.sources, "maskImage", "mask", filepath, true); - state = clearPreparedOutputs(state); - state = services.workflow.log(state, "Loaded mask image: " + filepath); -end - -function state = onGenerate(state, ~, services) - matPath = labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources, "dicMat"); - cache = state.session.cache; - if strlength(matPath) == 0 || isempty(cache.referenceImage) || ... - isempty(cache.maskImage) - services.dialogs.alert( ... - 'Load the DIC MAT file, reference image, and mask image first.', ... - 'Missing inputs'); - return; - end - if ~validColorRange(state.project.parameters) - services.dialogs.alert( ... - 'Color max must be greater than color min.', 'Invalid color range'); - return; - end - try - state.session.cache.strain = ... - dic_postprocess.sourceFiles.loadNcorrStrain(matPath); - state = prepareOutputs(state); - state = services.workflow.log(state, ... - "Generated EXX/EYY overlays and ROI summary."); - catch ME - services.diagnostics.report('Generate failed', ME); - services.dialogs.alert(ME.message, ... - 'DIC postprocess error'); - state = services.workflow.log(state, "Generate failed: " + ME.message); - end -end - -function state = onOptionsChanged(state, ~, services) - if ~hasPreparedInputs(state.session.cache) - return; - end - if ~validColorRange(state.project.parameters) - state = services.workflow.log(state, ... - "Option update skipped: Color max must exceed color min."); - return; - end - try - state = prepareOutputs(state); - catch ME - services.diagnostics.report('Option update skipped', ME); - state = services.workflow.log(state, ... - "Option update skipped: " + ME.message); - end -end - -function state = onSaveOverlays(state, ~, services) - if isempty(state.session.cache.overlayExx) || ... - isempty(state.session.cache.overlayEyy) - services.dialogs.alert( ... - 'Generate overlays before saving.', 'Save overlays'); - return; - end - [folder, cancelled] = services.dialogs.outputFolder( ... - 'Select folder for overlay PNGs', ""); - if cancelled - state = services.workflow.log(state, "Save overlay PNGs cancelled."); - return; - end - tag = string(dic_postprocess.userInterface.tagFromPath( ... - char(labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources, "dicMat")))); - exxName = "overlay_exx_" + tag + ".png"; - eyyName = "overlay_eyy_" + tag + ".png"; - exxFile = fullfile(folder, exxName); - eyyFile = fullfile(folder, eyyName); - dic_postprocess.resultFiles.exportOverlayImage( ... - state.session.cache.overlayExx, exxFile); - dic_postprocess.resultFiles.exportOverlayImage( ... - state.session.cache.overlayEyy, eyyFile); - outputs = [services.results.output( ... - "exxOverlay", "primary", exxName, "image/png"); ... - services.results.output( ... - "eyyOverlay", "primary", eyyName, "image/png")]; - spec = resultSpec(state, outputs); - spec.ManifestName = "dic_overlays_" + tag + ".labkit.json"; - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.overlayManifestPath = string(manifestPath); - state = services.workflow.log(state, ... - "Saved clean overlay PNGs: " + exxFile + " and " + eyyFile); -end - -function state = onExportSummary(state, ~, services) - summary = state.project.results.summaryTable; - if isempty(summary) || height(summary) == 0 - services.dialogs.alert( ... - 'Generate a summary before exporting.', 'Export summary'); - return; - end - [folder, name] = fileparts(char(labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources, "dicMat"))); - folder = services.dialogs.defaultFolder("output", folder); - defaultName = fullfile(folder, [name '_strain_summary.csv']); - [out, cancelled] = services.dialogs.outputFile( ... - '*.csv', 'Save strain summary CSV', defaultName); - if cancelled - state = services.workflow.log(state, "Export summary cancelled."); - return; - end - writetable(summary, out); - [folder, base, extension] = fileparts(out); - outputName = string(base) + string(extension); - spec = resultSpec(state, services.results.output( ... - "strainSummary", "primary", outputName, "text/csv")); - spec.ManifestName = string(base) + ".labkit.json"; - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.summaryManifestPath = string(manifestPath); - state = services.workflow.log(state, "Exported summary CSV: " + string(out)); -end - -function state = prepareOutputs(state) - [summary, overlayExx, overlayEyy] = ... - dic_postprocess.analysisRun.prepareOutputs( ... - state.session.cache, state.project.parameters); - state.project.results.summaryTable = summary; - state.session.cache.overlayExx = overlayExx; - state.session.cache.overlayEyy = overlayEyy; -end - -function state = clearPreparedOutputs(state) - state.project.results.summaryTable = table(); - state.session.cache.overlayExx = []; - state.session.cache.overlayEyy = []; -end - -function tf = hasPreparedInputs(inputs) - tf = isfield(inputs.strain, 'exx') && ... - ~isempty(inputs.referenceImage) && ~isempty(inputs.maskImage); -end - -function tf = validColorRange(parameters) - tf = parameters.colorMax > parameters.colorMin; -end - -function filepath = firstEventPath(event, services) - paths = services.events.paths(event, "addedFiles"); - filepath = ""; - if ~isempty(paths) - filepath = paths(1); - end -end - -function spec = resultSpec(state, outputs) - spec = struct(); - spec.Outputs = outputs; - spec.Inputs = state.project.inputs.sources; - spec.Parameters = state.project.parameters; - spec.Summary = struct("metricCount", ... - height(state.project.results.summaryTable)); -end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/projectSpec.m b/apps/dic/dic_postprocess/+dic_postprocess/projectSpec.m index 5b9feb1eb..a616b2689 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/projectSpec.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/projectSpec.m @@ -2,17 +2,13 @@ % Expected caller: dic_postprocess.definition. Output owns the current % payload version plus local creation and validation. Side effects are none. function spec = projectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", []); + spec = labkit.app.project.Schema( ... + Version=1, Create=@createProject, Validate=@validateProject); end function project = createProject() project = struct(); - project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + project.inputs = struct("sources", struct([])); project.parameters = struct( ... "alpha", 0.60, ... "colorMin", -0.15, ... diff --git a/apps/dic/dic_postprocess/labkit_DICPostprocess_app.m b/apps/dic/dic_postprocess/labkit_DICPostprocess_app.m index d6afc7155..23bbd8f21 100644 --- a/apps/dic/dic_postprocess/labkit_DICPostprocess_app.m +++ b/apps/dic/dic_postprocess/labkit_DICPostprocess_app.m @@ -2,6 +2,5 @@ %LABKIT_DICPOSTPROCESS_APP Ncorr strain summary and overlay export app. % Thin entrypoint delegates all product metadata and launch behavior to the % single app definition. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @dic_postprocess.definition, varargin{:}); + [varargout{1:nargout}] = dic_postprocess.definition().launch(varargin{:}); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/appendEditStep.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/appendEditStep.m new file mode 100644 index 000000000..6dd33d6e8 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/appendEditStep.m @@ -0,0 +1,12 @@ +% App-owned implementation for dic_preprocess.analysisRun.appendEditStep within the dic_preprocess product workflow. +function project = appendEditStep( ... + project, kind, transform, rectangle, description) +%APPENDEDITSTEP Append one durable registration or crop operation. +step = struct("kind", string(kind), "transform", transform, ... + "rect", rectangle, "description", string(description)); +if isempty(project.annotations.editSteps) + project.annotations.editSteps = step; +else + project.annotations.editSteps(end + 1) = step; +end +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m new file mode 100644 index 000000000..5bbfec5d1 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m @@ -0,0 +1,34 @@ +% App-owned implementation for dic_preprocess.analysisRun.applyCropRoi within the dic_preprocess product workflow. +function applicationState = applyCropRoi( ... + applicationState, callbackContext) +%APPLYCROPROI Persist the active square crop and rebuild both images. +rectangle = applicationState.project.annotations.cropRect; +if applicationState.session.workflow.mode ~= "crop" || isempty(rectangle) + callbackContext.alert( ... + "Start a crop ROI before applying the crop.", "No active ROI"); + return +end +applicationState.project = ... + dic_preprocess.editHistory.appendEditHistory( ... + applicationState.project, "crop"); +rectangle = dic_preprocess.analysisRun.squareRectInsideImage( ... + rectangle, ... + size(applicationState.session.cache.currentReferenceImage)); +applicationState.project = dic_preprocess.analysisRun.appendEditStep( ... + applicationState.project, "crop", [], rectangle, "crop"); +applicationState.project.annotations.cropRect = rectangle; +applicationState.project = ... + dic_preprocess.maskEditing.clearOperationDerivedState( ... + applicationState.project); +applicationState = ... + dic_preprocess.analysisRun.rebuildCache(applicationState); +applicationState = ... + dic_preprocess.analysisRun.stopEditors(applicationState); +applicationState.project.parameters.previewMode = "Current pair"; +applicationState.session.workflow.details = ... + dic_preprocess.analysisRun.cropSummary(rectangle); +applicationState = ... + dic_preprocess.analysisRun.clearResults(applicationState); +callbackContext.appendStatus(sprintf( ... + "Cropped current pair with [%g %g %g %g].", rectangle)); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyPointAlignment.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyPointAlignment.m new file mode 100644 index 000000000..c81c10ca2 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyPointAlignment.m @@ -0,0 +1,21 @@ +% App-owned implementation for dic_preprocess.analysisRun.applyPointAlignment within the dic_preprocess product workflow. +function state = applyPointAlignment(state, context) +reference = state.project.annotations.matchReferencePoints; +moving = state.project.annotations.matchMovingPoints; +if size(reference,1) < 2 || size(reference,1) ~= size(moving,1) + context.alert('Rigid registration requires at least two complete point pairs.', 'Not enough points'); + return; +end +cache = state.session.cache; +try + [~, transform] = dic_preprocess.analysisRun.alignMovingToReference( ... + cache.currentReferenceImage, cache.currentMovingImage, reference, moving); +catch ME + context.reportError('Point alignment', ME); + context.alert(ME.message, 'Point alignment failed'); + return; +end +state = dic_preprocess.analysisRun.recordAlignment(state, transform, "manual alignment"); +context.appendStatus( ... + "Aligned image using " + string(size(reference,1)) + " point pair(s)."); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelCropRoi.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelCropRoi.m new file mode 100644 index 000000000..1456a5155 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelCropRoi.m @@ -0,0 +1,9 @@ +% App-owned implementation for dic_preprocess.analysisRun.cancelCropRoi within the dic_preprocess product workflow. +function applicationState = cancelCropRoi( ... + applicationState, callbackContext) +if applicationState.session.workflow.mode ~= "crop" + return +end +applicationState = dic_preprocess.analysisRun.stopEditors(applicationState); +callbackContext.appendStatus("Crop ROI cancelled."); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelPointMatching.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelPointMatching.m new file mode 100644 index 000000000..b4e501ea8 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelPointMatching.m @@ -0,0 +1,10 @@ +% App-owned implementation for dic_preprocess.analysisRun.cancelPointMatching within the dic_preprocess product workflow. +function applicationState = cancelPointMatching( ... + applicationState, callbackContext) +if applicationState.session.workflow.mode ~= "matching" + return +end +applicationState = dic_preprocess.analysisRun.stopEditors(applicationState); +applicationState.session.workflow.details = {'Point matching cancelled.'}; +callbackContext.appendStatus("Cancelled point matching."); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/changeCropRectangle.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/changeCropRectangle.m new file mode 100644 index 000000000..5c073251a --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/changeCropRectangle.m @@ -0,0 +1,14 @@ +% App-owned implementation for dic_preprocess.analysisRun.changeCropRectangle within the dic_preprocess product workflow. +function applicationState = changeCropRectangle( ... + applicationState, position, ~) +%CHANGECROPRECTANGLE Store one managed square crop position. +if applicationState.session.workflow.mode ~= "crop" || numel(position) ~= 4 + return +end +rectangle = dic_preprocess.analysisRun.squareRectInsideImage( ... + double(position), ... + size(applicationState.session.cache.currentReferenceImage)); +applicationState.project.annotations.cropRect = rectangle; +applicationState.session.workflow.details = ... + dic_preprocess.analysisRun.cropSelectionSummary(rectangle); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/changeMatchPoints.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/changeMatchPoints.m new file mode 100644 index 000000000..d390d0e0f --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/changeMatchPoints.m @@ -0,0 +1,25 @@ +% App-owned implementation for dic_preprocess.analysisRun.changeMatchPoints within the dic_preprocess product workflow. +function state = changeMatchPoints(state, endpoints, ~) +%CHANGEMATCHPOINTS Preserve ordered reference/moving pair acquisition. +if ~iscell(endpoints) || numel(endpoints) ~= 2 + return; +end +referencePoints = double(endpoints{1}); +movingPoints = double(endpoints{2}); +referenceCount = size(referencePoints, 1); +movingCount = size(movingPoints, 1); +if movingCount > referenceCount || referenceCount > movingCount + 1 + state.session.workflow.details = { ... + 'Select each reference feature before its moving-image match.'}; + return; +end +state.project.annotations.matchReferencePoints = referencePoints; +state.project.annotations.matchMovingPoints = movingPoints; +if referenceCount > movingCount + next = 'Now select the matching feature in the moving image.'; +else + next = 'Select the next feature in the reference image.'; +end +state.session.workflow.details = {sprintf( ... + 'Complete point pairs: %d. %s', movingCount, next)}; +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/changePreviewMode.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/changePreviewMode.m new file mode 100644 index 000000000..a626c7092 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/changePreviewMode.m @@ -0,0 +1,5 @@ +% App-owned implementation for dic_preprocess.analysisRun.changePreviewMode within the dic_preprocess product workflow. +function state = changePreviewMode(state, value, ~) +state = dic_preprocess.analysisRun.stopEditors(state); +state.project.parameters.previewMode = string(value); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/clearResults.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/clearResults.m new file mode 100644 index 000000000..7c4f2bdc3 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/clearResults.m @@ -0,0 +1,6 @@ +% App-owned implementation for dic_preprocess.analysisRun.clearResults within the dic_preprocess product workflow. +function applicationState = clearResults(applicationState) +%CLEARRESULTS Invalidate output manifests after a semantic edit. +applicationState.project.results.currentImagesManifestPath = ""; +applicationState.project.results.maskManifestPath = ""; +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/cropSelectionSummary.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cropSelectionSummary.m similarity index 100% rename from apps/dic/dic_preprocess/+dic_preprocess/+userInterface/cropSelectionSummary.m rename to apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cropSelectionSummary.m diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/cropSummary.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cropSummary.m similarity index 100% rename from apps/dic/dic_preprocess/+dic_preprocess/+userInterface/cropSummary.m rename to apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cropSummary.m diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/defaultPreviewMode.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/defaultPreviewMode.m new file mode 100644 index 000000000..6b3b9b43a --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/defaultPreviewMode.m @@ -0,0 +1,9 @@ +% App-owned implementation for dic_preprocess.analysisRun.defaultPreviewMode within the dic_preprocess product workflow. +function value = defaultPreviewMode(applicationState) +%DEFAULTPREVIEWMODE Choose the useful preview for available source images. +if dic_preprocess.sourceFiles.hasImagePair(applicationState.session.cache) + value = "False-color overlay"; +else + value = "Current pair"; +end +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m new file mode 100644 index 000000000..2f972ee9b --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m @@ -0,0 +1,76 @@ +% Expected caller: the registered DIC Preprocess App SDK renderer. Inputs are one +% semantic preview axes and prepared image/overlay model. Side effects are +% limited to the supplied axes; overlays never become semantic state. +function drawPreview(axesById, model) +drawOne(axesById.reference, model.reference); +drawOne(axesById.moving, model.moving); +end + +function drawOne(ax, model) + if isempty(model.imageData) + labkit.app.plot.clearAxes(ax); + title(ax, char(model.title)); + box(ax, 'on'); + return; + end + + background = findobj(ax, 'Type', 'Image', 'Tag', backgroundTag()); + sameImage = isscalar(background) && isvalid(background) && ... + isequaln(background.CData, model.imageData); + if ~sameImage + labkit.app.plot.clearAxes(ax); + if ndims(model.imageData) == 2 + background = imagesc(ax, model.imageData); + colormap(ax, gray(256)); + else + background = image(ax, model.imageData); + end + background.Tag = backgroundTag(); + axis(ax, 'image'); + ax.YDir = 'reverse'; + end + + delete(findobj(ax, 'Tag', overlayTag())); + hold(ax, 'on'); + drawRectangle(ax, model.rectangle); + drawPointLabels(ax, model.pointLabels); + hold(ax, 'off'); + title(ax, char(model.title)); + xlabel(ax, ''); + ylabel(ax, ''); + box(ax, 'on'); +end + +function drawRectangle(ax, position) + if isempty(position) + return; + end + rectangle(ax, ... + 'Position', position, ... + 'EdgeColor', [1 0.85 0], ... + 'LineWidth', 1.5, ... + 'LineStyle', '--', ... + 'Tag', overlayTag(), ... + 'HitTest', 'off', ... + 'PickableParts', 'none'); +end + +function drawPointLabels(ax, points) + for k = 1:size(points, 1) + text(ax, points(k, 1) + 4, points(k, 2), string(k), ... + 'Color', [0 0.85 1], ... + 'FontWeight', 'bold', ... + 'Tag', overlayTag(), ... + 'Clipping', 'on', ... + 'HitTest', 'off', ... + 'PickableParts', 'none'); + end +end + +function value = backgroundTag() + value = 'labkitDicPreprocessPreviewImage'; +end + +function value = overlayTag() + value = 'labkitDicPreprocessPreviewOverlay'; +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/layoutSection.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/layoutSection.m new file mode 100644 index 000000000..cfbf07859 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/layoutSection.m @@ -0,0 +1,48 @@ +% App-owned implementation for dic_preprocess.analysisRun.layoutSection within the dic_preprocess product workflow. +function section = layoutSection() +labels = dic_preprocess.analysisRun.registrationLabels(); +section = labkit.app.layout.section( ... + "preprocessSection", "Registration + Crop", { ... + labkit.app.layout.button("startPointMatching", labels.startPointMatching, ... + @dic_preprocess.analysisRun.startPointMatching, ... + Tooltip="Start paired landmark selection for manual reference-to-moving image registration."), ... + labkit.app.layout.group("pointMatchingActions", { ... + labkit.app.layout.button("applyPointAlignment", ... + labels.applyPointAlignment, ... + @dic_preprocess.analysisRun.applyPointAlignment, ... + Tooltip="Estimate and apply the image alignment from the accepted reference/moving landmark pairs."), ... + labkit.app.layout.button("cancelPointMatching", ... + labels.cancelPointMatching, ... + @dic_preprocess.analysisRun.cancelPointMatching, ... + Tooltip="Leave landmark matching without applying the pending point-pair alignment.")}, ... + Layout="horizontal"), ... + labkit.app.layout.button("undoPointPair", labels.undoPointPair, ... + @dic_preprocess.analysisRun.undoPointPair, ... + Tooltip="Remove the most recently entered reference/moving landmark pair."), ... + labkit.app.layout.button("autoAlign", labels.autoAlign, ... + @dic_preprocess.analysisRun.runAutomaticRegistration, ... + Tooltip="Estimate an automatic intensity-based alignment of the moving image to the reference image."), ... + labkit.app.layout.button("startCropRoi", "Start/reset crop ROI", ... + @dic_preprocess.analysisRun.startCropRoi, ... + Tooltip="Create or reset the rectangular crop shared by the registered image pair."), ... + labkit.app.layout.group("cropActions", { ... + labkit.app.layout.button("applyCropRoi", "Apply ROI crop", ... + @dic_preprocess.analysisRun.applyCropRoi, ... + Tooltip="Crop both registered images to the current shared rectangular ROI."), ... + labkit.app.layout.button("cancelCropRoi", "Cancel ROI", ... + @dic_preprocess.analysisRun.cancelCropRoi, ... + Tooltip="Discard the pending crop rectangle without changing the registered images.")}, ... + Layout="horizontal"), ... + labkit.app.layout.group("editActions", { ... + labkit.app.layout.button("undoEdit", "Undo align/crop", ... + @dic_preprocess.analysisRun.undoEdit, ... + Tooltip="Undo the latest applied registration or crop operation."), ... + labkit.app.layout.button("saveCurrentImages", ... + "Save current images", ... + @dic_preprocess.resultFiles.saveCurrentImages, ... + Tooltip="Save the current registered and cropped image pair for downstream DIC processing.")}, ... + Layout="horizontal"), ... + labkit.app.layout.button("resetToOriginals", "Reset to originals", ... + @dic_preprocess.analysisRun.resetToOriginals, ... + Tooltip="Discard applied alignment and crop edits and restore both source images.")}); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/present.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/present.m new file mode 100644 index 000000000..9cf3a2a25 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/present.m @@ -0,0 +1,71 @@ +% App-owned implementation for dic_preprocess.analysisRun.present within the dic_preprocess product workflow. +function view = present(applicationState) +%PRESENT Describe registration, crop, and preview state. +cache = applicationState.session.cache; +annotations = applicationState.project.annotations; +hasPair = dic_preprocess.sourceFiles.hasImagePair(cache); +mode = applicationState.session.workflow.mode; +model = previewModel(applicationState); +imageSize = previewSize(model.reference.imageData); +matching = mode == "matching" && hasPair; +cropping = mode == "crop" && hasPair; +view = labkit.app.view.Snapshot() ... + .enabled("startPointMatching", hasPair) ... + .enabled("applyPointAlignment", matching && ... + size(annotations.matchReferencePoints, 1) >= 2 && ... + size(annotations.matchReferencePoints, 1) == ... + size(annotations.matchMovingPoints, 1)) ... + .enabled("cancelPointMatching", matching) ... + .enabled("undoPointPair", matching && ... + ~isempty(annotations.matchReferencePoints)) ... + .enabled("autoAlign", hasPair) ... + .enabled("startCropRoi", hasPair) ... + .enabled("applyCropRoi", cropping) ... + .enabled("cancelCropRoi", cropping) ... + .enabled("undoEdit", ~isempty(annotations.history)) ... + .enabled("resetToOriginals", hasPair) ... + .renderPlot("preview", model) ... + .pairedAnchors("matchPoints", ... + {annotations.matchReferencePoints, ... + annotations.matchMovingPoints}, ... + ImageSize=imageSize, Enabled=matching) ... + .rectangle("cropRectangle", cropPosition(annotations.cropRect), ... + ImageSize=imageSize, Enabled=cropping); +end + +function model = previewModel(state) +request = dic_preprocess.analysisRun.previewRequest( ... + state, state.project.parameters.previewMode); +model = struct( ... + "reference", axisModel(request.topImage, request.topTitle), ... + "moving", axisModel(request.bottomImage, request.bottomTitle)); +if state.session.workflow.mode == "crop" + model.reference.rectangle = state.project.annotations.cropRect; +elseif state.session.workflow.mode == "matching" + model.reference.pointLabels = ... + state.project.annotations.matchReferencePoints; + model.moving.pointLabels = ... + state.project.annotations.matchMovingPoints; +elseif state.session.workflow.mode == "mask" + model.reference.pointLabels = state.project.annotations.maskPoints; +end +end + +function model = axisModel(imageData, title) +model = struct("imageData", imageData, "title", title, ... + "rectangle", [], "pointLabels", zeros(0, 2)); +end + +function value = previewSize(imageData) +value = [1 1]; +if ~isempty(imageData) + value = [size(imageData, 1), size(imageData, 2)]; +end +end + +function value = cropPosition(rectangle) +value = [1 1 1 1]; +if numel(rectangle) == 4 && all(isfinite(rectangle)) + value = double(rectangle); +end +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/previewRequest.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/previewRequest.m similarity index 96% rename from apps/dic/dic_preprocess/+dic_preprocess/+userInterface/previewRequest.m rename to apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/previewRequest.m index f327e6039..be4399095 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/previewRequest.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/previewRequest.m @@ -1,4 +1,4 @@ -% Expected caller: DIC preprocess V2 presenter and unit tests. Inputs are the +% Expected caller: DIC preprocess App SDK presenter and unit tests. Inputs are the % canonical state and preview choice. Output is prepared image data. function request = previewRequest(state, previewValue) diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/rebuildCache.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/rebuildCache.m new file mode 100644 index 000000000..3982b89dc --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/rebuildCache.m @@ -0,0 +1,9 @@ +% App-owned implementation for dic_preprocess.analysisRun.rebuildCache within the dic_preprocess product workflow. +function applicationState = rebuildCache(applicationState) +%REBUILDCACHE Replay durable edit steps into transient working images. +cache = applicationState.session.cache; +applicationState.session.cache = ... + dic_preprocess.analysisRun.replayEditSteps( ... + cache.referenceImage, cache.movingImage, ... + applicationState.project.annotations.editSteps); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/recordAlignment.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/recordAlignment.m new file mode 100644 index 000000000..3e1b07de5 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/recordAlignment.m @@ -0,0 +1,24 @@ +% App-owned implementation for dic_preprocess.analysisRun.recordAlignment within the dic_preprocess product workflow. +function state = recordAlignment(state, transform, description) +%RECORDALIGNMENT Persist one alignment step and replay the transient images. +state.project = dic_preprocess.editHistory.appendEditHistory(state.project, description); +step = struct("kind", "alignment", "transform", transform, "rect", [], ... + "description", string(description)); +steps = state.project.annotations.editSteps; +if isempty(steps), steps = step; else, steps(end+1) = step; end +state.project.annotations.editSteps = steps; +state.project = dic_preprocess.maskEditing.clearOperationDerivedState(state.project); +cache = state.session.cache; +state.session.cache = dic_preprocess.analysisRun.replayEditSteps( ... + cache.referenceImage, cache.movingImage, steps); +state.session.workflow.mode = "idle"; +state.project.annotations.matchReferencePoints = zeros(0,2); +state.project.annotations.matchMovingPoints = zeros(0,2); +state.project.parameters.previewMode = "False-color overlay"; +state.session.workflow.details = ... + dic_preprocess.analysisRun.transformSummary( ... + transform, size(state.session.cache.currentReferenceImage), ... + size(state.session.cache.currentMovingImage)); +state.project.results.currentImagesManifestPath = ""; +state.project.results.maskManifestPath = ""; +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/registrationLabels.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/registrationLabels.m similarity index 100% rename from apps/dic/dic_preprocess/+dic_preprocess/+userInterface/registrationLabels.m rename to apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/registrationLabels.m diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/resetToOriginals.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/resetToOriginals.m new file mode 100644 index 000000000..4ab611cac --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/resetToOriginals.m @@ -0,0 +1,21 @@ +% App-owned implementation for dic_preprocess.analysisRun.resetToOriginals within the dic_preprocess product workflow. +function applicationState = resetToOriginals( ... + applicationState, callbackContext) +cache = applicationState.session.cache; +if isempty(cache.referenceImage) || isempty(cache.movingImage) + callbackContext.alert( ... + "Load both images before resetting the working pair.", "Reset"); + return +end +applicationState.project = ... + dic_preprocess.editHistory.appendEditHistory( ... + applicationState.project, "reset to originals"); +applicationState.project = ... + dic_preprocess.editHistory.resetToOriginals(applicationState.project); +applicationState = dic_preprocess.analysisRun.rebuildCache(applicationState); +applicationState = dic_preprocess.analysisRun.stopEditors(applicationState); +applicationState.project.parameters.previewMode = "Current pair"; +applicationState.session.workflow.details = {'Restored original image pair.'}; +applicationState = dic_preprocess.analysisRun.clearResults(applicationState); +callbackContext.appendStatus("Reset the working pair to originals."); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m new file mode 100644 index 000000000..78890904c --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m @@ -0,0 +1,20 @@ +% App-owned implementation for dic_preprocess.analysisRun.runAutomaticRegistration within the dic_preprocess product workflow. +function state = runAutomaticRegistration(state, context) +%RUNAUTOMATICREGISTRATION Estimate and record a rigid registration transform. +cache = state.session.cache; +if isempty(cache.currentReferenceImage) || isempty(cache.currentMovingImage) + context.alert('Load both reference and moving images before automatic alignment.', 'Missing images'); + return; +end +try + [~, transform, method] = dic_preprocess.analysisRun.autoAlignMovingToReference( ... + cache.currentReferenceImage, cache.currentMovingImage); +catch ME + context.reportError('Automatic alignment', ME); + context.alert("Automatic alignment failed:" + newline + ME.message, 'Auto align failed'); + return; +end +state = dic_preprocess.analysisRun.recordAlignment(state, transform, "automatic alignment"); +context.appendStatus( ... + "Automatically aligned current pair using " + string(method) + "."); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startCropRoi.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startCropRoi.m new file mode 100644 index 000000000..e5932ac22 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startCropRoi.m @@ -0,0 +1,20 @@ +% App-owned implementation for dic_preprocess.analysisRun.startCropRoi within the dic_preprocess product workflow. +function applicationState = startCropRoi( ... + applicationState, callbackContext) +%STARTCROPROI Begin a square crop interaction on the current pair. +if ~dic_preprocess.sourceFiles.hasImagePair(applicationState.session.cache) + callbackContext.alert( ... + "Load both reference and moving images before cropping.", ... + "Missing images"); + return +end +applicationState = dic_preprocess.analysisRun.stopEditors(applicationState); +rectangle = dic_preprocess.analysisRun.defaultSquareRect( ... + size(applicationState.session.cache.currentReferenceImage)); +applicationState.project.annotations.cropRect = rectangle; +applicationState.project.parameters.previewMode = "Current pair"; +applicationState.session.workflow.mode = "crop"; +applicationState.session.workflow.details = ... + dic_preprocess.analysisRun.cropSelectionSummary(rectangle); +callbackContext.appendStatus("Started crop ROI on the current pair."); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m new file mode 100644 index 000000000..05aa7cb87 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m @@ -0,0 +1,8 @@ +% App-owned implementation for dic_preprocess.analysisRun.startPointMatching within the dic_preprocess product workflow. +function state = startPointMatching(state, ~) +if dic_preprocess.sourceFiles.hasImagePair(state.session.cache) + state.session.workflow.mode = "matching"; + state.project.annotations.matchReferencePoints = zeros(0,2); + state.project.annotations.matchMovingPoints = zeros(0,2); +end +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/stopEditors.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/stopEditors.m new file mode 100644 index 000000000..c229f9d97 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/stopEditors.m @@ -0,0 +1,7 @@ +% App-owned implementation for dic_preprocess.analysisRun.stopEditors within the dic_preprocess product workflow. +function applicationState = stopEditors(applicationState) +%STOPEDITORS Leave active registration, crop, and mask interactions. +applicationState.session.workflow.mode = "idle"; +applicationState.project.annotations.matchReferencePoints = zeros(0, 2); +applicationState.project.annotations.matchMovingPoints = zeros(0, 2); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/transformSummary.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/transformSummary.m similarity index 100% rename from apps/dic/dic_preprocess/+dic_preprocess/+userInterface/transformSummary.m rename to apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/transformSummary.m diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/undoEdit.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/undoEdit.m new file mode 100644 index 000000000..3b38fced6 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/undoEdit.m @@ -0,0 +1,26 @@ +% App-owned implementation for dic_preprocess.analysisRun.undoEdit within the dic_preprocess product workflow. +function applicationState = undoEdit(applicationState, callbackContext) +%UNDOEDIT Restore the latest registration or crop snapshot. +history = applicationState.project.annotations.history; +if isempty(history) + callbackContext.alert( ... + "No align or crop operation is available to undo.", "Undo"); + return +end +snapshot = history(end); +applicationState.project.annotations.history(end) = []; +applicationState.project = ... + dic_preprocess.editHistory.restoreEditSnapshot( ... + applicationState.project, snapshot); +applicationState = ... + dic_preprocess.analysisRun.rebuildCache(applicationState); +applicationState = ... + dic_preprocess.analysisRun.stopEditors(applicationState); +applicationState.project.parameters.previewMode = "Current pair"; +applicationState.session.workflow.details = {sprintf( ... + "Restored state before %s.", snapshot.description)}; +applicationState = ... + dic_preprocess.analysisRun.clearResults(applicationState); +callbackContext.appendStatus( ... + "Undid " + string(snapshot.description) + "."); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/undoPointPair.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/undoPointPair.m new file mode 100644 index 000000000..f31ea3311 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/undoPointPair.m @@ -0,0 +1,14 @@ +% App-owned implementation for dic_preprocess.analysisRun.undoPointPair within the dic_preprocess product workflow. +function applicationState = undoPointPair(applicationState, ~) +reference = applicationState.project.annotations.matchReferencePoints; +moving = applicationState.project.annotations.matchMovingPoints; +if isempty(reference) + return +end +reference(end, :) = []; +if size(reference, 1) < size(moving, 1) + moving(end, :) = []; +end +applicationState.project.annotations.matchReferencePoints = reference; +applicationState.project.annotations.matchMovingPoints = moving; +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+debug/writeSamplePack.m b/apps/dic/dic_preprocess/+dic_preprocess/+debug/writeSamplePack.m index 9558bb4b2..9651fc8c4 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+debug/writeSamplePack.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+debug/writeSamplePack.m @@ -1,18 +1,21 @@ -% Expected caller: dic_preprocess.definitionActions during debug launch and unit tests. -% Input is a LabKit debug context. Output is a deterministic synthetic DIC -% image-pair sample pack. Side effects: writes anonymous debug images and -% records a session manifest when available. -function pack = writeSamplePack(debugLog) +% Expected caller: the App SDK debug-sample capability and unit tests. +% Input is a bounded diagnostic SampleContext. Output is a deterministic +% synthetic DIC image-pair SamplePack with a current project. Side effects: +% writes anonymous synthetic images beneath the context sample folder. +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write DIC preprocess debug image files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "dic_preprocess"); - sampleFolder = fullfile(char(folders.sampleFolder), "dic_preprocess"); - ensureFolder(sampleFolder); - - referencePath = string(fullfile(sampleFolder, "dic_reference_speckle_debug.png")); - movingPath = string(fullfile(sampleFolder, "dic_moving_shifted_speckle_debug.png")); - lowTexturePath = string(fullfile(sampleFolder, "dic_valid_low_texture_debug.png")); - malformedPath = string(fullfile(sampleFolder, "dic_malformed_not_an_image.png")); + referencePath = sampleContext.samplePath( ... + "dic_preprocess/reference.png"); + movingPath = sampleContext.samplePath( ... + "dic_preprocess/moving.png"); + lowTexturePath = sampleContext.samplePath( ... + "dic_preprocess/low_texture.png"); + malformedPath = sampleContext.samplePath( ... + "dic_preprocess/malformed.png"); ref = syntheticSpeckleImage(192, 256, 0, 0, 1); moving = syntheticSpeckleImage(192, 256, 4.2, -2.8, 0.96); @@ -23,21 +26,25 @@ imwrite(lowTexture, char(lowTexturePath)); writeTextFile(malformedPath, ["not a png payload"; "boundary=malformed image"]); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_DICPreprocess_app", ... - "description", "Anonymous DIC image-pair boundary pack for debug launch.", ... - "sampleFolder", string(sampleFolder), ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", struct("reference", referencePath, "moving", movingPath), ... - "boundaryFiles", struct( ... - "validEdgeLowTexture", lowTexturePath, ... - "malformedImage", malformedPath)); - recordManifest(debugLog, manifest); - - pack = manifest; - pack.referenceFile = referencePath; - pack.movingFile = movingPath; + project = dic_preprocess.projectSpec().Create(); + project.inputs.sources = [ ... + sampleContext.sourceRecord( ... + "referenceImage", "referenceImage", referencePath, true), ... + sampleContext.sourceRecord( ... + "movingImage", "movingImage", movingPath, true)]; + artifacts = { ... + sampleContext.artifact( ... + "referenceImage", "referenceImage", referencePath), ... + sampleContext.artifact( ... + "movingImage", "movingImage", movingPath), ... + sampleContext.artifact( ... + "lowTextureImage", "boundaryInput", lowTexturePath), ... + sampleContext.artifact( ... + "malformedImage", "boundaryInput", malformedPath, ... + Expectation="rejects")}; + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-image-pair", ... + InitialProject=project, Artifacts=artifacts); end function img = syntheticSpeckleImage(h, w, shiftX, shiftY, gain) @@ -57,30 +64,6 @@ 0.21 .* cos((2 .* x - y) ./ 11.0); end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, lines) fid = fopen(char(filepath), "w"); if fid < 0 @@ -89,9 +72,3 @@ function writeTextFile(filepath, lines) cleanup = onCleanup(@() fclose(fid)); fprintf(fid, "%s\n", lines); end - -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+editHistory/resetForNewInput.m b/apps/dic/dic_preprocess/+dic_preprocess/+editHistory/resetForNewInput.m index 5caab4a45..f1e6a6276 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+editHistory/resetForNewInput.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+editHistory/resetForNewInput.m @@ -1,4 +1,4 @@ -% Expected caller: DIC preprocess V2 actions and unit tests. Input/output is the +% Expected caller: DIC preprocess App SDK actions and unit tests. Input/output is the % canonical durable project; loaded originals remain and derived work resets. function project = resetForNewInput(project) diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+editHistory/resetToOriginals.m b/apps/dic/dic_preprocess/+dic_preprocess/+editHistory/resetToOriginals.m index bfebce506..688ef7d59 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+editHistory/resetToOriginals.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+editHistory/resetToOriginals.m @@ -1,4 +1,4 @@ -% Expected caller: DIC preprocess V2 actions and unit tests. Input/output is the +% Expected caller: DIC preprocess App SDK actions and unit tests. Input/output is the % canonical durable project with current images restored from originals. function project = resetToOriginals(project) diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/addBoundary.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/addBoundary.m new file mode 100644 index 000000000..7b3acd0d2 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/addBoundary.m @@ -0,0 +1,5 @@ +% App-owned implementation for dic_preprocess.maskEditing.addBoundary within the dic_preprocess product workflow. +function applicationState = addBoundary(applicationState, callbackContext) +applicationState = dic_preprocess.maskEditing.applyBoundary( ... + applicationState, callbackContext, "add"); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/applyBoundary.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/applyBoundary.m new file mode 100644 index 000000000..3635d441c --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/applyBoundary.m @@ -0,0 +1,29 @@ +% App-owned implementation for dic_preprocess.maskEditing.applyBoundary within the dic_preprocess product workflow. +function applicationState = applyBoundary( ... + applicationState, callbackContext, operation) +[boundaryMask, accepted] = ... + dic_preprocess.maskEditing.currentBoundaryMask(applicationState); +if ~accepted + callbackContext.alert( ... + "Mask ROI needs at least three anchors.", "Not enough anchors"); + return +end +applicationState.project = ... + dic_preprocess.maskEditing.appendMaskHistory( ... + applicationState.project, operation + " boundary"); +applicationState.project.annotations.maskImage = ... + dic_preprocess.maskEditing.applyBoundaryToMask( ... + applicationState.project.annotations.maskImage, ... + applicationState.session.cache.currentReferenceImage, ... + boundaryMask, operation); +applicationState.project.parameters.previewMode = "ROI mask"; +applicationState = dic_preprocess.analysisRun.clearResults(applicationState); +callbackContext.appendStatus( ... + upperFirst(operation) + "ed boundary on the ROI mask canvas."); +end + +function value = upperFirst(value) +value = char(string(value)); +value(1) = upper(value(1)); +value = string(value); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/boundaryStyleChanged.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/boundaryStyleChanged.m new file mode 100644 index 000000000..30fe4350c --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/boundaryStyleChanged.m @@ -0,0 +1,6 @@ +% App-owned implementation for dic_preprocess.maskEditing.boundaryStyleChanged within the dic_preprocess product workflow. +function applicationState = boundaryStyleChanged( ... + applicationState, value, ~) +applicationState.project.parameters.maskBoundaryStyle = string(value); +applicationState = dic_preprocess.analysisRun.clearResults(applicationState); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/changeBoundaryPoints.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/changeBoundaryPoints.m new file mode 100644 index 000000000..025c126d4 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/changeBoundaryPoints.m @@ -0,0 +1,10 @@ +% App-owned implementation for dic_preprocess.maskEditing.changeBoundaryPoints within the dic_preprocess product workflow. +function applicationState = changeBoundaryPoints( ... + applicationState, points, ~) +if applicationState.session.workflow.mode ~= "mask" + return +end +applicationState.project.annotations.maskPoints = double(points); +applicationState.session.workflow.details = ... + dic_preprocess.maskEditing.maskDraftDetails(double(points)); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearBoundary.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearBoundary.m new file mode 100644 index 000000000..2a4ac2962 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearBoundary.m @@ -0,0 +1,6 @@ +% App-owned implementation for dic_preprocess.maskEditing.clearBoundary within the dic_preprocess product workflow. +function applicationState = clearBoundary( ... + applicationState, callbackContext) +applicationState.project.annotations.maskPoints = zeros(0, 2); +callbackContext.appendStatus("Cleared mask ROI boundary anchors."); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearCanvas.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearCanvas.m new file mode 100644 index 000000000..8be62a490 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearCanvas.m @@ -0,0 +1,12 @@ +% App-owned implementation for dic_preprocess.maskEditing.clearCanvas within the dic_preprocess product workflow. +function applicationState = clearCanvas(applicationState, callbackContext) +if isempty(applicationState.project.annotations.maskImage) + return +end +applicationState.project = ... + dic_preprocess.maskEditing.appendMaskHistory( ... + applicationState.project, "clear mask canvas"); +applicationState.project.annotations.maskImage = []; +applicationState = dic_preprocess.analysisRun.clearResults(applicationState); +callbackContext.appendStatus("Cleared ROI mask canvas."); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/currentBoundaryMask.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/currentBoundaryMask.m new file mode 100644 index 000000000..fd22165ef --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/currentBoundaryMask.m @@ -0,0 +1,7 @@ +% App-owned implementation for dic_preprocess.maskEditing.currentBoundaryMask within the dic_preprocess product workflow. +function [mask, accepted] = currentBoundaryMask(applicationState) +[mask, accepted] = dic_preprocess.analysisRun.boundaryMaskFromEditor( ... + applicationState.project.annotations.maskPoints, ... + size(applicationState.session.cache.currentReferenceImage), ... + applicationState.project.parameters.maskBoundaryStyle, []); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/layoutSection.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/layoutSection.m new file mode 100644 index 000000000..ddfc5acd9 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/layoutSection.m @@ -0,0 +1,40 @@ +% App-owned implementation for dic_preprocess.maskEditing.layoutSection within the dic_preprocess product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare ROI mask editing controls. +section = labkit.app.layout.section("maskSection", "Mask ROI", { ... + labkit.app.layout.button("startMaskEdit", "Start ROI edit", ... + @dic_preprocess.maskEditing.startEdit, ... + Tooltip="Start drawing a boundary that will add to or subtract from the binary DIC ROI mask."), ... + labkit.app.layout.field("boundaryStyle", Label="Boundary:", ... + Kind="choice", Choices=["Curve", "Straight lines"], ... + Bind="project.parameters.maskBoundaryStyle", ... + OnValueChanged=@dic_preprocess.maskEditing.boundaryStyleChanged), ... + labkit.app.layout.group("maskPreviewActions", { ... + labkit.app.layout.button("previewMaskRoi", "Preview ROI mask", ... + @dic_preprocess.maskEditing.previewBoundary, ... + Tooltip="Preview the filled ROI implied by the current boundary without changing the saved mask."), ... + labkit.app.layout.button("addBoundaryToMask", "Add to mask", ... + @dic_preprocess.maskEditing.addBoundary, ... + Tooltip="Include pixels inside the current boundary in the DIC analysis mask.")}, Layout="horizontal"), ... + labkit.app.layout.group("maskEditActions", { ... + labkit.app.layout.button("subtractBoundaryFromMask", ... + "Subtract from mask", ... + @dic_preprocess.maskEditing.subtractBoundary, ... + Tooltip="Exclude pixels inside the current boundary from the DIC analysis mask."), ... + labkit.app.layout.button("undoMaskAnchor", "Undo point", ... + @dic_preprocess.maskEditing.undoAnchor, ... + Tooltip="Remove the most recently placed boundary anchor.")}, Layout="horizontal"), ... + labkit.app.layout.group("maskHistoryActions", { ... + labkit.app.layout.button("undoMaskEdit", "Undo mask edit", ... + @dic_preprocess.maskEditing.undoEdit, ... + Tooltip="Undo the latest committed addition or subtraction from the ROI mask."), ... + labkit.app.layout.button("clearMaskBoundary", "Clear boundary", ... + @dic_preprocess.maskEditing.clearBoundary, ... + Tooltip="Remove the pending boundary while preserving the committed ROI mask.")}, Layout="horizontal"), ... + labkit.app.layout.button("clearMaskCanvas", "Clear mask", ... + @dic_preprocess.maskEditing.clearCanvas, ... + Tooltip="Clear every included pixel from the current binary ROI mask."), ... + labkit.app.layout.button("saveMask", "Save ROI mask", ... + @dic_preprocess.resultFiles.saveMask, ... + Tooltip="Save the binary ROI mask used to include and exclude pixels in downstream DIC analysis.")}); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/maskDraftDetails.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/maskDraftDetails.m similarity index 100% rename from apps/dic/dic_preprocess/+dic_preprocess/+userInterface/maskDraftDetails.m rename to apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/maskDraftDetails.m diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/maskEditControlState.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/maskEditControlState.m new file mode 100644 index 000000000..6c6bdfba6 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/maskEditControlState.m @@ -0,0 +1,21 @@ +% Expected caller: DIC preprocess runner and direct unit tests. Inputs are mask +% edit state, current anchors, current mask canvas, and undo history. Output is +% an on/off enable-state struct for mask edit controls. Side effects: none. + +function state = maskEditControlState(editActive, maskPoints, maskImage, maskHistory) +%MASKEDITCONTROLSTATE Build enable states for DIC preprocess mask controls. + + hasPoints = ~isempty(maskPoints); + canBoundary = size(maskPoints, 1) >= 3; + canUndoCanvas = ~isempty(maskHistory); + canClearCanvas = ~isempty(maskImage); + + state = struct( ... + 'preview', dic_preprocess.maskEditing.onOff(editActive && (canBoundary || canClearCanvas)), ... + 'addBoundary', dic_preprocess.maskEditing.onOff(editActive && canBoundary), ... + 'subtractBoundary', dic_preprocess.maskEditing.onOff(editActive && canBoundary), ... + 'undoPoint', dic_preprocess.maskEditing.onOff(editActive && hasPoints), ... + 'clearBoundary', dic_preprocess.maskEditing.onOff(editActive && hasPoints), ... + 'undoMaskEdit', dic_preprocess.maskEditing.onOff(editActive && canUndoCanvas), ... + 'clearMask', dic_preprocess.maskEditing.onOff(editActive && canClearCanvas)); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/onOff.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/onOff.m similarity index 100% rename from apps/dic/dic_preprocess/+dic_preprocess/+userInterface/onOff.m rename to apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/onOff.m diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/present.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/present.m new file mode 100644 index 000000000..75d625e13 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/present.m @@ -0,0 +1,30 @@ +% App-owned implementation for dic_preprocess.maskEditing.present within the dic_preprocess product workflow. +function view = present(applicationState) +%PRESENT Describe ROI mask controls and managed anchor interaction. +annotations = applicationState.project.annotations; +active = applicationState.session.workflow.mode == "mask"; +hasReference = ... + ~isempty(applicationState.session.cache.currentReferenceImage); +pointCount = size(annotations.maskPoints, 1); +canBoundary = active && pointCount >= 3; +imageSize = [1 1]; +if hasReference + imageSize = [ ... + size(applicationState.session.cache.currentReferenceImage, 1), ... + size(applicationState.session.cache.currentReferenceImage, 2)]; +end +view = labkit.app.view.Snapshot() ... + .enabled("startMaskEdit", hasReference) ... + .enabled("previewMaskRoi", active && ... + (canBoundary || ~isempty(annotations.maskImage))) ... + .enabled("addBoundaryToMask", canBoundary) ... + .enabled("subtractBoundaryFromMask", canBoundary) ... + .enabled("undoMaskAnchor", active && pointCount > 0) ... + .enabled("undoMaskEdit", active && ... + ~isempty(annotations.maskHistory)) ... + .enabled("clearMaskBoundary", active && pointCount > 0) ... + .enabled("clearMaskCanvas", active && ... + ~isempty(annotations.maskImage)) ... + .pointSlots("maskPoints", annotations.maskPoints, ... + ImageSize=imageSize, Enabled=active); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/previewBoundary.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/previewBoundary.m new file mode 100644 index 000000000..ca78351f4 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/previewBoundary.m @@ -0,0 +1,16 @@ +% App-owned implementation for dic_preprocess.maskEditing.previewBoundary within the dic_preprocess product workflow. +function applicationState = previewBoundary( ... + applicationState, callbackContext) +[mask, accepted] = ... + dic_preprocess.maskEditing.currentBoundaryMask(applicationState); +if accepted + applicationState.project.annotations.maskImage = mask; + applicationState.project.parameters.previewMode = "ROI mask"; + callbackContext.appendStatus("Previewed ROI mask boundary."); +elseif ~isempty(applicationState.project.annotations.maskImage) + applicationState.project.parameters.previewMode = "ROI mask"; +else + callbackContext.alert( ... + "Mask ROI needs at least three anchors.", "Not enough anchors"); +end +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/startEdit.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/startEdit.m new file mode 100644 index 000000000..b57a5bb65 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/startEdit.m @@ -0,0 +1,16 @@ +% App-owned implementation for dic_preprocess.maskEditing.startEdit within the dic_preprocess product workflow. +function applicationState = startEdit(applicationState, callbackContext) +if isempty(applicationState.session.cache.currentReferenceImage) + callbackContext.alert( ... + "Load a reference image before editing the ROI mask.", ... + "Missing reference image"); + return +end +applicationState = dic_preprocess.analysisRun.stopEditors(applicationState); +applicationState.session.workflow.mode = "mask"; +applicationState.project.parameters.previewMode = "ROI mask"; +applicationState.session.workflow.details = ... + dic_preprocess.maskEditing.maskDraftDetails( ... + applicationState.project.annotations.maskPoints); +callbackContext.appendStatus("Started ROI mask editing."); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/subtractBoundary.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/subtractBoundary.m new file mode 100644 index 000000000..9c846e004 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/subtractBoundary.m @@ -0,0 +1,6 @@ +% App-owned implementation for dic_preprocess.maskEditing.subtractBoundary within the dic_preprocess product workflow. +function applicationState = subtractBoundary( ... + applicationState, callbackContext) +applicationState = dic_preprocess.maskEditing.applyBoundary( ... + applicationState, callbackContext, "subtract"); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/undoAnchor.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/undoAnchor.m new file mode 100644 index 000000000..51b620e05 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/undoAnchor.m @@ -0,0 +1,6 @@ +% App-owned implementation for dic_preprocess.maskEditing.undoAnchor within the dic_preprocess product workflow. +function applicationState = undoAnchor(applicationState, ~) +if ~isempty(applicationState.project.annotations.maskPoints) + applicationState.project.annotations.maskPoints(end, :) = []; +end +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/undoEdit.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/undoEdit.m new file mode 100644 index 000000000..87432731d --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/undoEdit.m @@ -0,0 +1,16 @@ +% App-owned implementation for dic_preprocess.maskEditing.undoEdit within the dic_preprocess product workflow. +function applicationState = undoEdit(applicationState, callbackContext) +history = applicationState.project.annotations.maskHistory; +if isempty(history) + return +end +snapshot = history(end); +applicationState.project.annotations.maskHistory(end) = []; +applicationState.project = ... + dic_preprocess.maskEditing.restoreMaskSnapshot( ... + applicationState.project, snapshot); +applicationState.project.parameters.previewMode = "ROI mask"; +applicationState = dic_preprocess.analysisRun.clearResults(applicationState); +callbackContext.appendStatus( ... + "Undid mask edit: " + string(snapshot.description) + "."); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/layoutSection.m b/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/layoutSection.m new file mode 100644 index 000000000..3f48771bc --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/layoutSection.m @@ -0,0 +1,12 @@ +% App-owned implementation for dic_preprocess.resultFiles.layoutSection within the dic_preprocess product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare current-pair and ROI mask exports. +section = labkit.app.layout.section("resultsSection", "Results", { ... + labkit.app.layout.button("saveCurrentImages", ... + "Save current images", ... + @dic_preprocess.resultFiles.saveCurrentImages, ... + Tooltip="Save the current registered and cropped image pair for downstream DIC processing."), ... + labkit.app.layout.button("saveMask", "Save ROI mask", ... + @dic_preprocess.resultFiles.saveMask, ... + Tooltip="Save the binary ROI mask used to restrict downstream DIC analysis.")}); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/present.m b/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/present.m new file mode 100644 index 000000000..7381f102a --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/present.m @@ -0,0 +1,11 @@ +% App-owned implementation for dic_preprocess.resultFiles.present within the dic_preprocess product workflow. +function view = present(applicationState) +%PRESENT Describe DIC output availability. +cache = applicationState.session.cache; +hasPair = dic_preprocess.sourceFiles.hasImagePair(cache); +hasMask = ~isempty(applicationState.project.annotations.maskImage) || ... + size(applicationState.project.annotations.maskPoints, 1) >= 3; +view = labkit.app.view.Snapshot() ... + .enabled("saveCurrentImages", hasPair) ... + .enabled("saveMask", hasMask); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveCurrentImages.m b/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveCurrentImages.m new file mode 100644 index 000000000..63bb0afed --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveCurrentImages.m @@ -0,0 +1,38 @@ +% App-owned implementation for dic_preprocess.resultFiles.saveCurrentImages within the dic_preprocess product workflow. +function applicationState = saveCurrentImages( ... + applicationState, callbackContext) +%SAVECURRENTIMAGES Write the working reference and moving pair. +cache = applicationState.session.cache; +if isempty(cache.currentReferenceImage) || isempty(cache.currentMovingImage) + callbackContext.alert( ... + "Load both images before saving the current pair.", ... + "Missing images"); + return +end +choice = callbackContext.chooseOutputFolder(""); +if choice.Cancelled + callbackContext.appendStatus("Save current images cancelled."); + return +end +folder = string(choice.Value); +outputs = dic_preprocess.resultFiles.writeCurrentImages( ... + cache.currentReferenceImage, cache.currentMovingImage, folder); +files = {resultFile("currentReference", outputs.referencePath), ... + resultFile("currentMoving", outputs.movingPath)}; +package = labkit.app.result.Package(Outputs=files, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct("editCount", ... + numel(applicationState.project.annotations.editSteps)), ... + ManifestName="dic_preprocess_images.labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.currentImagesManifestPath = ... + string(written.Value); +callbackContext.appendStatus("Saved current DIC image pair."); +end + +function file = resultFile(id, filepath) +[~, name, extension] = fileparts(filepath); +file = labkit.app.result.File(id, "primary", ... + string(name) + string(extension), MediaType="image/png"); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveMask.m b/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveMask.m new file mode 100644 index 000000000..ee7719641 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveMask.m @@ -0,0 +1,36 @@ +% App-owned implementation for dic_preprocess.resultFiles.saveMask within the dic_preprocess product workflow. +function applicationState = saveMask(applicationState, callbackContext) +%SAVEMASK Write the current ROI mask and result manifest. +mask = applicationState.project.annotations.maskImage; +if isempty(mask) + [mask, accepted] = ... + dic_preprocess.maskEditing.currentBoundaryMask(applicationState); + if ~accepted + callbackContext.alert( ... + "Draw a mask ROI or add a boundary before saving.", ... + "Save ROI mask"); + return + end + applicationState.project.annotations.maskImage = mask; +end +choice = callbackContext.chooseOutputFile( ... + ["*.png", "PNG mask"], "roi_mask.png"); +if choice.Cancelled + callbackContext.appendStatus("Save ROI mask cancelled."); + return +end +filepath = string(choice.Value); +dic_preprocess.resultFiles.writeMask(mask, filepath); +[folder, name, extension] = fileparts(filepath); +output = labkit.app.result.File("roiMask", "primary", ... + string(name) + string(extension), MediaType="image/png"); +package = labkit.app.result.Package(Outputs={output}, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct("anchorCount", size( ... + applicationState.project.annotations.maskPoints, 1)), ... + ManifestName=string(name) + ".labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.maskManifestPath = string(written.Value); +callbackContext.appendStatus("Saved ROI mask: " + filepath); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/layoutSection.m b/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/layoutSection.m new file mode 100644 index 000000000..7b6382e6f --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/layoutSection.m @@ -0,0 +1,23 @@ +% App-owned implementation for dic_preprocess.sourceFiles.layoutSection within the dic_preprocess product workflow. +function section = layoutSection() +section = labkit.app.layout.section("imagesSection", "Images", { ... + labkit.app.layout.fileList("referenceFile", Label="Reference image", ... + ChooseLabel="Choose reference", ... + ChooseTooltip="Choose the fixed optical image that defines the registration coordinate system.", ... + EmptyText="No reference image loaded", SelectionMode="single", ... + MaxFiles=1, Bind="project.inputs.sources", ... + OnSelectionChanged=@dic_preprocess.sourceFiles.sourceChanged, ... + SourceRole="referenceImage", SourceIdPrefix="reference", ... + Required=true), ... + labkit.app.layout.fileList("movingFile", Label="Moving image", ... + ChooseLabel="Choose moving", EmptyText="No moving image loaded", ... + ChooseTooltip="Choose the optical image to register and crop against the fixed reference image.", ... + SelectionMode="single", MaxFiles=1, Bind="project.inputs.sources", ... + OnSelectionChanged=@dic_preprocess.sourceFiles.sourceChanged, ... + SourceRole="movingImage", SourceIdPrefix="moving", Required=true), ... + labkit.app.layout.field("previewMode", Label="Preview:", ... + Kind="choice", Choices=["Current pair", "Current moving image", ... + "False-color overlay", "Original pair", "ROI mask"], ... + Bind="project.parameters.previewMode", ... + OnValueChanged=@dic_preprocess.analysisRun.changePreviewMode)}); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/loadProjectImages.m b/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/loadProjectImages.m index 78d4ed3f3..59d5de152 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/loadProjectImages.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/loadProjectImages.m @@ -1,14 +1,13 @@ -% Expected caller: dic_preprocess.createSession. Input is the -% validated portable source-record array. Outputs are decoded reference and -% moving images; missing optional records produce empty arrays. -function [referenceImage, movingImage] = loadProjectImages(sources) - referenceImage = readSource(sources, "referenceImage"); - movingImage = readSource(sources, "movingImage"); +% Expected caller: dic_preprocess.createSession. Inputs are resolved +% reference and moving paths. Missing paths produce empty arrays. +function [referenceImage, movingImage] = loadProjectImages( ... + referencePath, movingPath) + referenceImage = readSource(referencePath); + movingImage = readSource(movingPath); end -function imageData = readSource(sources, id) +function imageData = readSource(filepath) imageData = []; - filepath = labkit.ui.runtime.sourcePaths(sources, id); if strlength(filepath) > 0 && isfile(filepath) imageData = imread(filepath); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/sourceChanged.m b/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/sourceChanged.m new file mode 100644 index 000000000..f19dce23a --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/sourceChanged.m @@ -0,0 +1,25 @@ +% App-owned implementation for dic_preprocess.sourceFiles.sourceChanged within the dic_preprocess product workflow. +function applicationState = sourceChanged( ... + applicationState, selection, callbackContext) +%SOURCECHANGED Reset pair-derived annotations after either source changes. +arguments + applicationState (1, 1) struct + selection (1, 1) labkit.app.event.ListSelection + callbackContext (1, 1) labkit.app.CallbackContext +end +applicationState.project = ... + dic_preprocess.editHistory.resetForNewInput(applicationState.project); +cache = applicationState.session.cache; +applicationState.session.cache = ... + dic_preprocess.analysisRun.replayEditSteps( ... + cache.referenceImage, cache.movingImage, ... + applicationState.project.annotations.editSteps); +applicationState.session.workflow.mode = "idle"; +applicationState.project.parameters.previewMode = ... + dic_preprocess.analysisRun.defaultPreviewMode(applicationState); +applicationState.project.results.currentImagesManifestPath = ""; +applicationState.project.results.maskManifestPath = ""; +if ~isempty(selection.Indices) + callbackContext.appendStatus("Updated DIC source image."); +end +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildSummary.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildSummary.m deleted file mode 100644 index 273777290..000000000 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildSummary.m +++ /dev/null @@ -1,54 +0,0 @@ -% Expected caller: DIC preprocess V2 presenter and unit tests. Input is the -% canonical state. Output summarizes durable sources and rebuildable cache. - -function lines = buildSummary(state) -%BUILDSUMMARY Build DIC preprocess summary text from app state. - - project = state.project; - cache = state.session.cache; - annotations = project.annotations; - lines = cell(0, 1); - lines{end+1, 1} = sprintf('Reference: %s', ... - displayPath(labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "referenceImage"))); - lines{end+1, 1} = sprintf('Moving: %s', ... - displayPath(labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "movingImage"))); - lines{end+1, 1} = sprintf('Current pair: %s', currentPairSizeText(cache)); - lines{end+1, 1} = sprintf('Undo steps: %d', numel(annotations.history)); - lines{end+1, 1} = sprintf('Last aligned image: %s', ... - ternary(any(string({annotations.editSteps.kind}) == "alignment"), ... - 'available', 'not generated')); - lines{end+1, 1} = sprintf('ROI mask: %s', ... - ternary(~isempty(annotations.maskImage), 'available', 'not drawn')); -end - -function txt = currentPairSizeText(cache) - if isempty(cache.currentReferenceImage) || ... - isempty(cache.currentMovingImage) - txt = 'not loaded'; - return; - end - txt = sprintf('reference %d x %d, moving %d x %d', ... - size(cache.currentReferenceImage, 1), ... - size(cache.currentReferenceImage, 2), ... - size(cache.currentMovingImage, 1), ... - size(cache.currentMovingImage, 2)); -end - -function txt = displayPath(pathValue) - pathValue = string(pathValue); - if strlength(pathValue) == 0 - txt = 'none'; - return; - end - txt = char(pathValue); -end - -function out = ternary(condition, trueValue, falseValue) - if condition - out = trueValue; - else - out = falseValue; - end -end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 053c791e0..000000000 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,156 +0,0 @@ -% Expected caller: dic_preprocess.definition. Input is a callback struct whose -% fields are app-owned callback handles. Output is a data-only UI 5 -% workbench layout for the DIC Preprocess app. -function layout = buildWorkbenchLayout(callbacks, ~) - - previewItems = {'Current pair', 'Current moving image', ... - 'False-color overlay', 'Original pair', 'ROI mask'}; - boundaryItems = {'Curve', 'Straight lines'}; - - layout = labkit.ui.layout.workbench("dicPreprocessApp", ... - "DIC Image Preprocess", ... - "controlTabs", controlTabs(previewItems, boundaryItems, callbacks), ... - "workspace", imageWorkspace(), ... - "usageTitle", "Workflow Notes", ... - "usage", workflowNotesLines()); -end - -function tabs = controlTabs(previewItems, boundaryItems, callbacks) - tabs = { ... - filesAnalysisTab(previewItems, boundaryItems, callbacks), ... - summaryResultsTab(), ... - logTab()}; -end - -function tab = filesAnalysisTab(previewItems, boundaryItems, callbacks) - tab = labkit.ui.layout.tab("filesAnalysis", "Files + Analysis", { ... - imagesSection(previewItems, callbacks), ... - registrationCropSection(callbacks), ... - maskRoiSection(boundaryItems, callbacks)}); -end - -function tab = summaryResultsTab() - tab = labkit.ui.layout.tab("summaryResults", "Summary + Results", { ... - labkit.ui.layout.section("summarySection", "Summary", { ... - labkit.ui.layout.statusPanel("summaryText", "Summary", ... - "value", {'No images loaded.'})}), ... - labkit.ui.layout.section("detailsSection", "Details", { ... - labkit.ui.layout.statusPanel("detailsText", "Details", ... - "value", {'Alignment and crop details will appear here.'})})}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log", ... - "value", {'Ready.'})})}); -end - -function section = imagesSection(previewItems, callbacks) - section = labkit.ui.layout.section("imagesSection", "Images", { ... - labkit.ui.layout.filePanel("referenceFile", "Reference image", ... - "mode", "single", ... - "filters", {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', ... - 'Image files'}, ... - "chooseLabel", "Choose reference", ... - "status", "No reference image loaded", ... - "onChoose", callbacks.referenceChosen, ... - "onClear", callbacks.referenceCleared), ... - labkit.ui.layout.filePanel("movingFile", "Moving image", ... - "mode", "single", ... - "filters", {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', ... - 'Image files'}, ... - "chooseLabel", "Choose moving", ... - "status", "No moving image loaded", ... - "onChoose", callbacks.movingChosen, ... - "onClear", callbacks.movingCleared), ... - labkit.ui.layout.field("previewMode", "Preview:", ... - "kind", "dropdown", ... - "items", previewItems, ... - "value", previewItems{1}, ... - "Bind", "project.parameters.previewMode", ... - "Event", "previewChanged")}); -end - -function section = registrationCropSection(callbacks) - labels = dic_preprocess.userInterface.registrationLabels(); - section = labkit.ui.layout.section("registrationCrop", ... - "Registration + Crop", { ... - labkit.ui.layout.action("startPointMatching", labels.startPointMatching, ... - callbacks.startPointMatching), ... - labkit.ui.layout.group("pointMatchingActions", "", { ... - labkit.ui.layout.action("applyPointAlignment", labels.applyPointAlignment, ... - callbacks.applyPointAlignment, "enabled", false), ... - labkit.ui.layout.action("cancelPointMatching", labels.cancelPointMatching, ... - callbacks.cancelPointMatching, "enabled", false)}), ... - labkit.ui.layout.action("undoPointPair", labels.undoPointPair, ... - callbacks.undoPointPair, "enabled", false), ... - labkit.ui.layout.action("autoAlign", labels.autoAlign, ... - callbacks.autoAlign), ... - labkit.ui.layout.action("startCropRoi", "Start/reset crop ROI", ... - callbacks.startCropRoi), ... - labkit.ui.layout.group("cropActions", "", { ... - labkit.ui.layout.action("applyCropRoi", "Apply ROI crop", ... - callbacks.applyCropRoi, "enabled", false), ... - labkit.ui.layout.action("cancelCropRoi", "Cancel ROI", ... - callbacks.cancelCropRoi, "enabled", false)}), ... - labkit.ui.layout.group("editActions", "", { ... - labkit.ui.layout.action("undoEdit", "Undo align/crop", ... - callbacks.undoEdit, "enabled", false), ... - labkit.ui.layout.action("saveCurrentImages", "Save current images", ... - callbacks.saveCurrentImages)}), ... - labkit.ui.layout.action("resetToOriginals", "Reset to originals", ... - callbacks.resetToOriginals)}); -end - -function section = maskRoiSection(boundaryItems, callbacks) - section = labkit.ui.layout.section("maskRoi", "Mask ROI", { ... - labkit.ui.layout.action("startMaskEdit", "Start ROI edit", ... - callbacks.startMaskEdit), ... - labkit.ui.layout.field("boundaryStyle", "Boundary:", ... - "kind", "dropdown", ... - "items", boundaryItems, ... - "value", boundaryItems{1}, ... - "enabled", false, ... - "Bind", "project.parameters.maskBoundaryStyle", ... - "Event", "boundaryStyleChanged"), ... - labkit.ui.layout.group("maskPreviewActions", "", { ... - labkit.ui.layout.action("previewMaskRoi", "Preview ROI mask", ... - callbacks.previewMaskRoi, "enabled", false), ... - labkit.ui.layout.action("addBoundaryToMask", "Add to mask", ... - callbacks.addBoundaryToMask, "enabled", false)}), ... - labkit.ui.layout.group("maskEditActions", "", { ... - labkit.ui.layout.action("subtractBoundaryFromMask", "Subtract from mask", ... - callbacks.subtractBoundaryFromMask, ... - "enabled", false), ... - labkit.ui.layout.action("undoMaskAnchor", "Undo point", ... - callbacks.undoMaskAnchor, "enabled", false)}), ... - labkit.ui.layout.group("maskHistoryActions", "", { ... - labkit.ui.layout.action("undoMaskEdit", "Undo mask edit", ... - callbacks.undoMaskEdit, "enabled", false), ... - labkit.ui.layout.action("clearMaskBoundary", "Clear boundary", ... - callbacks.clearMaskBoundary, ... - "enabled", false)}), ... - labkit.ui.layout.action("clearMaskCanvas", "Clear mask", ... - callbacks.clearMaskCanvas, "enabled", false), ... - labkit.ui.layout.action("saveMask", "Save ROI mask", ... - callbacks.saveMask)}); -end - -function lines = workflowNotesLines() - lines = { ... - '1. Load a reference image and a moving image.', ... - '2. Start point matching, alternate between reference and moving features, then apply alignment.', ... - '3. False-color preview compares the current pair even before alignment.', ... - '4. Align or crop the current working pair in any order; each applied operation can be undone.', ... - '5. Draw curve or straight-line ROI boundaries, add/subtract them on the mask canvas, then save the mask.'}; -end - -function workspace = imageWorkspace() - workspace = labkit.ui.layout.workspace("imagePreview", ... - "Image Preview", { ... - labkit.ui.layout.previewArea("previewAxes", "Image Preview", ... - "layout", "stack", "count", 2, ... - "axisIds", {'reference', 'current'}, ... - "axisTitles", {'Reference', 'Current Preview'})}); -end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/deleteIfValid.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/deleteIfValid.m deleted file mode 100644 index e52427403..000000000 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/deleteIfValid.m +++ /dev/null @@ -1,13 +0,0 @@ -% Expected caller: DIC preprocess runner. Input is a graphics or listener handle. -% Side effect: deletes the handle when present and valid. - -function deleteIfValid(h) -%DELETEIFVALID Delete a MATLAB handle when it is nonempty and valid. - - if isempty(h) - return; - end - if isvalid(h) - delete(h); - end -end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/maskEditControlState.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/maskEditControlState.m deleted file mode 100644 index 72e89ea92..000000000 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/maskEditControlState.m +++ /dev/null @@ -1,21 +0,0 @@ -% Expected caller: DIC preprocess runner and direct unit tests. Inputs are mask -% edit state, current anchors, current mask canvas, and undo history. Output is -% an on/off enable-state struct for mask edit controls. Side effects: none. - -function state = maskEditControlState(editActive, maskPoints, maskImage, maskHistory) -%MASKEDITCONTROLSTATE Build enable states for DIC preprocess mask controls. - - hasPoints = ~isempty(maskPoints); - canBoundary = size(maskPoints, 1) >= 3; - canUndoCanvas = ~isempty(maskHistory); - canClearCanvas = ~isempty(maskImage); - - state = struct( ... - 'preview', dic_preprocess.userInterface.onOff(editActive && (canBoundary || canClearCanvas)), ... - 'addBoundary', dic_preprocess.userInterface.onOff(editActive && canBoundary), ... - 'subtractBoundary', dic_preprocess.userInterface.onOff(editActive && canBoundary), ... - 'undoPoint', dic_preprocess.userInterface.onOff(editActive && hasPoints), ... - 'clearBoundary', dic_preprocess.userInterface.onOff(editActive && hasPoints), ... - 'undoMaskEdit', dic_preprocess.userInterface.onOff(editActive && canUndoCanvas), ... - 'clearMask', dic_preprocess.userInterface.onOff(editActive && canClearCanvas)); -end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m deleted file mode 100644 index f1cf0a707..000000000 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,164 +0,0 @@ -% Expected caller: the LabKit V2 runtime. Input is canonical DIC Preprocess -% state. Output is one deterministic control, preview, and controlled-tool -% presentation with no UI registry or live graphics in semantic state. -function view = presentWorkbench(state) - project = state.project; - cache = state.session.cache; - annotations = project.annotations; - mode = string(state.session.workflow.mode); - hasReference = ~isempty(cache.currentReferenceImage); - hasPair = dic_preprocess.sourceFiles.hasImagePair(cache); - matching = mode == "matching"; - cropping = mode == "crop"; - masking = mode == "mask"; - completePairs = size(annotations.matchReferencePoints, 1) >= 2 && ... - size(annotations.matchReferencePoints, 1) == ... - size(annotations.matchMovingPoints, 1); - - view = struct(); - view.controls.referenceFile = fileSpec( ... - labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "referenceImage")); - view.controls.movingFile = fileSpec( ... - labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "movingImage")); - view.controls.previewMode = valueSpec(project.parameters.previewMode); - view.controls.boundaryStyle = controlSpec(masking, ... - project.parameters.maskBoundaryStyle); - view.controls.startPointMatching = enabledSpec(hasPair && ~matching); - view.controls.applyPointAlignment = enabledSpec(matching && completePairs); - view.controls.cancelPointMatching = enabledSpec(matching); - view.controls.undoPointPair = enabledSpec(matching && ... - ~isempty(annotations.matchReferencePoints)); - view.controls.autoAlign = enabledSpec(hasPair); - view.controls.startCropRoi = enabledSpec(hasPair); - view.controls.applyCropRoi = enabledSpec(cropping); - view.controls.cancelCropRoi = enabledSpec(cropping); - view.controls.undoEdit = enabledSpec(~isempty(annotations.history)); - view.controls.saveCurrentImages = enabledSpec(hasPair); - view.controls.resetToOriginals = enabledSpec( ... - ~isempty(cache.referenceImage) && ~isempty(cache.movingImage)); - view.controls.startMaskEdit = enabledSpec(hasReference); - view = maskControlPresentation(view, annotations, masking); - view.controls.saveMask = enabledSpec(~isempty(annotations.maskImage) || ... - size(annotations.maskPoints, 1) >= 3); - view.controls.summaryText = valueSpec( ... - dic_preprocess.userInterface.buildSummary(state)); - view.controls.detailsText = valueSpec(state.session.workflow.details); - - request = previewForMode(state, mode); - topRect = []; - bottomRect = []; - referenceLabels = zeros(0, 2); - movingLabels = zeros(0, 2); - if cropping - bottomRect = annotations.cropRect; - elseif matching - referenceLabels = annotations.matchReferencePoints; - movingLabels = annotations.matchMovingPoints; - end - view.previews.previewAxes.Axes.reference = previewSpec( ... - request.topImage, request.topTitle, topRect, referenceLabels); - view.previews.previewAxes.Axes.current = previewSpec( ... - request.bottomImage, request.bottomTitle, bottomRect, movingLabels); - - if matching - view.interactions.pointPairs = struct( ... - "Kind", "pairedAnchors", ... - "Targets", ["previewAxes.reference", "previewAxes.current"], ... - "Value", {{annotations.matchReferencePoints, ... - annotations.matchMovingPoints}}, ... - "Event", "pointPairsEdited", ... - "ImageSize", {{size(cache.currentReferenceImage), ... - size(cache.currentMovingImage)}}, ... - "ChangePolicy", "commit", ... - "Options", struct("mode", "points", "color", [0 0.85 1])); - elseif cropping - view.interactions.cropRectangle = struct( ... - "Kind", "rectangle", ... - "Targets", "previewAxes.reference", ... - "Value", annotations.cropRect, ... - "Event", "cropRectMoved", ... - "ImageSize", size(cache.currentReferenceImage), ... - "ChangePolicy", "commit", ... - "Options", struct("fixedAspectRatio", true, ... - "color", [1 0.85 0], "lineWidth", 1.5)); - elseif masking - view.interactions.maskBoundary = struct( ... - "Kind", "anchors", ... - "Targets", "previewAxes.reference", ... - "Value", annotations.maskPoints, ... - "Event", "maskPointsEdited", ... - "ImageSize", size(cache.currentReferenceImage), ... - "ChangePolicy", "commit", ... - "Options", struct("closed", true, ... - "style", project.parameters.maskBoundaryStyle, ... - "color", [0 0.85 1])); - end -end - -function view = maskControlPresentation(view, annotations, active) - hasPoints = ~isempty(annotations.maskPoints); - hasBoundary = size(annotations.maskPoints, 1) >= 3; - hasCanvas = ~isempty(annotations.maskImage); - hasHistory = ~isempty(annotations.maskHistory); - view.controls.previewMaskRoi = enabledSpec(active && ... - (hasBoundary || hasCanvas)); - view.controls.addBoundaryToMask = enabledSpec(active && hasBoundary); - view.controls.subtractBoundaryFromMask = enabledSpec(active && hasBoundary); - view.controls.undoMaskAnchor = enabledSpec(active && hasPoints); - view.controls.undoMaskEdit = enabledSpec(active && hasHistory); - view.controls.clearMaskBoundary = enabledSpec(active && hasPoints); - view.controls.clearMaskCanvas = enabledSpec(active && hasCanvas); -end - -function request = previewForMode(state, mode) - if mode ~= "mask" - request = dic_preprocess.userInterface.previewRequest( ... - state, state.project.parameters.previewMode); - return; - end - project = state.project; - referenceImage = state.session.cache.currentReferenceImage; - maskImage = project.annotations.maskImage; - if isempty(maskImage) && size(project.annotations.maskPoints, 1) >= 3 - [maskImage, ok] = dic_preprocess.analysisRun.boundaryMaskFromEditor( ... - project.annotations.maskPoints, size(referenceImage), ... - project.parameters.maskBoundaryStyle, []); - if ~ok - maskImage = []; - end - end - maskImage = dic_preprocess.maskEditing.maskCanvas(maskImage, referenceImage); - request = struct( ... - "topImage", referenceImage, ... - "topTitle", "Current reference", ... - "bottomImage", dic_preprocess.analysisRun.maskRgb(maskImage), ... - "bottomTitle", "ROI mask preview"); -end - -function spec = previewSpec(imageData, titleText, rectangleValue, points) - model = struct( ... - "imageData", imageData, ... - "title", string(titleText), ... - "rectangle", rectangleValue, ... - "pointLabels", points); - spec = struct("Renderer", "image", "Model", model); -end - -function spec = fileSpec(pathValue) - spec = struct("Files", string(pathValue)); -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = enabledSpec(enabled) - spec = struct("Enabled", logical(enabled)); -end - -function spec = controlSpec(enabled, value) - spec = struct("Enabled", logical(enabled), "Value", value); -end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/renderPreviewImage.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/renderPreviewImage.m deleted file mode 100644 index 3ad96482d..000000000 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/renderPreviewImage.m +++ /dev/null @@ -1,71 +0,0 @@ -% Expected caller: the registered DIC Preprocess V2 renderer. Inputs are one -% semantic preview axes and prepared image/overlay model. Side effects are -% limited to the supplied axes; overlays never become semantic state. -function renderPreviewImage(ax, model) - if isempty(model.imageData) - labkit.ui.plot.clear(ax, "ResetScale", true); - title(ax, char(model.title)); - box(ax, 'on'); - return; - end - - background = findobj(ax, 'Type', 'Image', 'Tag', backgroundTag()); - sameImage = isscalar(background) && isvalid(background) && ... - isequaln(background.CData, model.imageData); - if ~sameImage - labkit.ui.plot.clear(ax, "ResetScale", true); - if ndims(model.imageData) == 2 - background = imagesc(ax, model.imageData); - colormap(ax, gray(256)); - else - background = image(ax, model.imageData); - end - background.Tag = backgroundTag(); - axis(ax, 'image'); - ax.YDir = 'reverse'; - end - - delete(findobj(ax, 'Tag', overlayTag())); - hold(ax, 'on'); - drawRectangle(ax, model.rectangle); - drawPointLabels(ax, model.pointLabels); - hold(ax, 'off'); - title(ax, char(model.title)); - xlabel(ax, ''); - ylabel(ax, ''); - box(ax, 'on'); -end - -function drawRectangle(ax, position) - if isempty(position) - return; - end - rectangle(ax, ... - 'Position', position, ... - 'EdgeColor', [1 0.85 0], ... - 'LineWidth', 1.5, ... - 'LineStyle', '--', ... - 'Tag', overlayTag(), ... - 'HitTest', 'off', ... - 'PickableParts', 'none'); -end - -function drawPointLabels(ax, points) - for k = 1:size(points, 1) - text(ax, points(k, 1) + 4, points(k, 2), string(k), ... - 'Color', [0 0.85 1], ... - 'FontWeight', 'bold', ... - 'Tag', overlayTag(), ... - 'Clipping', 'on', ... - 'HitTest', 'off', ... - 'PickableParts', 'none'); - end -end - -function value = backgroundTag() - value = 'labkitDicPreprocessPreviewImage'; -end - -function value = overlayTag() - value = 'labkitDicPreprocessPreviewOverlay'; -end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m new file mode 100644 index 000000000..42257ee4c --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m @@ -0,0 +1,35 @@ +% App-owned implementation for dic_preprocess.workbench.buildLayout within the dic_preprocess product workflow. +function layout = buildLayout() +%BUILDLAYOUT Declare DIC preprocessing by product capability. +controls = { ... + labkit.app.layout.tab("filesAnalysis", "Files + Analysis", { ... + dic_preprocess.sourceFiles.layoutSection(), ... + dic_preprocess.analysisRun.layoutSection(), ... + dic_preprocess.maskEditing.layoutSection(), ... + labkit.app.layout.section("workflowNotesSection", ... + "Workflow Notes", {labkit.app.layout.statusPanel( ... + "workflowNotes", Title="Workflow Notes")})}), ... + labkit.app.layout.tab("summaryResults", "Summary + Results", { ... + labkit.app.layout.section("summarySection", "Summary", { ... + labkit.app.layout.statusPanel("summary", Title="Summary")}), ... + labkit.app.layout.section("detailsSection", "Details", { ... + labkit.app.layout.statusPanel("details", Title="Details")})}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("appLog", Title="Log")})})}; +matchPoints = labkit.app.interaction.pairedAnchors("matchPoints", ... + @dic_preprocess.analysisRun.changeMatchPoints, ... + Axes=["reference", "moving"], ... + ViewportPolicy="preserve"); +crop = labkit.app.interaction.rectangle("cropRectangle", ... + @dic_preprocess.analysisRun.changeCropRectangle, Axis="reference", ... + ViewportPolicy="preserve"); +maskPoints = labkit.app.interaction.pointSlots("maskPoints", ... + @dic_preprocess.maskEditing.changeBoundaryPoints, Axis="reference", ... + ViewportPolicy="preserve"); +workspace = labkit.app.layout.workspace(labkit.app.layout.plotArea("preview", ... + @dic_preprocess.analysisRun.drawPreview, ... + AxisIds=["reference", "moving"], ... + Interactions={matchPoints, crop, maskPoints}), Title="Image Preview"); +layout = labkit.app.layout.workbench(controls, Workspace=workspace); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+workbench/present.m b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/present.m new file mode 100644 index 000000000..ad8de9369 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/present.m @@ -0,0 +1,12 @@ +% App-owned implementation for dic_preprocess.workbench.present within the dic_preprocess product workflow. +function view = present(state) +%PRESENT Assemble DIC feature fragments at the only application-state view boundary. +view = dic_preprocess.analysisRun.present(state) ... + .include(dic_preprocess.maskEditing.present(state)) ... + .include(dic_preprocess.resultFiles.present(state)) ... + .text("summary", join( ... + dic_preprocess.workbench.summaryLines(state), newline)) ... + .text("workflowNotes", join( ... + dic_preprocess.workbench.workflowNotes(), newline)) ... + .text("details", join(string(state.session.workflow.details), newline)); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+workbench/summaryLines.m b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/summaryLines.m new file mode 100644 index 000000000..4ccbb8346 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/summaryLines.m @@ -0,0 +1,55 @@ +% App-owned implementation for dic_preprocess.workbench.summaryLines within the dic_preprocess product workflow. +function lines = summaryLines(applicationState) +%SUMMARYLINES Build the DIC preprocessing summary shown by workbench.present. +% Input is canonical App state. Output is a string column containing source, +% current-pair, edit-history, alignment, and mask availability. No state or +% graphics are modified. + +project = applicationState.project; +cache = applicationState.session.cache; +annotations = project.annotations; +lines = [ + "Reference: " + sourceReference(project.inputs.sources, "referenceImage") + "Moving: " + sourceReference(project.inputs.sources, "movingImage") + "Current pair: " + currentPairSize(cache) + "Undo steps: " + string(numel(annotations.history)) + "Last aligned image: " + availability(any( ... + string({annotations.editSteps.kind}) == "alignment"), ... + "available", "not generated") + "ROI mask: " + availability(~isempty(annotations.maskImage), ... + "available", "not drawn")]; +end + +function value = sourceReference(sources, role) +value = "none"; +if isempty(sources) + return +end +index = find(string({sources.role}) == role, 1); +if isempty(index) + return +end +candidate = string(sources(index).reference.originalPath); +if strlength(candidate) > 0 + value = candidate; +end +end + +function value = currentPairSize(cache) +if isempty(cache.currentReferenceImage) || isempty(cache.currentMovingImage) + value = "not loaded"; + return +end +value = sprintf("reference %d x %d, moving %d x %d", ... + size(cache.currentReferenceImage, 1), ... + size(cache.currentReferenceImage, 2), ... + size(cache.currentMovingImage, 1), ... + size(cache.currentMovingImage, 2)); +end + +function value = availability(condition, available, unavailable) +value = unavailable; +if condition + value = available; +end +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+workbench/workflowNotes.m b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/workflowNotes.m new file mode 100644 index 000000000..164a82e9d --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/workflowNotes.m @@ -0,0 +1,13 @@ +% App-owned implementation for dic_preprocess.workbench.workflowNotes within the dic_preprocess product workflow. +function lines = workflowNotes() +%WORKFLOWNOTES Return the static DIC preprocessing workflow instructions. +% Expected caller: workbench.present. Output is a string column and has no +% side effects. + +lines = [ + "1. Load a reference image and a moving image." + "2. Start point matching, alternate between reference and moving features, then apply alignment." + "3. False-color preview compares the current pair even before alignment." + "4. Align or crop the current working pair in any order; each applied operation can be undone." + "5. Draw curve or straight-line ROI boundaries, add/subtract them on the mask canvas, then save the mask."]; +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/createSession.m b/apps/dic/dic_preprocess/+dic_preprocess/createSession.m index 90ff302f0..a07514fbf 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/createSession.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/createSession.m @@ -1,9 +1,14 @@ %CREATESESSION Rebuild transient DIC images and editor workflow state. -% Expected caller: Runtime V2 through dic_preprocess.definition. Input is a +% Expected caller: App SDK through dic_preprocess.definition. Input is a % validated project; decoded and replayed images remain outside persistence. -function session = createSession(project) +function session = createSession(project, context) + referencePath = rolePath(project.inputs.sources, ... + "referenceImage", context); + movingPath = rolePath(project.inputs.sources, ... + "movingImage", context); [referenceImage, movingImage] = ... - dic_preprocess.sourceFiles.loadProjectImages(project.inputs.sources); + dic_preprocess.sourceFiles.loadProjectImages( ... + referencePath, movingPath); cache = dic_preprocess.analysisRun.replayEditSteps( ... referenceImage, movingImage, project.annotations.editSteps); session = struct( ... @@ -12,3 +17,18 @@ "details", {{'Alignment and crop details will appear here.'}}), ... "cache", cache); end + +function filepath = rolePath(sources, role, context) +filepath = ""; +if isempty(sources) + return +end +match = find(string({sources.role}) == role, 1); +if isempty(match) + return +end +paths = context.resolveSourcePaths(sources(match)); +if ~isempty(paths) + filepath = paths(1); +end +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/definition.m b/apps/dic/dic_preprocess/+dic_preprocess/definition.m index 1503c2b59..3a9ab4952 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/definition.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/definition.m @@ -1,23 +1,12 @@ % App-owned runtime definition for labkit_DICPreprocess_app. Expected caller: % the public app entrypoint. Output is a declarative LabKit app definition; % side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_DICPreprocess_app", ... - "Id", "dic_preprocess", ... - "Title", "DIC Image Preprocess", ... - "DisplayName", "DIC Preprocess", ... - "Family", "DIC", ... - "AppVersion", "1.5.8", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "image", ">=2.0 <3"), ... - "Project", dic_preprocess.projectSpec(), ... - "CreateSession", @dic_preprocess.createSession, ... - "Layout", @dic_preprocess.userInterface.buildWorkbenchLayout, ... - "Actions", dic_preprocess.definitionActions(), ... - "Present", @dic_preprocess.userInterface.presentWorkbench, ... - "Renderers", struct( ... - "image", @dic_preprocess.userInterface.renderPreviewImage), ... - "DebugSample", @dic_preprocess.debug.writeSamplePack); +function app = definition() + app = labkit.app.Definition(Entrypoint="labkit_DICPreprocess_app", ... + AppId="dic_preprocess", Title="DIC Image Preprocess", ... + DisplayName="DIC Preprocess", Family="DIC", AppVersion="1.6.1", ... + Updated="2026-07-20", Requirements=labkit.contract.requirements("app", ">=1 <2", "image", ">=2.0 <3"), ... + ProjectSchema=dic_preprocess.projectSpec(), CreateSession=@dic_preprocess.createSession, ... + Workbench=dic_preprocess.workbench.buildLayout(), PresentWorkbench=@dic_preprocess.workbench.present, ... + BuildDebugSample=@dic_preprocess.debug.writeSamplePack); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m b/apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m deleted file mode 100644 index d5a5776f4..000000000 --- a/apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m +++ /dev/null @@ -1,612 +0,0 @@ -% App-owned V2 action registry for DIC Preprocess. Handlers receive -% canonical state/events/services and own image loading, alignment, crop/mask -% annotations, undo history, and exports without raw UI handles. -function actions = definitionActions() - actions = struct( ... - "referenceChosen", @onReferenceChosen, ... - "referenceCleared", @onReferenceCleared, ... - "movingChosen", @onMovingChosen, ... - "movingCleared", @onMovingCleared, ... - "previewChanged", @onPreviewChanged, ... - "startPointMatching", @onStartPointMatching, ... - "pointPairsEdited", @onPointPairsEdited, ... - "applyPointAlignment", @onApplyPointAlignment, ... - "cancelPointMatching", @onCancelPointMatching, ... - "undoPointPair", @onUndoPointPair, ... - "autoAlign", @onAutoAlign, ... - "startCropRoi", @onStartCropRoi, ... - "cropRectMoved", @onCropRectMoved, ... - "applyCropRoi", @onApplyCropRoi, ... - "cancelCropRoi", @onCancelCropRoi, ... - "undoEdit", @onUndoEdit, ... - "saveCurrentImages", @onSaveCurrentImages, ... - "resetToOriginals", @onResetToOriginals, ... - "startMaskEdit", @onStartMaskEdit, ... - "maskPointsEdited", @onMaskPointsEdited, ... - "boundaryStyleChanged", @onBoundaryStyleChanged, ... - "previewMaskRoi", @onPreviewMaskRoi, ... - "addBoundaryToMask", @onAddBoundaryToMask, ... - "subtractBoundaryFromMask", @onSubtractBoundaryFromMask, ... - "undoMaskAnchor", @onUndoMaskAnchor, ... - "undoMaskEdit", @onUndoMaskEdit, ... - "clearMaskBoundary", @onClearMaskBoundary, ... - "clearMaskCanvas", @onClearMaskCanvas, ... - "saveMask", @onSaveMask); -end - -function state = onReferenceChosen(state, event, services) - state = onImageChosen(state, event, services, "reference"); -end - -function state = onMovingChosen(state, event, services) - state = onImageChosen(state, event, services, "moving"); -end - -function state = onImageChosen(state, event, services, role) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - state = services.workflow.log(state, titleCase(role) + ... - " image selection cancelled."); - return; - end - filepath = paths(1); - try - imageData = imread(filepath); - catch ME - services.diagnostics.report('Image load failed', ME); - services.dialogs.alert(ME.message, ... - 'Image load failed'); - return; - end - state.project.inputs.sources = services.project.upsertSource( ... - state.project.inputs.sources, role + "Image", role, filepath, true); - cacheField = char(role + "Image"); - state.session.cache.(cacheField) = imageData; - state.project = dic_preprocess.editHistory.resetForNewInput(state.project); - state = rebuildCache(state); - state = stopEditors(state); - state.project.parameters.previewMode = defaultPreviewMode(state); - state.session.workflow.details = {sprintf('Loaded %s image.', role)}; - state = clearResults(state); - state = services.workflow.log(state, ... - "Loaded " + role + " image: " + filepath); -end - -function state = onReferenceCleared(state, ~, services) - state = onImageCleared(state, services, "reference"); -end - -function state = onMovingCleared(state, ~, services) - state = onImageCleared(state, services, "moving"); -end - -function state = onImageCleared(state, services, role) - state.project.inputs.sources = removeSource( ... - state.project.inputs.sources, role); - state.session.cache.(char(role + "Image")) = []; - state.project = dic_preprocess.editHistory.resetForNewInput(state.project); - state = rebuildCache(state); - state = stopEditors(state); - state.project.parameters.previewMode = defaultPreviewMode(state); - state = clearResults(state); - state = services.workflow.log(state, "Cleared " + role + " image file."); -end - -function state = onPreviewChanged(state, ~, ~) - state = stopEditors(state); -end - -function state = onStartPointMatching(state, ~, services) - if alertIfMissingPair(state, services, ... - 'Load both reference and moving images before alignment.', ... - 'Missing images') - return; - end - state = stopEditors(state); - state.project.annotations.matchReferencePoints = zeros(0, 2); - state.project.annotations.matchMovingPoints = zeros(0, 2); - state.project.parameters.previewMode = "Current pair"; - state.session.workflow.mode = "matching"; - state.session.workflow.details = { ... - ['Point matching active. Select corresponding features in the ' ... - 'reference and moving previews; at least two pairs are required.']}; - state = services.workflow.log(state, ... - "Started point matching in the main reference and moving previews."); -end - -function state = onPointPairsEdited(state, event, ~) - if ~iscell(event.value) || numel(event.value) ~= 2 - return; - end - referencePoints = double(event.value{1}); - movingPoints = double(event.value{2}); - referenceCount = size(referencePoints, 1); - movingCount = size(movingPoints, 1); - if movingCount > referenceCount || referenceCount > movingCount + 1 - state.session.workflow.details = { ... - 'Select each reference feature before its moving-image match.'}; - return; - end - state.project.annotations.matchReferencePoints = referencePoints; - state.project.annotations.matchMovingPoints = movingPoints; - if referenceCount > movingCount - instruction = 'Now select the matching feature in the moving image.'; - else - instruction = 'Select the next feature in the reference image.'; - end - state.session.workflow.details = {sprintf( ... - 'Complete point pairs: %d. %s', movingCount, instruction)}; -end - -function state = onApplyPointAlignment(state, ~, services) - referencePoints = state.project.annotations.matchReferencePoints; - movingPoints = state.project.annotations.matchMovingPoints; - if size(referencePoints, 1) < 2 || ... - size(referencePoints, 1) ~= size(movingPoints, 1) - services.dialogs.alert( ... - 'Rigid registration requires at least two complete point pairs.', ... - 'Not enough points'); - return; - end - state = pushEditHistory(state, 'manual alignment'); - cache = state.session.cache; - try - [~, transform] = dic_preprocess.analysisRun.alignMovingToReference( ... - cache.currentReferenceImage, cache.currentMovingImage, ... - referencePoints, movingPoints); - catch ME - services.diagnostics.report('Point alignment failed', ME); - services.dialogs.alert(ME.message, ... - 'Point alignment failed'); - return; - end - state.project = appendEditStep(state.project, ... - "alignment", transform, [], "manual alignment"); - state.project = ... - dic_preprocess.maskEditing.clearOperationDerivedState(state.project); - state = rebuildCache(state); - state = stopEditors(state); - state.project.parameters.previewMode = "False-color overlay"; - state.session.workflow.details = ... - dic_preprocess.userInterface.transformSummary( ... - transform, size(state.session.cache.currentReferenceImage), ... - size(state.session.cache.currentMovingImage)); - state = clearResults(state); - state = services.workflow.log(state, sprintf( ... - 'Aligned image using %d point pair(s).', size(referencePoints, 1))); -end - -function state = onCancelPointMatching(state, ~, services) - if state.session.workflow.mode ~= "matching" - return; - end - state = stopEditors(state); - state.session.workflow.details = {'Point matching cancelled.'}; - state = services.workflow.log(state, "Cancelled point matching."); -end - -function state = onUndoPointPair(state, ~, ~) - reference = state.project.annotations.matchReferencePoints; - moving = state.project.annotations.matchMovingPoints; - if isempty(reference) - return; - end - if size(reference, 1) > size(moving, 1) - reference(end, :) = []; - else - reference(end, :) = []; - if ~isempty(moving) - moving(end, :) = []; - end - end - state.project.annotations.matchReferencePoints = reference; - state.project.annotations.matchMovingPoints = moving; -end - -function state = onAutoAlign(state, ~, services) - if alertIfMissingPair(state, services, ... - 'Load both reference and moving images before automatic alignment.', ... - 'Missing images') - return; - end - state = stopEditors(state); - cache = state.session.cache; - try - [~, transform, method] = ... - dic_preprocess.analysisRun.autoAlignMovingToReference( ... - cache.currentReferenceImage, cache.currentMovingImage); - catch ME - services.diagnostics.report('Automatic alignment failed', ME); - services.dialogs.alert(sprintf( ... - 'Automatic alignment failed:\n%s', ME.message), ... - 'Auto align failed'); - state = services.workflow.log(state, ... - "Automatic alignment failed: " + ME.message); - return; - end - state = pushEditHistory(state, 'automatic alignment'); - state.project = appendEditStep(state.project, ... - "alignment", transform, [], "automatic alignment"); - state.project = ... - dic_preprocess.maskEditing.clearOperationDerivedState(state.project); - state = rebuildCache(state); - state.project.parameters.previewMode = "False-color overlay"; - state.session.workflow.details = ... - dic_preprocess.userInterface.transformSummary( ... - transform, size(state.session.cache.currentReferenceImage), ... - size(state.session.cache.currentMovingImage)); - state = clearResults(state); - state = services.workflow.log(state, ... - "Automatically aligned current pair using " + string(method) + "."); -end - -function state = onStartCropRoi(state, ~, services) - if alertIfMissingPair(state, services, ... - 'Load both reference and moving images before cropping.', ... - 'Missing images') - return; - end - state = stopEditors(state); - state.project.annotations.cropRect = ... - dic_preprocess.analysisRun.defaultSquareRect( ... - size(state.session.cache.currentReferenceImage)); - state.project.parameters.previewMode = "Current pair"; - state.session.workflow.mode = "crop"; - state.session.workflow.details = ... - dic_preprocess.userInterface.cropSelectionSummary( ... - state.project.annotations.cropRect); - state = services.workflow.log(state, ... - "Started crop ROI on the current pair preview."); -end - -function state = onCropRectMoved(state, event, ~) - if state.session.workflow.mode ~= "crop" || numel(event.value) ~= 4 - return; - end - rect = dic_preprocess.analysisRun.squareRectInsideImage( ... - double(event.value), size(state.session.cache.currentReferenceImage)); - state.project.annotations.cropRect = rect; - state.session.workflow.details = ... - dic_preprocess.userInterface.cropSelectionSummary(rect); -end - -function state = onApplyCropRoi(state, ~, services) - rect = state.project.annotations.cropRect; - if state.session.workflow.mode ~= "crop" || isempty(rect) - services.dialogs.alert( ... - 'Start a crop ROI before applying the crop.', 'No active ROI'); - return; - end - state = pushEditHistory(state, 'crop'); - cache = state.session.cache; - rect = dic_preprocess.analysisRun.squareRectInsideImage( ... - rect, size(cache.currentReferenceImage)); - state.project = appendEditStep(state.project, ... - "crop", [], rect, "crop"); - state.project.annotations.cropRect = rect; - state.project = ... - dic_preprocess.maskEditing.clearOperationDerivedState(state.project); - state = rebuildCache(state); - state = stopEditors(state); - state.project.parameters.previewMode = "Current pair"; - state.session.workflow.details = dic_preprocess.userInterface.cropSummary(rect); - state = clearResults(state); - state = services.workflow.log(state, sprintf( ... - 'Cropped current pair with [%g %g %g %g].', rect)); -end - -function state = onCancelCropRoi(state, ~, services) - if state.session.workflow.mode ~= "crop" - return; - end - state = stopEditors(state); - state = services.workflow.log(state, "Crop ROI cancelled."); -end - -function state = onUndoEdit(state, ~, services) - history = state.project.annotations.history; - if isempty(history) - services.dialogs.alert( ... - 'No align or crop operation is available to undo.', 'Undo'); - return; - end - snapshot = history(end); - history(end) = []; - state.project.annotations.history = history; - state.project = dic_preprocess.editHistory.restoreEditSnapshot( ... - state.project, snapshot); - state = rebuildCache(state); - state = stopEditors(state); - state.project.parameters.previewMode = "Current pair"; - state.session.workflow.details = {sprintf( ... - 'Restored state before %s.', snapshot.description)}; - state = clearResults(state); - state = services.workflow.log(state, "Undid " + string(snapshot.description) + "."); -end - -function state = onResetToOriginals(state, ~, services) - if isempty(state.session.cache.referenceImage) || ... - isempty(state.session.cache.movingImage) - services.dialogs.alert( ... - 'Load both images before resetting the working pair.', 'Reset'); - return; - end - state = pushEditHistory(state, 'reset to originals'); - state.project = dic_preprocess.editHistory.resetToOriginals(state.project); - state = rebuildCache(state); - state = stopEditors(state); - state.project.parameters.previewMode = "Current pair"; - state.session.workflow.details = {'Current working pair reset to originals.'}; - state = clearResults(state); - state = services.workflow.log(state, ... - "Reset current working pair to the original loaded images."); -end - -function state = onSaveCurrentImages(state, ~, services) - if alertIfMissingPair(state, services, ... - 'Load both images before saving the current pair.', ... - 'Save current images') - return; - end - folderDefault = dic_preprocess.sourceFiles.defaultSaveFolder( ... - labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources, "referenceImage"), ... - labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources, "movingImage"), ... - services.dialogs.defaultFolder("output")); - [folder, cancelled] = services.dialogs.outputFolder( ... - 'Select folder for current images', folderDefault); - if cancelled - state = services.workflow.log(state, "Save current images cancelled."); - return; - end - outputs = dic_preprocess.resultFiles.writeCurrentImages( ... - state.session.cache.currentReferenceImage, ... - state.session.cache.currentMovingImage, folder); - spec = struct(); - spec.Outputs = [services.results.output( ... - "currentReference", "primary", "current_reference.png", "image/png"); ... - services.results.output( ... - "currentMoving", "primary", "current_moving.png", "image/png")]; - spec.Inputs = state.project.inputs.sources; - spec.Parameters = state.project.parameters; - spec.Summary = struct("pairSaved", true); - spec.ManifestName = "dic_preprocess_images.labkit.json"; - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.currentImagesManifestPath = string(manifestPath); - state = services.workflow.log(state, ... - "Saved current images: " + string(outputs.referencePath) + ... - " and " + string(outputs.movingPath)); -end - -function state = onStartMaskEdit(state, ~, services) - if isempty(state.session.cache.currentReferenceImage) - services.dialogs.alert( ... - 'Load a reference image before drawing an ROI mask.', ... - 'Missing image'); - return; - end - state = stopEditors(state); - state.project.annotations.maskImage = []; - state.project.annotations.maskPoints = zeros(0, 2); - state.project.annotations.maskHistory = ... - state.project.annotations.maskHistory([]); - state.session.workflow.mode = "mask"; - state.project.parameters.previewMode = "Current pair"; - state.session.workflow.details = { ... - ['ROI edit started. Add, move, or delete anchors in the reference ' ... - 'preview, then add/subtract the boundary on the mask canvas.']}; - state = services.workflow.log(state, ... - "Started mask ROI canvas for controlled anchor editing."); -end - -function state = onMaskPointsEdited(state, event, ~) - state.project.annotations.maskPoints = double(event.value); - state.session.workflow.details = ... - dic_preprocess.userInterface.maskDraftDetails( ... - state.project.annotations.maskPoints); -end - -function state = onBoundaryStyleChanged(state, ~, ~) - state.session.workflow.details = { ... - 'Boundary style: ' + string(state.project.parameters.maskBoundaryStyle) + '.'}; -end - -function state = onPreviewMaskRoi(state, ~, services) - [boundaryMask, ok] = currentBoundaryMask(state); - if ok - state.project.parameters.previewMode = "ROI mask"; - state.session.workflow.details = { ... - 'Boundary preview updated. Add it to the mask canvas, subtract it, or keep editing anchors.'}; - state = services.workflow.log(state, sprintf( ... - 'Previewed %s ROI boundary with %d anchors.', ... - state.project.parameters.maskBoundaryStyle, ... - size(state.project.annotations.maskPoints, 1))); - elseif ~isempty(state.project.annotations.maskImage) - state.project.parameters.previewMode = "ROI mask"; - else - services.dialogs.alert( ... - 'Mask ROI needs at least three anchors.', 'Not enough anchors'); - end -end - -function state = onAddBoundaryToMask(state, ~, services) - state = applyBoundary(state, services, "add"); -end - -function state = onSubtractBoundaryFromMask(state, ~, services) - state = applyBoundary(state, services, "subtract"); -end - -function state = applyBoundary(state, services, operation) - [boundaryMask, ok] = currentBoundaryMask(state); - if ~ok - services.dialogs.alert( ... - 'Mask ROI needs at least three anchors.', 'Not enough anchors'); - return; - end - state = pushMaskHistory(state, operation + " boundary"); - state.project.annotations.maskImage = ... - dic_preprocess.maskEditing.applyBoundaryToMask( ... - state.project.annotations.maskImage, ... - state.session.cache.currentReferenceImage, boundaryMask, operation); - state.project.parameters.previewMode = "ROI mask"; - state = clearResults(state); - state = services.workflow.log(state, titleCase(operation) + "ed " + ... - state.project.parameters.maskBoundaryStyle + ... - " boundary on the ROI mask canvas."); -end - -function state = onUndoMaskAnchor(state, ~, ~) - points = state.project.annotations.maskPoints; - if ~isempty(points) - points(end, :) = []; - state.project.annotations.maskPoints = points; - end -end - -function state = onUndoMaskEdit(state, ~, services) - history = state.project.annotations.maskHistory; - if isempty(history) - return; - end - snapshot = history(end); - history(end) = []; - state.project.annotations.maskHistory = history; - state.project = dic_preprocess.maskEditing.restoreMaskSnapshot( ... - state.project, snapshot); - state.project.parameters.previewMode = "ROI mask"; - state = clearResults(state); - state = services.workflow.log(state, ... - "Undid mask edit: " + string(snapshot.description) + "."); -end - -function state = onClearMaskBoundary(state, ~, services) - state.project.annotations.maskPoints = zeros(0, 2); - state = services.workflow.log(state, "Cleared mask ROI boundary anchors."); -end - -function state = onClearMaskCanvas(state, ~, services) - if isempty(state.project.annotations.maskImage) - return; - end - state = pushMaskHistory(state, 'clear mask canvas'); - state.project.annotations.maskImage = []; - state = clearResults(state); - state = services.workflow.log(state, "Cleared ROI mask canvas."); -end - -function state = onSaveMask(state, ~, services) - mask = state.project.annotations.maskImage; - if isempty(mask) - [mask, ok] = currentBoundaryMask(state); - if ~ok - services.dialogs.alert( ... - 'Draw a mask ROI or add a boundary before saving.', ... - 'Save ROI mask'); - return; - end - state.project.annotations.maskImage = mask; - end - defaultName = dic_preprocess.sourceFiles.defaultMaskPath( ... - labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources, "referenceImage"), ... - services.dialogs.defaultFolder("output")); - [outfile, cancelled] = services.dialogs.outputFile( ... - {'*.png', 'PNG mask'}, 'Save ROI mask', defaultName); - if cancelled - state = services.workflow.log(state, "Save ROI mask cancelled."); - return; - end - dic_preprocess.resultFiles.writeMask(mask, outfile); - [folder, name, extension] = fileparts(outfile); - spec = struct(); - spec.Outputs = services.results.output("roiMask", "primary", ... - string(name) + string(extension), "image/png"); - spec.Inputs = state.project.inputs.sources; - spec.Parameters = state.project.parameters; - spec.Summary = struct("anchorCount", ... - size(state.project.annotations.maskPoints, 1)); - spec.ManifestName = string(name) + ".labkit.json"; - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.maskManifestPath = string(manifestPath); - state = services.workflow.log(state, "Saved ROI mask: " + string(outfile)); -end - -function state = pushEditHistory(state, description) - state.project = dic_preprocess.editHistory.appendEditHistory( ... - state.project, description); -end - -function state = pushMaskHistory(state, description) - state.project = dic_preprocess.maskEditing.appendMaskHistory( ... - state.project, description); -end - -function project = appendEditStep(project, kind, transform, rect, description) - step = struct( ... - "kind", string(kind), ... - "transform", transform, ... - "rect", rect, ... - "description", string(description)); - steps = project.annotations.editSteps; - if isempty(steps) - steps = step; - else - steps(end + 1) = step; - end - project.annotations.editSteps = steps; -end - -function state = rebuildCache(state) - cache = state.session.cache; - state.session.cache = dic_preprocess.analysisRun.replayEditSteps( ... - cache.referenceImage, cache.movingImage, ... - state.project.annotations.editSteps); -end - -function [mask, ok] = currentBoundaryMask(state) - [mask, ok] = dic_preprocess.analysisRun.boundaryMaskFromEditor( ... - state.project.annotations.maskPoints, ... - size(state.session.cache.currentReferenceImage), ... - state.project.parameters.maskBoundaryStyle, []); -end - -function state = stopEditors(state) - state.session.workflow.mode = "idle"; - state.project.annotations.matchReferencePoints = zeros(0, 2); - state.project.annotations.matchMovingPoints = zeros(0, 2); -end - -function tf = alertIfMissingPair(state, services, message, titleText) - tf = ~dic_preprocess.sourceFiles.hasImagePair(state.session.cache); - if tf - services.dialogs.alert(message, titleText); - end -end - -function value = defaultPreviewMode(state) - if ~dic_preprocess.sourceFiles.hasImagePair(state.session.cache) - value = "Current pair"; - else - value = "False-color overlay"; - end -end - -function state = clearResults(state) - state.project.results.currentImagesManifestPath = ""; - state.project.results.maskManifestPath = ""; -end - -function sources = removeSource(sources, role) - if isempty(sources) - return; - end - sources(string({sources.id}) == role + "Image") = []; -end - -function value = titleCase(value) - value = char(string(value)); - value(1) = upper(value(1)); - value = string(value); -end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m b/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m index f8952932c..c35679f3c 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m @@ -2,17 +2,13 @@ % Expected caller: dic_preprocess.definition. Output owns the current payload % version, creation defaults, and validation. Side effects are none. function spec = projectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", []); + spec = labkit.app.project.Schema(Version=1, Create=@createProject, Validate=@validateProject); end function project = createProject() project = struct(); project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + struct([])); project.parameters = struct( ... "previewMode", "Current pair", ... "maskBoundaryStyle", "Curve"); diff --git a/apps/dic/dic_preprocess/labkit_DICPreprocess_app.m b/apps/dic/dic_preprocess/labkit_DICPreprocess_app.m index da305d6d9..3cbdbb3f1 100644 --- a/apps/dic/dic_preprocess/labkit_DICPreprocess_app.m +++ b/apps/dic/dic_preprocess/labkit_DICPreprocess_app.m @@ -1,6 +1,5 @@ function varargout = labkit_DICPreprocess_app(varargin) %LABKIT_DICPREPROCESS_APP Image registration and paired-crop app for DIC workflows. % Thin entrypoint delegates product metadata and launch behavior to one definition. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @dic_preprocess.definition, varargin{:}); + [varargout{1:nargout}] = dic_preprocess.definition().launch(varargin{:}); end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+debug/writeSamplePack.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+debug/writeSamplePack.m index cc7914d06..fe2aca09b 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+debug/writeSamplePack.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+debug/writeSamplePack.m @@ -1,41 +1,34 @@ -% Expected caller: chrono_overlay.definitionActions startup action and unit tests. +% Expected caller: Chrono Overlay debug-sample tooling and unit tests. % Input is a LabKit debug context. Output is a deterministic synthetic DTA % sample pack. Side effects: writes anonymous debug input files under the % debug samples folder and records a session manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Chrono Overlay debug DTA files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "chrono_overlay"); - dtaFolder = fullfile(char(folders.sampleFolder), "dta"); - ensureFolder(dtaFolder); - - currentPath = string(fullfile(dtaFolder, "chrono_current_pulse_debug.DTA")); - voltagePath = string(fullfile(dtaFolder, "chrono_voltage_pulse_debug.DTA")); - flatPath = string(fullfile(dtaFolder, "chrono_valid_no_pulse_debug.DTA")); - malformedPath = string(fullfile(dtaFolder, "chrono_malformed_missing_table_debug.DTA")); + currentPath = sampleContext.samplePath("chrono_overlay/current.DTA"); + voltagePath = sampleContext.samplePath("chrono_overlay/voltage.DTA"); + flatPath = sampleContext.samplePath("chrono_overlay/no_pulse.DTA"); + malformedPath = sampleContext.samplePath("chrono_overlay/malformed.DTA"); writeTextFile(currentPath, chronoText("current")); writeTextFile(voltagePath, chronoText("voltage")); writeTextFile(flatPath, flatChronoText()); writeTextFile(malformedPath, malformedChronoText()); - pack = struct( ... - "sampleFolder", folders.sampleFolder, ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", [currentPath; voltagePath], ... - "boundaryFiles", struct( ... - "validEdge", flatPath, ... - "malformed", malformedPath)); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_ChronoOverlay_app", ... - "description", "Anonymous chrono DTA boundary pack for overlay debug launch.", ... - "inputs", struct( ... - "representativeChronoDta", pack.representativeFiles, ... - "validEdgeChronoDta", flatPath, ... - "malformedChronoDta", malformedPath), ... - "outputFolder", folders.outputFolder); - pack.manifest = manifest; - recordManifest(debugLog, manifest); + project = chrono_overlay.projectSpec().Create(); + project.inputs.sources = [ ... + sampleContext.sourceRecord("dta1", "chrono", currentPath, true), ... + sampleContext.sourceRecord("dta2", "chrono", voltagePath, true)]; + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-chrono-overlay", InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact("currentPulse", "chrono", currentPath), ... + sampleContext.artifact("voltagePulse", "chrono", voltagePath), ... + sampleContext.artifact("noPulse", "boundaryInput", flatPath), ... + sampleContext.artifact("malformed", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function text = flatChronoText() @@ -130,35 +123,6 @@ end end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder") - sampleFolder = string(debugLog.sampleFolder); - end - if isfield(debugLog, "outputFolder") - outputFolder = string(debugLog.outputFolder); - end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && ... - isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 @@ -169,12 +133,6 @@ function writeTextFile(filepath, text) fprintf(fid, "%s", char(text)); end -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end - function value = tab() value = char(9); end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+overlayPlot/draw.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+overlayPlot/draw.m new file mode 100644 index 000000000..1f5d27d1d --- /dev/null +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+overlayPlot/draw.m @@ -0,0 +1,182 @@ +% Expected caller: the registered Chrono Overlay renderer. Inputs are the +% named voltage/current axes and one prepared overlay model. +% Side effects are limited to redrawing the supplied axes. +function draw(axesById, model) + renderSignal(axesById.voltage, model, "voltage"); + renderSignal(axesById.current, model, "current"); +end + +function renderSignal(ax, model, signal) + options = model.options; + items = model.items; + clearAxesForRedraw(ax); + if isempty(items) + renderEmpty(ax, signal); + return; + end + + colorMap = lines(numel(items)); + hold(ax, 'on'); + labels = cell(1, numel(items)); + plotLines = gobjects(1, numel(items)); + for k = 1:numel(items) + item = items(k); + plotLines(k) = plot(ax, chooseX(item, options.xAxis), ... + signalValues(item, signal), ... + 'LineWidth', options.lineWidth, 'Color', colorMap(k, :)); + labels{k} = char(item.name); + end + hold(ax, 'off'); + fitAxesToLines(ax, plotLines(isgraphics(plotLines))); + xlabel(ax, axisLabel(options.xAxis)); + [titleText, yLabel] = signalLabels(signal, numel(items)); + ylabel(ax, yLabel); + title(ax, titleText); + setGrid(ax, options.showGrid); + setLegend(ax, labels, options.showLegend); +end + +function clearAxesForRedraw(ax) + delete(allchild(ax)); + cla(ax); + legend(ax, "off"); + hold(ax, "off"); + ax.XLimMode = "auto"; + ax.YLimMode = "auto"; + ax.XScale = "linear"; + ax.YScale = "linear"; + ax.XTickMode = "auto"; + ax.YTickMode = "auto"; +end + +function fitAxesToLines(ax, plotLines) + xParts = cell(1, numel(plotLines)); + yParts = cell(1, numel(plotLines)); + for k = 1:numel(plotLines) + xParts{k} = plotLines(k).XData(:); + yParts{k} = plotLines(k).YData(:); + end + x = vertcat(xParts{:}); + y = vertcat(yParts{:}); + x = x(isfinite(x)); + y = y(isfinite(y)); + applyPaddedLimits(ax, x, y); +end + +function applyPaddedLimits(ax, x, y) + if isempty(x) + xlim(ax, "auto"); + else + xlim(ax, paddedLimits(x)); + end + if isempty(y) + ylim(ax, "auto"); + else + ylim(ax, paddedLimits(y)); + end +end + +function limits = paddedLimits(values) + lower = min(values); + upper = max(values); + if lower == upper + padding = max(abs(lower), 1) * 0.05; + else + padding = (upper - lower) * 0.02; + end + limits = [lower - padding, upper + padding]; +end + +function renderEmpty(ax, signal) + if signal == "voltage" + title(ax, 'Voltage'); + ylabel(ax, 'Vf (V)'); + else + title(ax, 'Current'); + ylabel(ax, 'Im (A)'); + end + xlabel(ax, 'Blank-Center Aligned Time (s)'); +end + +function values = signalValues(item, signal) + if signal == "voltage" + values = firstAvailable(item, "Vf_V", "Vf"); + else + values = firstAvailable(item, "Im_A", "Im"); + end +end + +function values = firstAvailable(item, preferred, fallback) + values = []; + if isfield(item, preferred) && ~isempty(item.(preferred)) + values = item.(preferred)(:); + elseif isfield(item, fallback) && ~isempty(item.(fallback)) + values = item.(fallback)(:); + end +end + +function x = chooseX(item, mode) + % Constant: 1000 converts seconds to milliseconds for the selected axis. + millisecondsPerSecond = 1e3; + switch string(mode) + case "Time (ms)" + x = millisecondsPerSecond * alignedTime(item); + case "Sample #" + x = samplePoint(item); + otherwise + x = alignedTime(item); + end +end + +function values = alignedTime(item) + values = firstAvailable(item, "tAligned_s", "tAligned"); +end + +function values = samplePoint(item) + if isfield(item, 'pt') && ~isempty(item.pt) + values = item.pt(:); + else + values = (0:numel(alignedTime(item))-1).'; + end +end + +function text = axisLabel(mode) + switch string(mode) + case "Time (ms)" + text = 'Blank-Center Aligned Time (ms)'; + case "Sample #" + text = 'Sample #'; + otherwise + text = 'Blank-Center Aligned Time (s)'; + end +end + +function [titleText, yLabel] = signalLabels(signal, count) + suffix = 's'; + if count == 1 + suffix = ''; + end + if signal == "voltage" + titleText = sprintf('Voltage Overlay (%d file%s)', count, suffix); + yLabel = 'Vf (V)'; + else + titleText = sprintf('Current Overlay (%d file%s)', count, suffix); + yLabel = 'Im (A)'; + end +end + +function setGrid(ax, visible) + if visible + grid(ax, 'on'); + else + grid(ax, 'off'); + end +end + +function setLegend(ax, labels, visible) + if visible + legend(ax, labels, 'Interpreter', 'none', 'Location', 'best'); + else + legend(ax, 'off'); + end +end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/buildOverlayExportTable.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/buildOverlayExportTable.m index 8da1502a6..399eb5f71 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/buildOverlayExportTable.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/buildOverlayExportTable.m @@ -1,4 +1,4 @@ -% Expected caller: chrono_overlay.definitionActions and export tests. Inputs are +% Expected caller: chrono_overlay.stateHandlers and export tests. Inputs are % aligned chrono item structs. Output is the stable overlay export table. No % file side effects. diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/exportSelectedCurves.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/exportSelectedCurves.m new file mode 100644 index 000000000..9016b8ded --- /dev/null +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/exportSelectedCurves.m @@ -0,0 +1,47 @@ +% App-owned implementation for chrono_overlay.resultFiles.exportSelectedCurves within the chrono_overlay product workflow. +function state = exportSelectedCurves(state, context) +%EXPORTSELECTEDCURVES Let the user write the selected curves and manifest. +% +% Expected caller: the export button declared by Chrono Overlay. This +% framework-boundary callback reads the current selected indices, delegates +% table construction to buildOverlayExportTable, writes the chosen CSV and +% result package, and records the destinations in project results. + +arguments + state (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end + +items = selectedItems(state.session.cache.items, ... + state.session.selection.files.Indices); +if isempty(items) + context.alert("No files selected for export.", "Export"); + return; +end +chosen = context.chooseOutputFile( ... + ["*.csv", "CSV files (*.csv)"], pwd); +if chosen.Cancelled + return; +end +outputPath = string(chosen.Value); +tableValue = chrono_overlay.resultFiles.buildOverlayExportTable(items); +writetable(tableValue, outputPath); +[folder, base, extension] = fileparts(outputPath); +outputName = string(base) + string(extension); +output = labkit.app.result.File( ... + "overlayCurves", "primary", outputName, MediaType="text/csv"); +result = labkit.app.result.Package( ... + Outputs={output}, ... + Inputs=struct("sources", state.project.inputs.sources), ... + Parameters=state.project.parameters, ... + Summary=struct("fileCount", numel(items))); +written = context.writeResultPackage(folder, result); +state.project.results.lastExport = struct( ... + "csvPath", outputPath, "manifestPath", string(written.Value)); +context.appendStatus("Exported CSV: " + outputPath); +end + +function items = selectedItems(items, indices) +indices = indices(indices <= numel(items)); +items = items(indices); +end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/alignByPulseGap.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/alignByPulseGap.m index 6e2bfd169..c6d412b9c 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/alignByPulseGap.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/alignByPulseGap.m @@ -1,4 +1,4 @@ -% Expected caller: chrono_overlay.definitionActions and unit tests. Inputs are +% Expected caller: chrono_overlay.stateHandlers and unit tests. Inputs are % one chrono item struct with time/current/voltage and pulse fields. Outputs % return the aligned item and status message. No file or UI side effects. diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/loadProjectItems.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/loadProjectItems.m index 2c3c7a92c..19f0405d9 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/loadProjectItems.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/loadProjectItems.m @@ -1,9 +1,9 @@ -% Expected caller: Chrono Overlay session creation. Input is canonical source -% records. Output is the rebuildable decoded and pulse-aligned DTA item vector. -function items = loadProjectItems(sources) +% Expected caller: Chrono Overlay session creation. Input is runtime-resolved +% paths. Output is the rebuildable decoded and pulse-aligned DTA item vector. +function items = loadProjectItems(paths) items = struct([]); - paths = labkit.ui.runtime.sourcePaths(sources); - for k = 1:numel(sources) + paths = string(paths(:)); + for k = 1:numel(paths) filepath = paths(k); [item, status] = labkit.dta.loadFile(filepath, "chrono"); if ~status.ok diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index d6ee00d73..000000000 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,84 +0,0 @@ -% Expected caller: chrono_overlay.definition. Input is a callback struct whose -% fields are app-owned callback handles. Output is a data-only UI 5 workbench -% layout for the Chrono Overlay app. -function layout = buildWorkbenchLayout(callbacks) - - layout = labkit.ui.layout.workbench("chronoOverlay", ... - "Gamry Multi-DTA Plot Export GUI", ... - "controlTabs", controlTabs(callbacks), ... - "workspace", plotsWorkspace(), ... - "usage", usageLines()); -end - -function tabs = controlTabs(callbacks) - tabs = {filesAnalysisTab(callbacks), logTab()}; -end - -function tab = filesAnalysisTab(callbacks) - tab = labkit.ui.layout.tab("filesAnalysis", "Files + Analysis", { ... - filesSection(callbacks), ... - plotOptionsSection()}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log")})}); -end - -function section = filesSection(callbacks) - section = labkit.ui.layout.section("filesSection", "Files", { ... - labkit.ui.layout.filePanel("files", "Files", ... - "selectionMode", "multiple", ... - "chooseLabel", "Add DTA files", ... - "clearLabel", "Clear all", ... - "filters", {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... - "status", "No files loaded", ... - "onChoose", callbacks.openFilesChosen, ... - "onRemove", callbacks.removeSelected, ... - "onClear", callbacks.clearAll, ... - "onSelectionChange", callbacks.selectionChanged), ... - labkit.ui.layout.group("fileActions", "", { ... - labkit.ui.layout.action("exportCurves", ... - "Export curves CSV", callbacks.exportCSV)})}); -end - -function section = plotOptionsSection() - section = labkit.ui.layout.section("plotOptions", "Plot Options", { ... - labkit.ui.layout.field("xAxis", "X axis:", ... - "kind", "dropdown", ... - "items", {'Time (s)', 'Time (ms)', 'Sample #'}, ... - "Bind", "project.parameters.xAxis"), ... - labkit.ui.layout.panner("lineWidth", "Line width:", ... - "limits", [0.1 10], ... - "step", 0.1, ... - "Bind", "project.parameters.lineWidth"), ... - labkit.ui.layout.field("showLegend", ... - "Show file-name legend", ... - "kind", "checkbox", ... - "Bind", "project.parameters.showLegend"), ... - labkit.ui.layout.field("showGrid", "Show grid", ... - "kind", "checkbox", ... - "Bind", "project.parameters.showGrid")}); -end - -function lines = usageLines() - lines = { ... - '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.'}; -end - -function workspace = plotsWorkspace() - workspace = labkit.ui.layout.workspace("plots", "Overlay Plots", { ... - labkit.ui.layout.previewArea("overlayPlots", "Overlay Plots", ... - "layout", "stack", ... - "count", 2, ... - "axisIds", {'voltage', 'current'}, ... - "axisTitles", {'Voltage', 'Current'}, ... - "xLabels", {'Time (s)', 'Time (s)'}, ... - "yLabels", {'Vf (V)', 'Im (A)'})}); -end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/presentWorkbench.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/presentWorkbench.m deleted file mode 100644 index c44f9f4c2..000000000 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,64 +0,0 @@ -% Expected caller: the LabKit V2 runtime. Input is canonical Chrono Overlay -% state. Output is a deterministic control and multi-axis preview model with -% no access to the UI registry. -function view = presentWorkbench(state) - items = state.session.cache.items; - files = fileEntries(items); - selectedIds = selectedFileIds(items, state.session.selection.paths); - plotItems = selectedItems(items, state.session.selection.paths); - model = struct(); - model.items = plotItems; - model.options = state.project.parameters; - - view = struct(); - view.controls.files = struct(); - view.controls.files.Files = files; - view.controls.files.Selection = selectedIds; - view.controls.files.Status = fileStatus(numel(items)); - view.previews.overlayPlots.Axes.voltage = struct( ... - "Renderer", "overlay", ... - "Model", withSignal(model, "voltage")); - view.previews.overlayPlots.Axes.current = struct( ... - "Renderer", "overlay", ... - "Model", withSignal(model, "current")); -end - -function value = fileStatus(count) - if count == 0 - value = "No files loaded"; - else - value = count + " file(s) loaded"; - end -end - -function files = fileEntries(items) - files = struct("id", {}, "path", {}, "status", {}); - for k = 1:numel(items) - files(end + 1) = struct( ... - "id", "item" + string(k), ... - "path", string(items(k).filepath), ... - "status", ""); - end -end - -function ids = selectedFileIds(items, paths) - ids = strings(0, 1); - if isempty(items) - return; - end - mask = ismember(string({items.filepath}), paths(:)); - ids = "item" + string(find(mask)).'; -end - -function selected = selectedItems(items, paths) - selected = items; - if isempty(items) - return; - end - selected = items(ismember(string({items.filepath}), paths(:))); -end - -function value = withSignal(model, signal) - value = model; - value.signal = string(signal); -end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/renderOverlayAxis.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/renderOverlayAxis.m deleted file mode 100644 index 012054a46..000000000 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/renderOverlayAxis.m +++ /dev/null @@ -1,126 +0,0 @@ -% Expected caller: the registered Chrono Overlay V2 renderer. Inputs are one -% axes and a prepared model containing items, plot options, and signal kind. -% Side effects are limited to redrawing the supplied axes. -function renderOverlayAxis(ax, model) - options = model.options; - items = model.items; - labkit.ui.plot.clear(ax, "ResetScale", true); - if isempty(items) - renderEmpty(ax, model.signal); - return; - end - - colorMap = lines(numel(items)); - hold(ax, 'on'); - labels = cell(1, numel(items)); - plotLines = gobjects(1, numel(items)); - for k = 1:numel(items) - item = items(k); - plotLines(k) = plot(ax, chooseX(item, options.xAxis), ... - signalValues(item, model.signal), ... - 'LineWidth', options.lineWidth, 'Color', colorMap(k, :)); - labels{k} = char(item.name); - end - hold(ax, 'off'); - labkit.ui.plot.fit(ax, plotLines(isgraphics(plotLines))); - xlabel(ax, axisLabel(options.xAxis)); - [titleText, yLabel] = signalLabels(model.signal, numel(items)); - ylabel(ax, yLabel); - title(ax, titleText); - setGrid(ax, options.showGrid); - setLegend(ax, labels, options.showLegend); -end - -function renderEmpty(ax, signal) - if signal == "voltage" - title(ax, 'Voltage'); - ylabel(ax, 'Vf (V)'); - else - title(ax, 'Current'); - ylabel(ax, 'Im (A)'); - end - xlabel(ax, 'Blank-Center Aligned Time (s)'); -end - -function values = signalValues(item, signal) - if signal == "voltage" - values = firstAvailable(item, "Vf_V", "Vf"); - else - values = firstAvailable(item, "Im_A", "Im"); - end -end - -function values = firstAvailable(item, preferred, fallback) - values = []; - if isfield(item, preferred) && ~isempty(item.(preferred)) - values = item.(preferred)(:); - elseif isfield(item, fallback) && ~isempty(item.(fallback)) - values = item.(fallback)(:); - end -end - -function x = chooseX(item, mode) - % Constant: 1000 converts seconds to milliseconds for the selected axis. - millisecondsPerSecond = 1e3; - switch string(mode) - case "Time (ms)" - x = millisecondsPerSecond * alignedTime(item); - case "Sample #" - x = samplePoint(item); - otherwise - x = alignedTime(item); - end -end - -function values = alignedTime(item) - values = firstAvailable(item, "tAligned_s", "tAligned"); -end - -function values = samplePoint(item) - if isfield(item, 'pt') && ~isempty(item.pt) - values = item.pt(:); - else - values = (0:numel(alignedTime(item))-1).'; - end -end - -function text = axisLabel(mode) - switch string(mode) - case "Time (ms)" - text = 'Blank-Center Aligned Time (ms)'; - case "Sample #" - text = 'Sample #'; - otherwise - text = 'Blank-Center Aligned Time (s)'; - end -end - -function [titleText, yLabel] = signalLabels(signal, count) - suffix = 's'; - if count == 1 - suffix = ''; - end - if signal == "voltage" - titleText = sprintf('Voltage Overlay (%d file%s)', count, suffix); - yLabel = 'Vf (V)'; - else - titleText = sprintf('Current Overlay (%d file%s)', count, suffix); - yLabel = 'Im (A)'; - end -end - -function setGrid(ax, visible) - if visible - grid(ax, 'on'); - else - grid(ax, 'off'); - end -end - -function setLegend(ax, labels, visible) - if visible - legend(ax, labels, 'Interpreter', 'none', 'Location', 'best'); - else - legend(ax, 'off'); - end -end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m new file mode 100644 index 000000000..175c4f1ea --- /dev/null +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m @@ -0,0 +1,61 @@ +% Expected caller: chrono_overlay.definition. Callbacks live with the +% workflow capability they perform; bindings and standard file lifecycle are +% supplied by the LabKit App runtime. +function layout = buildLayout() + files = labkit.app.layout.fileList("files", ... + Label="Files", ... + Filters=["*.DTA;*.dta", "Gamry DTA (*.DTA)", "*.*", "All files"], ... + ChooseLabel="Add DTA files", ... + ChooseTooltip="Add Gamry chrono DTA traces whose voltage and current will be aligned around the interphase time gap.", ... + FolderLabel="Add folder", ... + RecursiveFolderLabel="Add folder tree", ... + RemoveLabel="Remove selected", ClearLabel="Clear all", ... + EmptyText="No files loaded", ... + Bind="project.inputs.sources", ... + SelectionBind="session.selection.files", ... + SourceRole="chrono", SourceIdPrefix="dta", Required=true); + controls = { ... + labkit.app.layout.tab("filesAnalysis", "Files + Analysis", { ... + labkit.app.layout.section("filesSection", "Files", { ... + files, ... + labkit.app.layout.button("exportCurves", ... + "Export curves CSV", ... + @chrono_overlay.resultFiles.exportSelectedCurves, ... + Tooltip="Export time-gap-centered voltage and current curves; differing time grids are merged by interpolation.")}), ... + labkit.app.layout.section("plotOptions", "Plot Options", { ... + labkit.app.layout.field("xAxis", Label="X axis:", ... + Kind="choice", ... + Choices=["Time (s)", "Time (ms)", "Sample #"], ... + Bind="project.parameters.xAxis"), ... + labkit.app.layout.slider("lineWidth", Label="Line width:", ... + Limits=[0.1 10], Step=0.1, ... + Bind="project.parameters.lineWidth"), ... + labkit.app.layout.field("showLegend", ... + Label="Show file-name legend", Kind="logical", ... + Bind="project.parameters.showLegend"), ... + labkit.app.layout.field("showGrid", Label="Show grid", ... + Kind="logical", Bind="project.parameters.showGrid")})}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.logPanel("appLog")})}; + workspace = labkit.app.layout.workspace( ... + labkit.app.layout.plotArea("overlayPlots", ... + @chrono_overlay.overlayPlot.draw, ... + Title="Overlay Plots", Layout="stack", ... + AxisIds=["voltage", "current"], ... + AxisTitles=["Voltage", "Current"], ... + XLabels=["Time (s)", "Time (s)"], ... + YLabels=["Vf (V)", "Im (A)"]), ... + Title="Overlay Plots"); + layout = labkit.app.layout.workbench(controls, Workspace=workspace, ... + Usage=usageLines()); +end + +function lines = usageLines() +lines = [ ... + "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."]; +end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/present.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/present.m new file mode 100644 index 000000000..d8e1d7f9b --- /dev/null +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/present.m @@ -0,0 +1,21 @@ +% Expected caller: the LabKit App runtime. Binding values, file rows, +% selection, and framework log text are supplied by the runtime; this +% presenter owns only Chrono-specific availability and plot content. +function view = present(state) + arguments + state (1, 1) struct + end + items = selectedItems(state); + model = struct( ... + "items", items, ... + "options", state.project.parameters); + view = labkit.app.view.Snapshot() ... + .renderPlot("overlayPlots", model); +end + +function items = selectedItems(state) + items = state.session.cache.items; + indices = state.session.selection.files.Indices; + indices = indices(indices <= numel(items)); + items = items(indices); +end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/createSession.m b/apps/electrochem/chrono_overlay/+chrono_overlay/createSession.m index f2d7f135f..eb02550c4 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/createSession.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/createSession.m @@ -1,13 +1,15 @@ %CREATESESSION Rebuild transient Chrono Overlay DTA items and selection. -% Expected caller: Runtime V2 through chrono_overlay.definition. Input is a -% validated current project; decoded DTA curves remain outside persistence. -function session = createSession(project) - items = chrono_overlay.sourceFiles.loadProjectItems(project.inputs.sources); - selectedPaths = strings(0, 1); - if ~isempty(items) - selectedPaths = string({items.filepath}).'; +% Expected caller: LabKit App runtime through chrono_overlay.definition. +% Portable sources remain opaque; context resolves their current paths. +function session = createSession(project, context) + arguments + project (1, 1) struct + context (1, 1) labkit.app.CallbackContext end + paths = context.resolveSourcePaths(project.inputs.sources); + items = chrono_overlay.sourceFiles.loadProjectItems(paths); session = struct( ... - "selection", struct("paths", selectedPaths), ... + "selection", struct("files", labkit.app.event.ListSelection( ... + Indices=1:numel(paths))), ... "cache", struct("items", items)); end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m b/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m index 7fe679cc7..4a2307b65 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m @@ -1,23 +1,19 @@ -% App-owned runtime definition for labkit_ChronoOverlay_app. Expected caller: -% the public app entrypoint. Output is a declarative LabKit app definition; -% side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_ChronoOverlay_app", ... - "Id", "chrono_overlay", ... - "Title", "Gamry Multi-DTA Plot Export GUI", ... - "DisplayName", "Chrono Overlay", ... - "Family", "Electrochem", ... - "AppVersion", "1.4.7", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "dta", ">=2.0 <3"), ... - "Project", chrono_overlay.projectSpec(), ... - "CreateSession", @chrono_overlay.createSession, ... - "Layout", @chrono_overlay.userInterface.buildWorkbenchLayout, ... - "Actions", chrono_overlay.definitionActions(), ... - "Present", @chrono_overlay.userInterface.presentWorkbench, ... - "Renderers", struct( ... - "overlay", @chrono_overlay.userInterface.renderOverlayAxis), ... - "DebugSample", @chrono_overlay.debug.writeSamplePack); +% App-owned explicit contract for labkit_ChronoOverlay_app. Construction is +% side-effect free and validates the complete semantic graph before GUI work. +function app = definition() + app = labkit.app.Definition( ... + Entrypoint="labkit_ChronoOverlay_app", ... + AppId="chrono_overlay", ... + Title="Gamry Multi-DTA Plot Export GUI", ... + DisplayName="Chrono Overlay", ... + Family="Electrochem", ... + AppVersion="1.5.1", ... + Updated="2026-07-20", ... + Requirements=labkit.contract.requirements( ... + "app", ">=1 <2", "dta", ">=2.0 <3"), ... + ProjectSchema=chrono_overlay.projectSpec(), ... + CreateSession=@chrono_overlay.createSession, ... + Workbench=chrono_overlay.workbench.buildLayout(), ... + PresentWorkbench=@chrono_overlay.workbench.present, ... + BuildDebugSample=@chrono_overlay.debug.writeSamplePack); end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m b/apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m deleted file mode 100644 index f981ce8fd..000000000 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/definitionActions.m +++ /dev/null @@ -1,161 +0,0 @@ -% App-owned V2 action table for Chrono Overlay. Handlers receive canonical -% state/events/services and never read or mutate UI controls directly. -function actions = definitionActions() - actions = struct( ... - "openFilesChosen", @onOpenFilesChosen, ... - "removeSelected", @onRemoveSelected, ... - "clearAll", @onClearAll, ... - "exportCSV", @onExportCSV, ... - "selectionChanged", @onSelectionChanged); -end - -function state = onOpenFilesChosen(state, event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - state = services.workflow.log(state, "Open cancelled."); - return; - end - firstFailure = struct("filepath", "", "message", ""); - hasFailure = false; - for k = 1:numel(paths) - filepath = paths(k); - if isLoaded(state.session.cache.items, filepath) - state = services.workflow.log(state, ... - "Skipped already loaded: " + filepath); - continue; - end - [item, status] = labkit.dta.loadFile(filepath, "chrono"); - if ~status.ok - if ~hasFailure - firstFailure = struct( ... - "filepath", filepath, ... - "message", string(status.message)); - hasFailure = true; - end - state = services.workflow.log(state, ... - "Failed: " + filepath + " | " + status.message); - continue; - end - [item, alignMessage] = chrono_overlay.sourceFiles.alignByPulseGap(item); - state.session.cache.items = appendItem( ... - state.session.cache.items, item); - state.session.selection.paths(end + 1, 1) = filepath; - state = services.workflow.log(state, alignMessage); - for iMessage = 1:numel(item.logmsg) - state = services.workflow.log(state, item.logmsg{iMessage}); - end - state = services.workflow.log(state, string(item.name) + ": " + item.message); - state = services.workflow.log(state, "Loaded: " + filepath); - end - state = reconcileProjectSources(state, services); - if hasFailure - services.dialogs.alert(sprintf( ... - 'Failed to load:\n%s\n\n%s', firstFailure.filepath, ... - firstFailure.message), 'Load error'); - end -end - -function state = onRemoveSelected(state, event, services) - paths = services.events.paths(event, "removedFiles"); - if isempty(paths) - return; - end - [state.session.cache.items, removed] = removeItems( ... - state.session.cache.items, paths); - state = reconcileProjectSources(state, services); - state.session.selection.paths = setdiff( ... - state.session.selection.paths, paths, 'stable'); - for k = 1:numel(removed) - state = services.workflow.log(state, "Removed: " + removed(k)); - end -end - -function state = onClearAll(state, ~, services) - state.session.cache.items = struct([]); - state = reconcileProjectSources(state, services); - state.session.selection.paths = strings(0, 1); - state = services.workflow.log(state, "Cleared all files."); -end - -function state = onSelectionChanged(state, event, services) - state.session.selection.paths = ... - services.events.paths(event, "selectedFiles"); -end - -function state = onExportCSV(state, ~, services) - if isempty(state.session.cache.items) - services.dialogs.alert( ... - 'No files loaded.', 'Export'); - return; - end - items = selectedItems(state); - if isempty(items) - services.dialogs.alert( ... - 'No files selected for export.', 'Export'); - return; - end - [out, cancelled] = services.dialogs.outputFile( ... - 'gamry_overlay_curves.csv', 'Save overlay curves CSV', ... - 'gamry_overlay_curves.csv'); - if cancelled - return; - end - tableValue = chrono_overlay.resultFiles.buildOverlayExportTable(items); - writetable(tableValue, out); - [folder, base, extension] = fileparts(out); - outputName = string(base) + string(extension); - outputs = services.results.output( ... - "overlayCurves", "primary", outputName, "text/csv"); - spec = struct(); - spec.Outputs = outputs; - spec.Inputs = state.project.inputs.sources; - spec.Parameters = state.project.parameters; - spec.Summary = struct("fileCount", numel(items)); - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.lastExport = struct( ... - "csvPath", string(out), "manifestPath", string(manifestPath)); - state = services.workflow.log(state, "Exported CSV: " + string(out)); -end - -function items = selectedItems(state) - items = state.session.cache.items; - if isempty(items) - return; - end - selected = state.session.selection.paths; - keep = ismember(string({items.filepath}), selected(:)); - items = items(keep); -end - -function tf = isLoaded(items, filepath) - tf = ~isempty(items) && ... - any(string({items.filepath}) == string(filepath)); -end - -function items = appendItem(items, item) - if isempty(items) - items = item; - else - items(end + 1) = item; - end -end - -function [items, removed] = removeItems(items, paths) - removed = strings(0, 1); - if isempty(items) - return; - end - itemPaths = string({items.filepath}); - keep = ~ismember(itemPaths, paths(:)); - removed = itemPaths(~keep).'; - items = items(keep); -end - -function state = reconcileProjectSources(state, services) - paths = strings(0, 1); - if ~isempty(state.session.cache.items) - paths = string({state.session.cache.items.filepath}).'; - end - state.project.inputs.sources = services.project.reconcileSources( ... - state.project.inputs.sources, paths, "chrono", "dta", true); -end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/projectSpec.m b/apps/electrochem/chrono_overlay/+chrono_overlay/projectSpec.m index 804270c58..0dad8a9d2 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/projectSpec.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/projectSpec.m @@ -2,16 +2,14 @@ % Expected caller: chrono_overlay.definition. Output owns the current payload % version, creation, validation, and one version-aware migration entry. function spec = projectSpec() - spec = struct( ... - "Version", 2, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", @migrateProject); + spec = labkit.app.project.Schema( ... + Version=2, Create=@createProject, Validate=@validateProject, ... + Migrate=@migrateProject); end function project = createProject() project = struct( ... - "inputs", struct("sources", labkit.ui.runtime.emptySourceRecords()), ... + "inputs", struct("sources", struct([])), ... "parameters", struct( ... "xAxis", "Time (s)", ... "lineWidth", 1.3, ... diff --git a/apps/electrochem/chrono_overlay/labkit_ChronoOverlay_app.m b/apps/electrochem/chrono_overlay/labkit_ChronoOverlay_app.m index 04828051e..37ca5d381 100644 --- a/apps/electrochem/chrono_overlay/labkit_ChronoOverlay_app.m +++ b/apps/electrochem/chrono_overlay/labkit_ChronoOverlay_app.m @@ -1,6 +1,6 @@ function varargout = labkit_ChronoOverlay_app(varargin) %LABKIT_CHRONOOVERLAY_APP Chrono voltage/current overlay and export app. % Thin entrypoint delegates product metadata and launch behavior to one definition. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @chrono_overlay.definition, varargin{:}); + [varargout{1:nargout}] = ... + chrono_overlay.definition().launch(varargin{:}); end diff --git a/apps/electrochem/cic/+cic/+userInterface/addBaselineYLines.m b/apps/electrochem/cic/+cic/+analysisPlot/addBaselineYLines.m similarity index 100% rename from apps/electrochem/cic/+cic/+userInterface/addBaselineYLines.m rename to apps/electrochem/cic/+cic/+analysisPlot/addBaselineYLines.m diff --git a/apps/electrochem/cic/+cic/+userInterface/addPaperStyleITAnnotations.m b/apps/electrochem/cic/+cic/+analysisPlot/addPaperStyleITAnnotations.m similarity index 98% rename from apps/electrochem/cic/+cic/+userInterface/addPaperStyleITAnnotations.m rename to apps/electrochem/cic/+cic/+analysisPlot/addPaperStyleITAnnotations.m index b8b497269..ec773908d 100644 --- a/apps/electrochem/cic/+cic/+userInterface/addPaperStyleITAnnotations.m +++ b/apps/electrochem/cic/+cic/+analysisPlot/addPaperStyleITAnnotations.m @@ -55,7 +55,7 @@ function drawDurationBracket(ax, x1, x2, y, labelText) end function x = chooseX(A, xChoice) - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); if strcmp(xChoice, choices.xAxes(2)) x = A.pt; else diff --git a/apps/electrochem/cic/+cic/+userInterface/addPaperStyleVTAnnotations.m b/apps/electrochem/cic/+cic/+analysisPlot/addPaperStyleVTAnnotations.m similarity index 94% rename from apps/electrochem/cic/+cic/+userInterface/addPaperStyleVTAnnotations.m rename to apps/electrochem/cic/+cic/+analysisPlot/addPaperStyleVTAnnotations.m index 9a977dfd5..59f55023f 100644 --- a/apps/electrochem/cic/+cic/+userInterface/addPaperStyleVTAnnotations.m +++ b/apps/electrochem/cic/+cic/+analysisPlot/addPaperStyleVTAnnotations.m @@ -12,7 +12,7 @@ function addPaperStyleVTAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodSt yMid = yl(1) + 0.55*dy; yLow = yl(1) + 0.18*dy; - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); if strcmp(xChoice, choices.xAxes(2)) cOnX = interp1Safe(A.t, A.pt, A.t_conset); aOnX = interp1Safe(A.t, A.pt, A.t_aonset); @@ -108,8 +108,10 @@ function drawExtremaLabel(ax, x, y, labelText, color, side, yOffsetFraction) alignment = 'right'; xOffset = -0.025; end - xyText = labkit.ui.plot.offsetData(ax, [x y], [xOffset yOffsetFraction]); - xyText = labkit.ui.plot.clampData(ax, xyText, "Padding", 0.05); + limitsX = xlim(ax); limitsY = ylim(ax); + xyText = [x + xOffset * diff(limitsX), y + yOffsetFraction * diff(limitsY)]; + xyText(1) = min(max(xyText(1), limitsX(1) + .05 * diff(limitsX)), limitsX(2) - .05 * diff(limitsX)); + xyText(2) = min(max(xyText(2), limitsY(1) + .05 * diff(limitsY)), limitsY(2) - .05 * diff(limitsY)); text(ax, xyText(1), xyText(2), labelText, ... 'HorizontalAlignment', alignment, ... 'VerticalAlignment', 'middle', ... diff --git a/apps/electrochem/cic/+cic/+analysisPlot/draw.m b/apps/electrochem/cic/+cic/+analysisPlot/draw.m new file mode 100644 index 000000000..39de15b6f --- /dev/null +++ b/apps/electrochem/cic/+cic/+analysisPlot/draw.m @@ -0,0 +1,86 @@ +% Expected caller: registered CIC App SDK runtime renderer. Inputs are one axes +% and a prepared app-owned axis model. Side effects are limited to replacing +% graphics on the supplied axes. +function draw(axesById, model) +renderCicAxis(axesById.top, model.top); +renderCicAxis(axesById.bottom, model.bottom); +end + +function renderCicAxis(ax, model) + labkit.app.plot.clearAxes(ax, ResetScale=true); + if ~model.valid + title(ax, model.title); + if strlength(model.message) > 0 + labkit.app.plot.showMessage( ... + ax, model.message, Title=model.title); + end + return; + end + + request = model.request; + analysis = model.analysis; + coords = request.coords; + lineHandle = plot(ax, request.x, request.y, ... + 'LineWidth', 1.25, 'Color', request.baseColor); + if isgraphics(lineHandle); axis(ax, 'tight'); end + hold(ax, 'on'); + if model.showShading + cic.analysisPlot.shadeWindow(ax, coords.cathStartX, ... + coords.cathEndX, [0.85 0.93 1.00]); + cic.analysisPlot.shadeWindow(ax, coords.anodStartX, ... + coords.anodEndX, [1.00 0.92 0.85]); + end + if strcmp(request.kind, 'VT') && model.showLimits + yline(ax, analysis.cathLimit, '--', ... + sprintf('Cath limit = %.3f V', analysis.cathLimit), ... + 'Color', [0.85 0.2 0.2], ... + 'LabelHorizontalAlignment', 'left'); + yline(ax, analysis.anodLimit, '--', ... + sprintf('Anod limit = %.3f V', analysis.anodLimit), ... + 'Color', [0.85 0.2 0.2], ... + 'LabelHorizontalAlignment', 'left'); + end + if strcmp(request.kind, 'VT') + cic.analysisPlot.addBaselineYLines(ax, analysis); + end + if model.showMarkers + addWindowMarkers(ax, coords); + addAnalysisAnnotations(ax, request, analysis, coords); + end + hold(ax, 'off'); + title(ax, request.title, 'Interpreter', 'none'); + xlabel(ax, request.xLabel); + ylabel(ax, request.yLabel); + setGrid(ax, model.showGrid); +end + +function addWindowMarkers(ax, coords) + xline(ax, coords.cathStartX, ':', 'Cath start', ... + 'Color', [0.2 0.4 0.8]); + xline(ax, coords.cathEndX, ':', 'Cath end', ... + 'Color', [0.2 0.4 0.8]); + xline(ax, coords.anodStartX, ':', 'Anod start', ... + 'Color', [0.8 0.4 0.2]); + xline(ax, coords.anodEndX, ':', 'Anod end', ... + 'Color', [0.8 0.4 0.2]); +end + +function addAnalysisAnnotations(ax, request, analysis, coords) + if strcmp(request.kind, 'VT') + cic.analysisPlot.addPaperStyleVTAnnotations(ax, analysis, ... + request.xChoice, coords.cathStartX, coords.cathEndX, ... + coords.anodStartX, coords.anodEndX, coords.emcX, coords.emaX); + else + cic.analysisPlot.addPaperStyleITAnnotations(ax, analysis, ... + request.xChoice, coords.cathStartX, coords.cathEndX, ... + coords.anodStartX, coords.anodEndX, coords.emcX, coords.emaX); + end +end + +function setGrid(ax, enabled) + if enabled + grid(ax, 'on'); + else + grid(ax, 'off'); + end +end diff --git a/apps/electrochem/cic/+cic/+userInterface/plotRequest.m b/apps/electrochem/cic/+cic/+analysisPlot/plotRequest.m similarity index 88% rename from apps/electrochem/cic/+cic/+userInterface/plotRequest.m rename to apps/electrochem/cic/+cic/+analysisPlot/plotRequest.m index 73e694319..3f75c4fab 100644 --- a/apps/electrochem/cic/+cic/+userInterface/plotRequest.m +++ b/apps/electrochem/cic/+cic/+analysisPlot/plotRequest.m @@ -4,8 +4,11 @@ function request = plotRequest(A, itemName, xChoice, yChoice) %PLOTREQUEST Prepare deterministic CIC plot payload for runner drawing. +% Constant: MATLAB default blue and orange preserve legacy series styling. +defaultBlue = [0 0.4470 0.7410]; +defaultOrange = [0.8500 0.3250 0.0980]; - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); if nargin < 2 || isempty(itemName) itemName = 'file'; end @@ -26,19 +29,19 @@ request.kind = 'VT'; request.y = A.Vf; request.yLabel = 'Vf (V vs Ref.)'; - request.baseColor = [0 0.4470 0.7410]; + request.baseColor = defaultBlue; request.title = sprintf('%s | VT | %s', itemName, safeText(A)); else request.kind = 'IT'; request.y = A.Im; request.yLabel = 'Im (A)'; - request.baseColor = [0.8500 0.3250 0.0980]; + request.baseColor = defaultOrange; request.title = sprintf('%s | IT | |I|max = %.4g A', itemName, A.ampEstimate_A); end end function [x, xLabel, coords] = xPayload(A, xChoice) - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); if strcmp(xChoice, choices.xAxes(2)) x = A.pt; xLabel = char(choices.xAxes(2)); diff --git a/apps/electrochem/cic/+cic/+userInterface/shadeWindow.m b/apps/electrochem/cic/+cic/+analysisPlot/shadeWindow.m similarity index 100% rename from apps/electrochem/cic/+cic/+userInterface/shadeWindow.m rename to apps/electrochem/cic/+cic/+analysisPlot/shadeWindow.m diff --git a/apps/electrochem/cic/+cic/+userInterface/analysisChoices.m b/apps/electrochem/cic/+cic/+analysisRun/analysisChoices.m similarity index 100% rename from apps/electrochem/cic/+cic/+userInterface/analysisChoices.m rename to apps/electrochem/cic/+cic/+analysisRun/analysisChoices.m diff --git a/apps/electrochem/cic/+cic/+analysisRun/buildBatchTableData.m b/apps/electrochem/cic/+cic/+analysisRun/buildBatchTableData.m new file mode 100644 index 000000000..3a1267e47 --- /dev/null +++ b/apps/electrochem/cic/+cic/+analysisRun/buildBatchTableData.m @@ -0,0 +1,67 @@ +% Expected caller: CIC app runner and unit tests. Inputs are item structs and a +% display unit label. Outputs are the stable UI table cell data and column names. +% No file side effects. + +function [C, columnNames] = buildBatchTableData(items, unitLabel) +%BUILDBATCHTABLEDATA Build legacy CIC batch uitable data. + + if nargin < 2 + unitLabel = 'mC/cm^2'; + end + [scale, unitLabel] = displayScale(unitLabel); + columnNames = {'File', 'Amp(A)', 'Emc(V)', 'Ema(V)', ... + ['Qc(' unitLabel ')'], ['Qa(' unitLabel ')'], ['Qtot(' unitLabel ')'], 'Safe'}; + + C = cell(numel(items), 8); + for i = 1:numel(items) + item = items(i); + C{i, 1} = itemName(item); + A = itemAnalysis(item); + if isempty(A) || ~isfield(A, 'ok') || ~A.ok + C{i, 2} = NaN; + C{i, 3} = NaN; + C{i, 4} = NaN; + C{i, 5} = NaN; + C{i, 6} = NaN; + C{i, 7} = NaN; + C{i, 8} = 'parse/analyze failed'; + continue; + end + + C{i, 2} = A.ampEstimate_A; + C{i, 3} = A.Emc; + C{i, 4} = A.Ema; + C{i, 5} = scale * A.CICc_mCcm2; + C{i, 6} = scale * A.CICa_mCcm2; + C{i, 7} = scale * A.CICt_mCcm2; + C{i, 8} = ternary(A.safe, 'safe', A.limitSide); + end +end + +function name = itemName(item) + if isfield(item, 'name') + name = item.name; + else + name = ''; + end +end + +function A = itemAnalysis(item) + if isfield(item, 'analysis') + A = item.analysis; + else + A = []; + end +end + +function [scale, unitLabel] = displayScale(unitLabel) + [scale, unitLabel] = cic.analysisRun.displayUnit(unitLabel); +end + +function txt = ternary(cond, a, b) + if cond + txt = a; + else + txt = b; + end +end diff --git a/apps/electrochem/cic/+cic/+userInterface/buildCurrentSummary.m b/apps/electrochem/cic/+cic/+analysisRun/buildCurrentSummary.m similarity index 95% rename from apps/electrochem/cic/+cic/+userInterface/buildCurrentSummary.m rename to apps/electrochem/cic/+cic/+analysisRun/buildCurrentSummary.m index 730cc0611..86da098f7 100644 --- a/apps/electrochem/cic/+cic/+userInterface/buildCurrentSummary.m +++ b/apps/electrochem/cic/+cic/+analysisRun/buildCurrentSummary.m @@ -5,7 +5,7 @@ function summary = buildCurrentSummary(items, currentIndex, modeLabel, unitLabel) %BUILDCURRENTSUMMARY Build CIC current-file summary text. - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); if nargin < 2 currentIndex = []; end @@ -114,7 +114,7 @@ end function v = selectedCICValue(A, modeLabel) - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); switch modeLabel case char(choices.cicModes(1)) v = A.CICc_mCcm2; @@ -126,7 +126,7 @@ case char(choices.cicModes(2)) end function s = shortModeName(modeLabel) - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); switch modeLabel case char(choices.cicModes(1)) s = 'CICc'; @@ -171,5 +171,5 @@ case char(choices.cicModes(2)) end function [scale, unitLabel] = displayScale(unitLabel) - [scale, unitLabel] = cic.userInterface.displayUnit(unitLabel); + [scale, unitLabel] = cic.analysisRun.displayUnit(unitLabel); end diff --git a/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m b/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m index 18c1e8cdf..50247afc0 100644 --- a/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m +++ b/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m @@ -263,7 +263,7 @@ opts.area_cm2 = NaN; end if ~isfield(opts, 'pulseMode') - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); opts.pulseMode = char(choices.pulseModes(1)); end if ~isfield(opts, 'usedMeasuredCurrent') diff --git a/apps/electrochem/cic/+cic/+userInterface/displayUnit.m b/apps/electrochem/cic/+cic/+analysisRun/displayUnit.m similarity index 100% rename from apps/electrochem/cic/+cic/+userInterface/displayUnit.m rename to apps/electrochem/cic/+cic/+analysisRun/displayUnit.m diff --git a/apps/electrochem/cic/+cic/+userInterface/formatChargeDensity.m b/apps/electrochem/cic/+cic/+analysisRun/formatChargeDensity.m similarity index 88% rename from apps/electrochem/cic/+cic/+userInterface/formatChargeDensity.m rename to apps/electrochem/cic/+cic/+analysisRun/formatChargeDensity.m index 752b12265..ce79706f7 100644 --- a/apps/electrochem/cic/+cic/+userInterface/formatChargeDensity.m +++ b/apps/electrochem/cic/+cic/+analysisRun/formatChargeDensity.m @@ -12,5 +12,5 @@ end function [scale, unitLabel] = displayScale(unitLabel) - [scale, unitLabel] = cic.userInterface.displayUnit(unitLabel); + [scale, unitLabel] = cic.analysisRun.displayUnit(unitLabel); end diff --git a/apps/electrochem/cic/+cic/+analysisRun/presetChanged.m b/apps/electrochem/cic/+cic/+analysisRun/presetChanged.m new file mode 100644 index 000000000..cd78c0efd --- /dev/null +++ b/apps/electrochem/cic/+cic/+analysisRun/presetChanged.m @@ -0,0 +1,24 @@ +% App-owned implementation for cic.analysisRun.presetChanged within the cic product workflow. +function applicationState = presetChanged( ... + applicationState, ~, callbackContext) +%PRESETCHANGED Apply the selected literature window preset and recompute. +choices = cic.analysisRun.analysisChoices(); +preset = string(applicationState.project.parameters.preset); +if preset == choices.presets(1) + applicationState.project.parameters.cathLimit = -0.6; + applicationState.project.parameters.anodLimit = 0.8; +elseif preset == choices.presets(2) + applicationState.project.parameters.cathLimit = -0.9; + applicationState.project.parameters.anodLimit = 0.6; +end +items = applicationState.session.cache.items; +if ~isempty(items) + applicationState.session.cache.items = ... + cic.analysisRun.recomputeLoaded( ... + items, applicationState.project.parameters); + callbackContext.appendStatus(sprintf( ... + "Reanalyzed %d loaded CIC file(s) for the selected preset.", ... + numel(items))); +end +applicationState.project.results.lastExport = []; +end diff --git a/apps/electrochem/cic/+cic/+analysisRun/recomputeLoaded.m b/apps/electrochem/cic/+cic/+analysisRun/recomputeLoaded.m new file mode 100644 index 000000000..c8e426112 --- /dev/null +++ b/apps/electrochem/cic/+cic/+analysisRun/recomputeLoaded.m @@ -0,0 +1,6 @@ +% App-owned implementation for cic.analysisRun.recomputeLoaded within the cic product workflow. +function items = recomputeLoaded(items, parameters) +%RECOMPUTELOADED Reanalyze explicit loaded CIC items with shared parameters. +options = cic.analysisRun.optionsFromParameters(parameters); +items = cic.analysisRun.recomputeItems(items, options); +end diff --git a/apps/electrochem/cic/+cic/+analysisRun/settingsChanged.m b/apps/electrochem/cic/+cic/+analysisRun/settingsChanged.m new file mode 100644 index 000000000..7434f47be --- /dev/null +++ b/apps/electrochem/cic/+cic/+analysisRun/settingsChanged.m @@ -0,0 +1,16 @@ +% App-owned implementation for cic.analysisRun.settingsChanged within the cic product workflow. +function applicationState = settingsChanged( ... + applicationState, ~, callbackContext) +%SETTINGSCHANGED Recompute every loaded CIC item with current parameters. +items = applicationState.session.cache.items; +if isempty(items) + applicationState.project.results.lastExport = []; + return +end +applicationState.session.cache.items = ... + cic.analysisRun.recomputeLoaded( ... + items, applicationState.project.parameters); +applicationState.project.results.lastExport = []; +callbackContext.appendStatus(sprintf( ... + "Reanalyzed %d loaded CIC file(s).", numel(items))); +end diff --git a/apps/electrochem/cic/+cic/+debug/writeSamplePack.m b/apps/electrochem/cic/+cic/+debug/writeSamplePack.m index fd87d2f9b..20fd36337 100644 --- a/apps/electrochem/cic/+cic/+debug/writeSamplePack.m +++ b/apps/electrochem/cic/+cic/+debug/writeSamplePack.m @@ -1,37 +1,30 @@ -% Expected caller: Runtime V2 DebugSample lifecycle and unit tests. Input is a +% Expected caller: App SDK runtime DebugSample lifecycle and unit tests. Input is a % LabKit debug context. Output is a deterministic synthetic chrono DTA sample % pack. Side effects: writes anonymous debug input files and records a session % manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write CIC debug chrono DTA files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "cic"); - dtaFolder = fullfile(char(folders.sampleFolder), "dta"); - ensureFolder(dtaFolder); - - currentPath = string(fullfile(dtaFolder, "chrono_cic_current_debug.DTA")); - weakPath = string(fullfile(dtaFolder, "chrono_cic_valid_weak_response_debug.DTA")); - malformedPath = string(fullfile(dtaFolder, "chrono_cic_malformed_missing_columns_debug.DTA")); + currentPath = sampleContext.samplePath("cic/representative.DTA"); + weakPath = sampleContext.samplePath("cic/weak_response.DTA"); + malformedPath = sampleContext.samplePath("cic/malformed.DTA"); writeTextFile(currentPath, chronoText()); writeTextFile(weakPath, weakChronoText()); writeTextFile(malformedPath, malformedChronoText()); - pack = struct( ... - "sampleFolder", folders.sampleFolder, ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", currentPath, ... - "boundaryFiles", struct("validEdge", weakPath, "malformed", malformedPath)); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_CIC_app", ... - "description", "Anonymous current-controlled chrono DTA boundary pack for CIC debug launch.", ... - "inputs", struct( ... - "representativeChronoDta", currentPath, ... - "validEdgeChronoDta", weakPath, ... - "malformedChronoDta", malformedPath), ... - "outputFolder", folders.outputFolder); - pack.manifest = manifest; - recordManifest(debugLog, manifest); + project = cic.projectSpec().Create(); + project.inputs.sources = sampleContext.sourceRecord( ... + "dta1", "chrono", currentPath, true); + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-chrono", InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact("representative", "chrono", currentPath), ... + sampleContext.artifact("weakResponse", "boundaryInput", weakPath), ... + sampleContext.artifact("malformed", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function text = weakChronoText() @@ -117,28 +110,6 @@ end end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 @@ -148,12 +119,6 @@ function writeTextFile(filepath, text) fprintf(fid, "%s", char(text)); end -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end - function value = tab() value = char(9); end diff --git a/apps/electrochem/cic/+cic/+resultFiles/buildResultsTable.m b/apps/electrochem/cic/+cic/+resultFiles/buildResultsTable.m index 8fc79c378..86392b864 100644 --- a/apps/electrochem/cic/+cic/+resultFiles/buildResultsTable.m +++ b/apps/electrochem/cic/+cic/+resultFiles/buildResultsTable.m @@ -64,7 +64,7 @@ end function [scale, unitLabel] = displayScale(unitLabel) - [scale, unitLabel] = cic.userInterface.displayUnit(unitLabel); + [scale, unitLabel] = cic.analysisRun.displayUnit(unitLabel); end function name = itemName(item) diff --git a/apps/electrochem/cic/+cic/+resultFiles/exportResults.m b/apps/electrochem/cic/+cic/+resultFiles/exportResults.m new file mode 100644 index 000000000..a38ed50b5 --- /dev/null +++ b/apps/electrochem/cic/+cic/+resultFiles/exportResults.m @@ -0,0 +1,41 @@ +% App-owned implementation for cic.resultFiles.exportResults within the cic product workflow. +function applicationState = exportResults( ... + applicationState, callbackContext) +%EXPORTRESULTS Write the loaded CIC batch and its provenance manifest. +items = applicationState.session.cache.items; +if isempty(items) + callbackContext.alert("No CIC results to export.", "Export"); + return +end + +choice = callbackContext.chooseOutputFile( ... + ["*.csv", "CSV files"], "cic_results.csv"); +if choice.Cancelled + callbackContext.appendStatus("CIC result export cancelled."); + return +end +filepath = string(choice.Value); +[~, unitLabel] = cic.analysisRun.displayUnit( ... + applicationState.project.parameters.cicUnit); +[ok, message] = cic.resultFiles.writeResultsCSV( ... + items, filepath, unitLabel); +if ~ok + callbackContext.alert(message, "Export"); + return +end + +[folder, name, extension] = fileparts(filepath); +output = labkit.app.result.File( ... + "cicResults", "primary", string(name) + string(extension), ... + MediaType="text/csv"); +package = labkit.app.result.Package( ... + Outputs={output}, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct("fileCount", numel(items)), ... + ManifestName="cic_results.labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.lastExport = struct( ... + "csvPath", filepath, "manifestPath", string(written.Value)); +callbackContext.appendStatus("Exported CIC CSV: " + filepath); +end diff --git a/apps/electrochem/cic/+cic/+sourceFiles/loadProjectItems.m b/apps/electrochem/cic/+cic/+sourceFiles/loadProjectItems.m index c1f5e0285..b6f822a0b 100644 --- a/apps/electrochem/cic/+cic/+sourceFiles/loadProjectItems.m +++ b/apps/electrochem/cic/+cic/+sourceFiles/loadProjectItems.m @@ -4,7 +4,7 @@ function items = loadProjectItems(sources, parameters) items = struct([]); opts = cic.analysisRun.optionsFromParameters(parameters); - paths = labkit.ui.runtime.sourcePaths(sources); + paths = string(sources); for k = 1:numel(sources) filepath = paths(k); [item, status] = labkit.dta.loadFile(filepath, "chrono"); diff --git a/apps/electrochem/cic/+cic/+userInterface/buildBatchTableData.m b/apps/electrochem/cic/+cic/+userInterface/buildBatchTableData.m deleted file mode 100644 index b777fcec4..000000000 --- a/apps/electrochem/cic/+cic/+userInterface/buildBatchTableData.m +++ /dev/null @@ -1,67 +0,0 @@ -% Expected caller: CIC app runner and unit tests. Inputs are item structs and a -% display unit label. Outputs are the stable UI table cell data and column names. -% No file side effects. - -function [C, columnNames] = buildBatchTableData(items, unitLabel) -%BUILDBATCHTABLEDATA Build legacy CIC batch uitable data. - - if nargin < 2 - unitLabel = 'mC/cm^2'; - end - [scale, unitLabel] = displayScale(unitLabel); - columnNames = {'File', 'Amp(A)', 'Emc(V)', 'Ema(V)', ... - ['Qc(' unitLabel ')'], ['Qa(' unitLabel ')'], ['Qtot(' unitLabel ')'], 'Safe'}; - - C = cell(numel(items), 8); - for i = 1:numel(items) - item = items(i); - C{i, 1} = itemName(item); - A = itemAnalysis(item); - if isempty(A) || ~isfield(A, 'ok') || ~A.ok - C{i, 2} = NaN; - C{i, 3} = NaN; - C{i, 4} = NaN; - C{i, 5} = NaN; - C{i, 6} = NaN; - C{i, 7} = NaN; - C{i, 8} = 'parse/analyze failed'; - continue; - end - - C{i, 2} = A.ampEstimate_A; - C{i, 3} = A.Emc; - C{i, 4} = A.Ema; - C{i, 5} = scale * A.CICc_mCcm2; - C{i, 6} = scale * A.CICa_mCcm2; - C{i, 7} = scale * A.CICt_mCcm2; - C{i, 8} = ternary(A.safe, 'safe', A.limitSide); - end -end - -function name = itemName(item) - if isfield(item, 'name') - name = item.name; - else - name = ''; - end -end - -function A = itemAnalysis(item) - if isfield(item, 'analysis') - A = item.analysis; - else - A = []; - end -end - -function [scale, unitLabel] = displayScale(unitLabel) - [scale, unitLabel] = cic.userInterface.displayUnit(unitLabel); -end - -function txt = ternary(cond, a, b) - if cond - txt = a; - else - txt = b; - end -end diff --git a/apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m b/apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 4fc1b0900..000000000 --- a/apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,204 +0,0 @@ -% Expected caller: cic.definition. Input is a callback struct whose fields are -% app-owned callback handles. Output is a data-only UI 5 workbench layout for -% the CIC app. -function layout = buildWorkbenchLayout(callbacks) - - choices = cic.userInterface.analysisChoices(); - presetItems = cellstr(choices.presets); - pulseModes = cellstr(choices.pulseModes); - cicModes = cellstr(choices.cicModes); - cicUnits = cellstr(choices.cicUnits); - xChoices = cellstr(choices.xAxes); - yChoices = cellstr(choices.yAxes); - - layout = labkit.ui.layout.workbench("cicApp", ... - "Gamry CIC GUI (Voltage Transient)", ... - "controlTabs", controlTabs(presetItems, pulseModes, cicModes, ... - cicUnits, xChoices, yChoices, callbacks), ... - "workspace", plotsWorkspace()); -end - -function tabs = controlTabs(presetItems, pulseModes, cicModes, cicUnits, ... - xChoices, yChoices, callbacks) - tabs = { ... - filesAnalysisTab(presetItems, pulseModes, cicModes, cicUnits, ... - xChoices, yChoices, callbacks), ... - summaryResultsTab(), ... - logTab()}; -end - -function tab = filesAnalysisTab(presetItems, pulseModes, cicModes, cicUnits, ... - xChoices, yChoices, callbacks) - tab = labkit.ui.layout.tab("filesAnalysis", "Files + Analysis", { ... - filesSection(callbacks), ... - analysisSettingsSection(presetItems, pulseModes, cicModes, cicUnits), ... - plotDisplaySection(), ... - plotSelectionsSection(xChoices, yChoices)}); -end - -function tab = summaryResultsTab() - tab = labkit.ui.layout.tab("summaryResults", "Summary + Results", { ... - currentSummarySection(), ... - batchResultsSection()}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log")})}); -end - -function section = filesSection(callbacks) - section = labkit.ui.layout.section("filesSection", "Files", { ... - labkit.ui.layout.filePanel("files", "Files", ... - "selectionMode", "single", ... - "chooseLabel", "Add DTA files", ... - "clearLabel", "Clear all", ... - "filters", {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... - "status", "No files loaded", ... - "emptyText", "No files loaded", ... - "onChoose", callbacks.openFilesChosen, ... - "onRemove", callbacks.removeSelected, ... - "onClear", callbacks.clearAll, ... - "onSelectionChange", callbacks.fileSelectionChanged), ... - labkit.ui.layout.group("fileActions", "", { ... - labkit.ui.layout.action("exportResults", ... - "Export results CSV", callbacks.exportResults)})}); -end - -function section = analysisSettingsSection(presetItems, pulseModes, ... - cicModes, cicUnits) - section = labkit.ui.layout.section("analysisSettings", "Analysis Settings", { ... - labkit.ui.layout.field("preset", "Window preset:", ... - "kind", "dropdown", ... - "items", presetItems, ... - "value", presetItems{1}, ... - "Bind", "project.parameters.preset", ... - "Event", "presetChanged"), ... - analysisPanner("cathLimit", "Cathodic limit (V):", -0.6, ... - [-10 10], 0.01), ... - analysisPanner("anodLimit", "Anodic limit (V):", 0.8, ... - [-10 10], 0.01), ... - analysisPanner("delayUs", "Delay after pulse end (us):", 10, ... - [0 10000000], 1), ... - labkit.ui.layout.field("area", "Area override (cm^2):", ... - "kind", "text", ... - "value", "", ... - "Bind", "project.parameters.areaOverride", ... - "Event", "analysisChanged", ... - "debounceMs", 0), ... - labkit.ui.layout.field("pulseMode", "Pulse detection:", ... - "kind", "dropdown", ... - "items", pulseModes, ... - "value", pulseModes{1}, ... - "Bind", "project.parameters.pulseMode", ... - "Event", "analysisChanged", ... - "debounceMs", 0), ... - labkit.ui.layout.field("cicMode", "CIC summary mode:", ... - "kind", "dropdown", ... - "items", cicModes, ... - "value", cicModes{3}, ... - "Bind", "project.parameters.cicMode"), ... - labkit.ui.layout.field("cicUnit", "CIC unit:", ... - "kind", "dropdown", ... - "items", cicUnits, ... - "value", cicUnits{1}, ... - "Bind", "project.parameters.cicUnit"), ... - labkit.ui.layout.field("useMeasuredCurrent", ... - "Use measured Im integration for charge (recommended)", ... - "kind", "checkbox", ... - "value", true, ... - "Bind", "project.parameters.useMeasuredCurrent", ... - "Event", "analysisChanged", ... - "debounceMs", 0)}); -end - -function section = plotDisplaySection() - section = labkit.ui.layout.section("plotDisplay", "Plot Display", { ... - labkit.ui.layout.field("showMarkers", "Show debug markers", ... - "kind", "checkbox", ... - "value", true, ... - "Bind", "project.parameters.showMarkers"), ... - labkit.ui.layout.field("showLimits", "Show window limits", ... - "kind", "checkbox", ... - "value", true, ... - "Bind", "project.parameters.showLimits"), ... - labkit.ui.layout.field("showShading", "Shade pulse windows", ... - "kind", "checkbox", ... - "value", true, ... - "Bind", "project.parameters.showShading")}); -end - -function section = plotSelectionsSection(xChoices, yChoices) - section = labkit.ui.layout.section("plotSelections", "Plot Selections", { ... - axisChoice("topX", "Top X:", xChoices, xChoices{1}), ... - axisChoice("topY", "Top Y:", yChoices, yChoices{1}), ... - gridChoice("topGrid"), ... - axisChoice("bottomX", "Bottom X:", xChoices, xChoices{1}), ... - axisChoice("bottomY", "Bottom Y:", yChoices, yChoices{2}), ... - gridChoice("bottomGrid")}); -end - -function section = currentSummarySection() - section = labkit.ui.layout.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:")}); -end - -function section = batchResultsSection() - section = labkit.ui.layout.section("batchResults", "Batch Results", { ... - labkit.ui.layout.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))}); -end - -function workspace = plotsWorkspace() - workspace = labkit.ui.layout.workspace("plots", "Plots", { ... - labkit.ui.layout.previewArea("plotAxes", "Plots", ... - "layout", "stack", ... - "count", 2, ... - "axisIds", {'top', 'bottom'}, ... - "axisTitles", {'Top Plot', 'Bottom Plot'})}); -end - -function layoutNode = analysisPanner(id, labelText, value, limits, step) - layoutNode = labkit.ui.layout.panner(id, labelText, ... - "value", value, ... - "limits", limits, ... - "step", step, ... - "valueDisplayFormat", "%.6g", ... - "Bind", "project.parameters." + id, ... - "Event", "analysisChanged"); -end - -function layoutNode = axisChoice(id, labelText, items, value) - layoutNode = labkit.ui.layout.field(id, labelText, ... - "kind", "dropdown", ... - "items", items, ... - "value", value, ... - "Bind", "project.parameters." + id); -end - -function layoutNode = gridChoice(id) - layoutNode = labkit.ui.layout.field(id, "Grid", ... - "kind", "checkbox", ... - "value", true, ... - "Bind", "project.parameters." + id); -end - -function layoutNode = summaryField(id, labelText) - layoutNode = labkit.ui.layout.field(id, labelText, ... - "kind", "readonly", ... - "value", "-"); -end diff --git a/apps/electrochem/cic/+cic/+userInterface/presentWorkbench.m b/apps/electrochem/cic/+cic/+userInterface/presentWorkbench.m deleted file mode 100644 index ba3a8dc58..000000000 --- a/apps/electrochem/cic/+cic/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,155 +0,0 @@ -% Expected caller: Runtime V2. Input is canonical CIC state. Output is one -% deterministic control/table/summary/two-axis presentation with no UI -% registry access or side effects. -function view = presentWorkbench(state) - items = state.session.cache.items; - sources = state.project.inputs.sources; - parameters = state.project.parameters; - index = boundedIndex(state.session.selection.currentIndex, numel(sources)); - itemIndex = loadedItemIndex(items, sources, index); - [~, unitLabel] = cic.userInterface.displayUnit(parameters.cicUnit); - [tableData, columns] = cic.userInterface.buildBatchTableData( ... - items, unitLabel); - summary = cic.userInterface.buildCurrentSummary( ... - items, itemIndex, parameters.cicMode, parameters.cicUnit); - - view = struct(); - view.controls.files = filePanelSpec(sources, items, index); - view.controls.results = struct(); - view.controls.results.Data = tableData; - view.controls.results.ColumnName = columns; - view = applySummary(view, summary); - view.previews.plotAxes.Axes.top = struct( ... - "Renderer", "cicAxis", ... - "Model", axisModel(items, itemIndex, parameters, "top")); - view.previews.plotAxes.Axes.bottom = struct( ... - "Renderer", "cicAxis", ... - "Model", axisModel(items, itemIndex, parameters, "bottom")); -end - -function spec = filePanelSpec(sources, items, index) - files = struct("id", {}, "path", {}, "status", {}); - paths = labkit.ui.runtime.sourcePaths(sources); - for k = 1:numel(paths) - files(end + 1) = struct( ... - "id", "item" + string(k), ... - "path", paths(k), ... - "status", pathStatus(items, paths(k))); - end - selection = strings(0, 1); - if index > 0 - selection = "item" + string(index); - end - spec = struct( ... - "Files", files, ... - "Selection", selection, ... - "Status", fileStatus(numel(paths))); -end - -function value = pathStatus(items, filepath) - itemIndex = find(string({items.filepath}) == filepath, 1); - if isempty(itemIndex) - value = "deferred"; - else - value = analysisStatus(items(itemIndex)); - end -end - -function value = analysisStatus(item) - value = ""; - if isfield(item, 'analysis') && isstruct(item.analysis) && ... - isfield(item.analysis, 'ok') && ~item.analysis.ok - value = "analysis failed"; - end -end - -function value = fileStatus(count) - if count == 0 - value = "No files loaded"; - else - value = string(count) + " file(s) registered"; - end -end - -function view = applySummary(view, summary) - fields = ["controlMode", "detect", "delay", "areaSummary", ... - "emc", "ema", "qc", "qa", "qt", "safe", "best"]; - values = {summary.controlMode, summary.detect, summary.delay, ... - summary.area, summary.emc, summary.ema, summary.qc, summary.qa, ... - summary.qt, summary.safe, summary.bestSafe}; - for k = 1:numel(fields) - view.controls.(fields(k)) = struct("Value", values{k}); - end -end - -function model = axisModel(items, index, parameters, whichAxis) - model = struct( ... - "valid", false, ... - "message", "", ... - "title", axisTitle(whichAxis), ... - "request", struct(), ... - "analysis", struct(), ... - "showMarkers", logical(parameters.showMarkers), ... - "showLimits", logical(parameters.showLimits), ... - "showShading", logical(parameters.showShading), ... - "showGrid", axisGrid(parameters, whichAxis)); - if index == 0 - return; - end - item = items(index); - if isempty(item.analysis) || ~item.analysis.ok - model.message = "No valid analysis"; - return; - end - [xChoice, yChoice] = axisChoices(parameters, whichAxis); - model.valid = true; - model.request = cic.userInterface.plotRequest( ... - item.analysis, item.name, xChoice, yChoice); - model.analysis = item.analysis; -end - -function [xChoice, yChoice] = axisChoices(parameters, whichAxis) - if whichAxis == "top" - xChoice = parameters.topX; - yChoice = parameters.topY; - else - xChoice = parameters.bottomX; - yChoice = parameters.bottomY; - end -end - -function value = axisGrid(parameters, whichAxis) - if whichAxis == "top" - value = logical(parameters.topGrid); - else - value = logical(parameters.bottomGrid); - end -end - -function value = axisTitle(whichAxis) - if whichAxis == "top" - value = "Top Plot"; - else - value = "Bottom Plot"; - end -end - -function index = boundedIndex(index, count) - if count == 0 - index = 0; - else - index = min(max(1, round(double(index))), count); - end -end - -function itemIndex = loadedItemIndex(items, sources, sourceIndex) - itemIndex = 0; - if sourceIndex == 0 || isempty(items) - return; - end - paths = labkit.ui.runtime.sourcePaths(sources); - match = find(string({items.filepath}) == paths(sourceIndex), 1); - if ~isempty(match) - itemIndex = match; - end -end diff --git a/apps/electrochem/cic/+cic/+userInterface/renderCicAxis.m b/apps/electrochem/cic/+cic/+userInterface/renderCicAxis.m deleted file mode 100644 index c0c4491b9..000000000 --- a/apps/electrochem/cic/+cic/+userInterface/renderCicAxis.m +++ /dev/null @@ -1,81 +0,0 @@ -% Expected caller: registered CIC Runtime V2 renderer. Inputs are one axes -% and a prepared app-owned axis model. Side effects are limited to replacing -% graphics on the supplied axes. -function renderCicAxis(ax, model) - labkit.ui.plot.clear(ax, "ResetScale", true); - if ~model.valid - title(ax, model.title); - if strlength(model.message) > 0 - text(ax, 0.5, 0.5, model.message, ... - 'Units', 'normalized', 'HorizontalAlignment', 'center'); - end - return; - end - - request = model.request; - analysis = model.analysis; - coords = request.coords; - lineHandle = plot(ax, request.x, request.y, ... - 'LineWidth', 1.25, 'Color', request.baseColor); - labkit.ui.plot.fit(ax, lineHandle); - hold(ax, 'on'); - if model.showShading - cic.userInterface.shadeWindow(ax, coords.cathStartX, ... - coords.cathEndX, [0.85 0.93 1.00]); - cic.userInterface.shadeWindow(ax, coords.anodStartX, ... - coords.anodEndX, [1.00 0.92 0.85]); - end - if strcmp(request.kind, 'VT') && model.showLimits - yline(ax, analysis.cathLimit, '--', ... - sprintf('Cath limit = %.3f V', analysis.cathLimit), ... - 'Color', [0.85 0.2 0.2], ... - 'LabelHorizontalAlignment', 'left'); - yline(ax, analysis.anodLimit, '--', ... - sprintf('Anod limit = %.3f V', analysis.anodLimit), ... - 'Color', [0.85 0.2 0.2], ... - 'LabelHorizontalAlignment', 'left'); - end - if strcmp(request.kind, 'VT') - cic.userInterface.addBaselineYLines(ax, analysis); - end - if model.showMarkers - addWindowMarkers(ax, coords); - addAnalysisAnnotations(ax, request, analysis, coords); - end - hold(ax, 'off'); - title(ax, request.title, 'Interpreter', 'none'); - xlabel(ax, request.xLabel); - ylabel(ax, request.yLabel); - setGrid(ax, model.showGrid); -end - -function addWindowMarkers(ax, coords) - xline(ax, coords.cathStartX, ':', 'Cath start', ... - 'Color', [0.2 0.4 0.8]); - xline(ax, coords.cathEndX, ':', 'Cath end', ... - 'Color', [0.2 0.4 0.8]); - xline(ax, coords.anodStartX, ':', 'Anod start', ... - 'Color', [0.8 0.4 0.2]); - xline(ax, coords.anodEndX, ':', 'Anod end', ... - 'Color', [0.8 0.4 0.2]); -end - -function addAnalysisAnnotations(ax, request, analysis, coords) - if strcmp(request.kind, 'VT') - cic.userInterface.addPaperStyleVTAnnotations(ax, analysis, ... - request.xChoice, coords.cathStartX, coords.cathEndX, ... - coords.anodStartX, coords.anodEndX, coords.emcX, coords.emaX); - else - cic.userInterface.addPaperStyleITAnnotations(ax, analysis, ... - request.xChoice, coords.cathStartX, coords.cathEndX, ... - coords.anodStartX, coords.anodEndX, coords.emcX, coords.emaX); - end -end - -function setGrid(ax, enabled) - if enabled - grid(ax, 'on'); - else - grid(ax, 'off'); - end -end diff --git a/apps/electrochem/cic/+cic/+workbench/buildLayout.m b/apps/electrochem/cic/+cic/+workbench/buildLayout.m new file mode 100644 index 000000000..4b59c378b --- /dev/null +++ b/apps/electrochem/cic/+cic/+workbench/buildLayout.m @@ -0,0 +1,122 @@ +% App-owned implementation for cic.workbench.buildLayout within the cic product workflow. +function layout = buildLayout() +%BUILDLAYOUT Compose the complete CIC file, analysis, plot, and export flow. +choices = cic.analysisRun.analysisChoices(); + +files = labkit.app.layout.fileList("files", ... + Label="Files", ChooseLabel="Add DTA files", ... + ChooseTooltip="Add Gamry chrono DTA files containing stimulation pulses for charge-injection-capacity analysis.", ... + FolderLabel="Add folder", ... + RecursiveFolderLabel="Add folder tree", ... + RemoveLabel="Remove selected", ClearLabel="Clear all", ... + EmptyText="No files loaded", ... + Filters=["*.DTA;*.dta", "Gamry DTA (*.DTA)"], ... + SelectionMode="single", ... + Bind="project.inputs.sources", ... + SelectionBind="session.selection.files", ... + SourceRole="chrono", SourceIdPrefix="dta"); + +analysisSettings = labkit.app.layout.section( ... + "analysisSettings", "Analysis Settings", { ... + choiceField("preset", "Window preset:", choices.presets, ... + @cic.analysisRun.presetChanged), ... + numericField("cathLimit", "Cathodic limit (V):", [-10 10], 0.01), ... + numericField("anodLimit", "Anodic limit (V):", [-10 10], 0.01), ... + numericField("delayUs", "Delay after pulse end (us):", ... + [0 10000000], 1), ... + labkit.app.layout.field("areaOverride", ... + Label="Area override (cm^2):", Kind="text", ... + Bind="project.parameters.areaOverride", ... + OnValueChanged=@cic.analysisRun.settingsChanged), ... + choiceField("pulseMode", "Pulse detection:", choices.pulseModes, ... + @cic.analysisRun.settingsChanged), ... + choiceField("cicMode", "CIC summary mode:", choices.cicModes, []), ... + choiceField("cicUnit", "CIC unit:", choices.cicUnits, []), ... + logicalField("useMeasuredCurrent", ... + "Use measured Im integration for charge (recommended)", ... + @cic.analysisRun.settingsChanged)}); + +plotDisplay = labkit.app.layout.section( ... + "plotDisplay", "Plot Display", { ... + logicalField("showMarkers", "Show debug markers", []), ... + logicalField("showLimits", "Show window limits", []), ... + logicalField("showShading", "Shade pulse windows", [])}); +plotSelections = labkit.app.layout.section( ... + "plotSelections", "Plot Selections", { ... + choiceField("topX", "Top X:", choices.xAxes, []), ... + choiceField("topY", "Top Y:", choices.yAxes, []), ... + logicalField("topGrid", "Grid", []), ... + choiceField("bottomX", "Bottom X:", choices.xAxes, []), ... + choiceField("bottomY", "Bottom Y:", choices.yAxes, []), ... + logicalField("bottomGrid", "Grid", [])}); + +summary = labkit.app.layout.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:")}); +results = labkit.app.layout.section( ... + "batchResults", "Batch Results", { ... + labkit.app.layout.dataTable("results")}); + +controls = { ... + labkit.app.layout.tab("filesAnalysis", "Files + Analysis", { ... + labkit.app.layout.section("filesSection", "Files", { ... + files, labkit.app.layout.button("exportResults", ... + "Export results CSV", @cic.resultFiles.exportResults, ... + Tooltip="Export cathodic, anodic, and total injected charge or CIC together with pulse-window and safety results.")}), ... + analysisSettings, plotDisplay, plotSelections}), ... + labkit.app.layout.tab("summaryResults", "Summary + Results", { ... + summary, results}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("appLog", Title="Log")})})}; +plots = labkit.app.layout.plotArea("plotAxes", ... + @cic.analysisPlot.draw, Title="Plots", Layout="stack", ... + AxisIds=["top", "bottom"], ... + AxisTitles=["Top Plot", "Bottom Plot"]); +layout = labkit.app.layout.workbench(controls, ... + Workspace=labkit.app.layout.workspace(plots, Title="Plots")); +end + +function node = numericField(id, label, limits, step) +node = labkit.app.layout.slider(id, Label=label, Limits=limits, Step=step, ... + ValueDisplayFormat="%.6g", ... + Bind="project.parameters." + id, ... + OnValueChanged=@cic.analysisRun.settingsChanged); +end + +function node = choiceField(id, label, choices, callback) +arguments + id + label + choices + callback = [] +end +node = labkit.app.layout.field(id, Label=label, Kind="choice", ... + Choices=choices, Bind="project.parameters." + id, ... + OnValueChanged=callback); +end + +function node = logicalField(id, label, callback) +arguments + id + label + callback = [] +end +node = labkit.app.layout.field(id, Label=label, Kind="logical", ... + Bind="project.parameters." + id, OnValueChanged=callback); +end + +function node = summaryField(id, label) +node = labkit.app.layout.field(id, Label=label, Kind="readonly", ... + Value="-"); +end diff --git a/apps/electrochem/cic/+cic/+workbench/present.m b/apps/electrochem/cic/+cic/+workbench/present.m new file mode 100644 index 000000000..a82aa3f4b --- /dev/null +++ b/apps/electrochem/cic/+cic/+workbench/present.m @@ -0,0 +1,69 @@ +% App-owned implementation for cic.workbench.present within the cic product workflow. +function view = present(applicationState) +%PRESENT Adapt CIC runtime state into summary, table, and named plot models. +items = applicationState.session.cache.items; +parameters = applicationState.project.parameters; +selection = applicationState.session.selection.files; +index = selectedIndex(selection, numel(items)); + +[~, unitLabel] = cic.analysisRun.displayUnit(parameters.cicUnit); +[tableData, columns] = ... + cic.analysisRun.buildBatchTableData(items, unitLabel); +summary = cic.analysisRun.buildCurrentSummary( ... + items, index, parameters.cicMode, parameters.cicUnit); +plotModel = struct( ... + "top", axisModel(items, index, parameters, "top"), ... + "bottom", axisModel(items, index, parameters, "bottom")); + +view = labkit.app.view.Snapshot() ... + .tableData("results", tableData, Columns=string(columns)) ... + .renderPlot("plotAxes", plotModel); +ids = ["controlMode", "detect", "delay", "areaSummary", ... + "emc", "ema", "qc", "qa", "qt", "safe", "best"]; +values = {summary.controlMode, summary.detect, summary.delay, summary.area, ... + summary.emc, summary.ema, summary.qc, summary.qa, summary.qt, ... + summary.safe, summary.bestSafe}; +for k = 1:numel(ids) + view = view.text(ids(k), string(values{k})); +end +end + +function model = axisModel(items, index, parameters, axisId) +model = struct( ... + "valid", false, "message", "", ... + "title", upperFirst(axisId) + " Plot", ... + "request", struct(), "analysis", struct(), ... + "showMarkers", logical(parameters.showMarkers), ... + "showLimits", logical(parameters.showLimits), ... + "showShading", logical(parameters.showShading), ... + "showGrid", logical(parameters.(axisId + "Grid"))); +if index == 0 + return +end +analysis = items(index).analysis; +if isempty(analysis) || ~analysis.ok + model.message = "No valid analysis"; + return +end +model.valid = true; +model.analysis = analysis; +model.request = cic.analysisPlot.plotRequest( ... + analysis, items(index).name, ... + parameters.(axisId + "X"), parameters.(axisId + "Y")); +end + +function index = selectedIndex(selection, count) +index = 0; +if count > 0 + index = 1; + if ~isempty(selection.Indices) + index = min(max(1, selection.Indices(1)), count); + end +end +end + +function text = upperFirst(value) +text = char(value); +text(1) = upper(text(1)); +text = string(text); +end diff --git a/apps/electrochem/cic/+cic/createSession.m b/apps/electrochem/cic/+cic/createSession.m index bc14cfd2c..d48c3c139 100644 --- a/apps/electrochem/cic/+cic/createSession.m +++ b/apps/electrochem/cic/+cic/createSession.m @@ -1,21 +1,13 @@ -%CREATESESSION Rebuild the selected CIC preview item lazily. -% Expected caller: Runtime V2 through cic.definition. Only the first source is -% decoded for immediate preview; the full batch remains deferred to export. -function session = createSession(project) - items = struct([]); - currentIndex = 0; - if ~isempty(project.inputs.sources) - currentIndex = 1; - filepath = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources(1)); - [item, status] = cic.sourceFiles.loadItem(filepath, project.parameters); - if ~status.ok - error('cic:SourceLoadFailed', 'Could not load %s: %s', ... - filepath, status.message); - end - items = item; - end - session = struct( ... - "selection", struct("currentIndex", currentIndex), ... - "cache", struct("items", items)); +% App-owned implementation for cic.createSession within the cic product workflow. +function session = createSession(project, context) +%CREATESESSION Rebuild CIC's lazy selected preview from portable sources. +arguments + project (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end +paths = context.resolveSourcePaths(project.inputs.sources); +items = cic.sourceFiles.loadProjectItems(paths, project.parameters); +selection = labkit.app.event.ListSelection(Indices=1:min(1, numel(items))); +session = struct("selection", struct("files", selection), ... + "cache", struct("items", items)); end diff --git a/apps/electrochem/cic/+cic/definition.m b/apps/electrochem/cic/+cic/definition.m index 7e8c2527f..3708cb4fc 100644 --- a/apps/electrochem/cic/+cic/definition.m +++ b/apps/electrochem/cic/+cic/definition.m @@ -1,23 +1,12 @@ -% App-owned runtime definition for labkit_CIC_app. Expected caller: the -% public app entrypoint. Output is a declarative LabKit app definition; side -% effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_CIC_app", ... - "Id", "cic", ... - "Title", "Gamry CIC GUI (Voltage Transient)", ... - "DisplayName", "CIC", ... - "Family", "Electrochem", ... - "AppVersion", "1.4.7", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "dta", ">=2.0 <3"), ... - "Project", cic.projectSpec(), ... - "CreateSession", @cic.createSession, ... - "Layout", @cic.userInterface.buildWorkbenchLayout, ... - "Actions", cic.definitionActions(), ... - "Present", @cic.userInterface.presentWorkbench, ... - "Renderers", struct("cicAxis", ... - @cic.userInterface.renderCicAxis), ... - "DebugSample", @cic.debug.writeSamplePack); +%DEFINE Declare CIC's explicit App SDK contract. +function app = definition() +app = labkit.app.Definition( ... + Entrypoint="labkit_CIC_app", AppId="cic", ... + Title="Gamry CIC GUI (Voltage Transient)", DisplayName="CIC", ... + Family="Electrochem", AppVersion="1.5.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2", "dta", ">=2.0 <3"), ... + ProjectSchema=cic.projectSpec(), CreateSession=@cic.createSession, ... + Workbench=cic.workbench.buildLayout(), ... + PresentWorkbench=@cic.workbench.present, ... + BuildDebugSample=@cic.debug.writeSamplePack); end diff --git a/apps/electrochem/cic/+cic/definitionActions.m b/apps/electrochem/cic/+cic/definitionActions.m deleted file mode 100644 index 079059e8e..000000000 --- a/apps/electrochem/cic/+cic/definitionActions.m +++ /dev/null @@ -1,265 +0,0 @@ -% App-owned Runtime V2 action table for CIC. Handlers receive canonical -% state/events/services and own DTA selection, CIC analysis, and result export -% without reading or mutating UI controls. -function actions = definitionActions() - actions = struct( ... - "openFilesChosen", @onOpenFilesChosen, ... - "removeSelected", @onRemoveSelected, ... - "clearAll", @onClearAll, ... - "exportResults", @onExportResults, ... - "fileSelectionChanged", @onFileSelectionChanged, ... - "presetChanged", @onPresetChanged, ... - "analysisChanged", @onAnalysisChanged); -end - -function state = onOpenFilesChosen(state, event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - if isempty(paths) - state = services.workflow.log(state, "Open cancelled."); - return; - end - - existingPaths = labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources); - addedPaths = strings(numel(paths), 1); - added = 0; - for k = 1:numel(paths) - filepath = paths(k); - if any(existingPaths == filepath) || ... - any(addedPaths(1:added) == filepath) - state = services.workflow.log(state, ... - "Skipped already loaded: " + filepath); - continue; - end - added = added + 1; - addedPaths(added) = filepath; - state = services.workflow.log(state, "Registered: " + filepath); - end - addedPaths = addedPaths(1:added); - state.project.inputs.sources = services.project.reconcileSources( ... - state.project.inputs.sources, [existingPaths; addedPaths], ... - "chrono", "dta", true); - state.session.selection.currentIndex = numel(state.project.inputs.sources); - state = ensureCurrentItemLoaded(state, services); - state.project.parameters = resetPlotSelections(state.project.parameters); - state.project.results.lastExport = []; - if added > 1 - state = services.workflow.log(state, sprintf( ... - 'Deferred %d non-visible file(s) until selection or export.', ... - added - 1)); - end -end - -function state = onRemoveSelected(state, event, services) - sources = state.project.inputs.sources; - indices = services.events.indices(event, "removedFiles", numel(sources)); - if isempty(indices) - return; - end - removedSourceFiles = labkit.ui.runtime.sourcePaths(sources(indices)); - state.session.cache.items = removeItemsByPath( ... - state.session.cache.items, removedSourceFiles); - remainingPaths = labkit.ui.runtime.sourcePaths(sources); - remainingPaths(indices) = []; - state.project.inputs.sources = services.project.reconcileSources( ... - sources, remainingPaths, "chrono", "dta", true); - state.session.selection.currentIndex = boundedCurrentIndex( ... - state.session.selection.currentIndex, numel(state.project.inputs.sources)); - state.project.parameters = resetPlotSelections(state.project.parameters); - state.project.results.lastExport = []; - for k = 1:numel(removedSourceFiles) - state = services.workflow.log(state, "Removed: " + removedSourceFiles(k)); - end -end - -function state = onClearAll(state, ~, services) - state.project.inputs.sources = services.project.reconcileSources( ... - state.project.inputs.sources, strings(0, 1), ... - "chrono", "dta", true); - state.project.results.lastExport = []; - state.session.cache.items = struct([]); - state.session.selection.currentIndex = 0; - state.project.parameters = resetPlotSelections(state.project.parameters); - state = services.workflow.log(state, "Cleared all files."); -end - -function state = onFileSelectionChanged(state, event, services) - indices = services.events.indices(event, "selectedFiles", ... - numel(state.project.inputs.sources)); - if isempty(indices) - state.session.selection.currentIndex = 0; - else - state.session.selection.currentIndex = indices(1); - end - state = ensureCurrentItemLoaded(state, services); - state.project.parameters = resetPlotSelections(state.project.parameters); -end - -function state = onPresetChanged(state, ~, services) - choices = cic.userInterface.analysisChoices(); - preset = string(state.project.parameters.preset); - if preset == choices.presets(1) - state.project.parameters.cathLimit = -0.6; - state.project.parameters.anodLimit = 0.8; - elseif preset == choices.presets(2) - state.project.parameters.cathLimit = -0.9; - state.project.parameters.anodLimit = 0.6; - end - state = analyzeAllFiles(state, services); -end - -function state = onAnalysisChanged(state, ~, services) - state = analyzeAllFiles(state, services); -end - -function state = analyzeAllFiles(state, services) - if isempty(state.session.cache.items) - state.project.results.lastExport = []; - return; - end - opts = cic.analysisRun.optionsFromParameters(state.project.parameters); - state.session.cache.items = cic.analysisRun.recomputeItems( ... - state.session.cache.items, opts); - state.project.results.lastExport = []; - state = services.workflow.log(state, sprintf( ... - 'Reanalyzed %d loaded file(s) with shared analysis settings.', ... - numel(state.session.cache.items))); -end - -function state = onExportResults(state, ~, services) - if isempty(state.project.inputs.sources) - services.dialogs.alert('No results to export.', 'Export'); - return; - end - [state, ok] = ensureAllItemsLoaded(state, services); - if ~ok - return; - end - state = analyzeAllFiles(state, services); - [out, cancelled] = services.dialogs.outputFile( ... - 'cic_results.csv', 'Save results CSV', 'cic_results.csv'); - if cancelled - state = services.workflow.log(state, "Result export cancelled."); - return; - end - [~, unitLabel] = cic.userInterface.displayUnit( ... - state.project.parameters.cicUnit); - [ok, message] = cic.resultFiles.writeResultsCSV( ... - state.session.cache.items, out, unitLabel); - if ~ok - services.dialogs.alert(message, 'Export'); - return; - end - [folder, name, extension] = fileparts(out); - output = services.results.output("cicResults", "primary", ... - string(name) + string(extension), "text/csv"); - spec = struct( ... - "Outputs", output, ... - "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, ... - "Summary", struct("fileCount", numel(state.session.cache.items)), ... - "ManifestName", "cic_results.labkit.json"); - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.lastExport = struct( ... - "csvPath", string(out), "manifestPath", string(manifestPath)); - state = services.workflow.log(state, "Exported CSV: " + string(out)); -end - -function state = logAnalysis(state, item, services) - analysis = item.analysis; - if analysis.ok - state = services.workflow.log(state, string(sprintf( ... - '%s: Emc=%.6f V, Ema=%.6f V, safe=%d', ... - item.name, analysis.Emc, analysis.Ema, analysis.safe))); - elseif isfield(analysis, 'logOnFailure') && analysis.logOnFailure - state = services.workflow.log(state, ... - string(item.name) + ": " + string(analysis.message)); - end -end - -function parameters = resetPlotSelections(parameters) - choices = cic.userInterface.analysisChoices(); - parameters.topX = choices.xAxes(1); - parameters.topY = choices.yAxes(1); - parameters.topGrid = true; - parameters.bottomX = choices.xAxes(1); - parameters.bottomY = choices.yAxes(2); - parameters.bottomGrid = true; -end - -function index = boundedCurrentIndex(index, count) - if count == 0 - index = 0; - else - index = min(max(1, round(double(index))), count); - end -end - -function state = ensureCurrentItemLoaded(state, services) - index = boundedCurrentIndex(state.session.selection.currentIndex, ... - numel(state.project.inputs.sources)); - if index == 0 - return; - end - filepath = labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources(index)); - if itemIsLoaded(state.session.cache.items, filepath) - return; - end - [item, status] = cic.sourceFiles.loadItem(filepath, state.project.parameters); - if ~status.ok - state = services.workflow.log(state, ... - "Failed: " + filepath + " | " + string(status.message)); - services.dialogs.alert(sprintf('Failed to load:\n%s\n\n%s', ... - filepath, status.message), 'Load error'); - return; - end - state.session.cache.items = appendItem(state.session.cache.items, item); - for messageIndex = 1:numel(item.logmsg) - state = services.workflow.log(state, item.logmsg{messageIndex}); - end - state = logAnalysis(state, item, services); - state = services.workflow.log(state, "Loaded: " + filepath); -end - -function [state, ok] = ensureAllItemsLoaded(state, services) - ok = true; - paths = labkit.ui.runtime.sourcePaths(state.project.inputs.sources); - for k = 1:numel(paths) - if itemIsLoaded(state.session.cache.items, paths(k)) - continue; - end - [item, status] = cic.sourceFiles.loadItem(paths(k), state.project.parameters); - if ~status.ok - ok = false; - state = services.workflow.log(state, ... - "Failed: " + paths(k) + " | " + string(status.message)); - services.dialogs.alert(sprintf('Failed to load:\n%s\n\n%s', ... - paths(k), status.message), 'Load error'); - return; - end - state.session.cache.items = appendItem(state.session.cache.items, item); - end -end - -function tf = itemIsLoaded(items, filepath) - tf = ~isempty(items) && any(string({items.filepath}) == filepath); -end - -function items = removeItemsByPath(items, paths) - if isempty(items) - return; - end - items(ismember(string({items.filepath}), paths(:))) = []; -end - -function items = appendItem(items, item) - if isempty(items) - items = item; - else - items(end + 1) = item; - end -end diff --git a/apps/electrochem/cic/+cic/projectSpec.m b/apps/electrochem/cic/+cic/projectSpec.m index 478c03e69..38458803a 100644 --- a/apps/electrochem/cic/+cic/projectSpec.m +++ b/apps/electrochem/cic/+cic/projectSpec.m @@ -2,18 +2,15 @@ % Expected caller: cic.definition. Output owns the current payload version, % creation defaults, and validation. Side effects are none. function spec = projectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", []); + spec = labkit.app.project.Schema(Version=1, ... + Create=@createProject, Validate=@validateProject); end function project = createProject() - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); project = struct(); project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + struct([])); project.parameters = struct( ... "preset", choices.presets(1), ... "cathLimit", -0.6, ... @@ -41,7 +38,7 @@ function accepted = validateProject(project) assert(isfield(project.inputs, 'sources'), ... 'cic:InvalidProject', 'CIC project sources are missing.'); - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); p = project.parameters; requiredParameters = ["preset", "cathLimit", "anodLimit", ... "delayUs", "areaOverride", "pulseMode", "cicMode", "cicUnit", ... diff --git a/apps/electrochem/cic/labkit_CIC_app.m b/apps/electrochem/cic/labkit_CIC_app.m index ab476dc32..aa8bd5f3b 100644 --- a/apps/electrochem/cic/labkit_CIC_app.m +++ b/apps/electrochem/cic/labkit_CIC_app.m @@ -23,6 +23,5 @@ % reports the highest safe file among all loaded files. % - By default, the evaluation point is 10 us after the end of each phase, % matching the convention commonly used in the literature the user shared. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @cic.definition, varargin{:}); + [varargout{1:nargout}] = cic.definition().launch(varargin{:}); end diff --git a/apps/electrochem/csc/+csc/+analysisPlot/draw.m b/apps/electrochem/csc/+csc/+analysisPlot/draw.m new file mode 100644 index 000000000..400f4039a --- /dev/null +++ b/apps/electrochem/csc/+csc/+analysisPlot/draw.m @@ -0,0 +1,68 @@ +% Expected caller: registered CSC App SDK runtime renderer. Inputs are one axes +% and a prepared axis model. Side effects are limited to supplied graphics. +function draw(axesById, model) +renderCscAxis(axesById.top, model.top); +renderCscAxis(axesById.bottom, model.bottom); +end + +function renderCscAxis(ax, model) + if ~model.valid + labkit.app.plot.clearAxes(ax, ResetScale=true); + title(ax, model.title); + xlabel(ax, 'X'); + ylabel(ax, 'Y'); + return; + end + if model.curveIndex == 0 + opts = struct( ... + "showGrid", model.showGrid, ... + "title", model.title + " (all cycles)", ... + "curveIndices", model.curveIndices); + csc.analysisPlot.plotAllCycles(ax, model.curves, ... + model.xSelection, model.ySelection, opts); + return; + end + + curve = model.curves(model.curveIndex); + request = csc.analysisPlot.plotRequest(curve, ... + model.xSelection, model.ySelection, upperFirst(model.axisId)); + opts = struct( ... + "holdPlot", model.holdPlot, ... + "showGrid", model.showGrid, ... + "lineWidth", 1.2); + info = csc.analysisPlot.plotXY( ... + ax, request.x, request.y, request.labels, opts); + clearTrim(ax); + if info.ok && model.showTrim && model.ySelection == "Im" && ... + isstruct(model.analysis) && isfield(model.analysis, 'ok') && ... + model.analysis.ok + drawTrimOverlay(ax, curve, model.xSelection, ... + model.ySelection, model.analysis); + end +end + +function drawTrimOverlay(ax, curve, xSelection, ySelection, result) + [xValues, ~, ~, ~] = labkit.dta.getCurveXY( ... + curve, xSelection, ySelection); + overlay = csc.analysisPlot.trimOverlayData( ... + true, ySelection, xValues, result); + if ~overlay.ok + return; + end + hold(ax, 'on'); + plot(ax, overlay.x, overlay.cathY, ... + 'Color', [0.1 0.6 0.1], 'LineWidth', 1.0, 'Tag', 'trimCath'); + plot(ax, overlay.x, overlay.anodY, ... + 'Color', [0.8 0.3 0.1], 'LineWidth', 1.0, 'Tag', 'trimAnod'); + hold(ax, 'off'); +end + +function clearTrim(ax) + delete(findobj(ax, 'Tag', 'trimCath')); + delete(findobj(ax, 'Tag', 'trimAnod')); +end + +function text = upperFirst(value) + text = char(value); + text(1) = upper(text(1)); +end diff --git a/apps/electrochem/csc/+csc/+userInterface/plotAllCycles.m b/apps/electrochem/csc/+csc/+analysisPlot/plotAllCycles.m similarity index 94% rename from apps/electrochem/csc/+csc/+userInterface/plotAllCycles.m rename to apps/electrochem/csc/+csc/+analysisPlot/plotAllCycles.m index cebd2b26e..791b9dcd6 100644 --- a/apps/electrochem/csc/+csc/+userInterface/plotAllCycles.m +++ b/apps/electrochem/csc/+csc/+analysisPlot/plotAllCycles.m @@ -10,7 +10,7 @@ end opts = fillOptions(opts, numel(curves)); - labkit.ui.plot.clear(ax, "ResetScale", true); + labkit.app.plot.clearAxes(ax, ResetScale=true); hold(ax, 'on'); colors = lines(max(1, numel(curves))); labels = struct('x', char(string(xSelection)), 'y', char(string(ySelection))); @@ -38,7 +38,7 @@ title(ax, opts.title, 'Interpreter', 'none'); xlabel(ax, labels.x, 'Interpreter', 'none'); ylabel(ax, labels.y, 'Interpreter', 'none'); - labkit.ui.plot.fit(ax); + labkit.app.plot.fitAxesToGraphics(ax); info = struct('ok', plotted > 0, 'plotted', plotted, ... 'xName', labels.x, 'yName', labels.y); @@ -52,7 +52,7 @@ opts.lineWidth = 1.1; end if ~isfield(opts, 'title') - choices = csc.userInterface.analysisChoices(); + choices = csc.analysisRun.analysisChoices(); opts.title = char(choices.allCycles); end if ~isfield(opts, 'curveIndices') diff --git a/apps/electrochem/csc/+csc/+userInterface/plotRequest.m b/apps/electrochem/csc/+csc/+analysisPlot/plotRequest.m similarity index 100% rename from apps/electrochem/csc/+csc/+userInterface/plotRequest.m rename to apps/electrochem/csc/+csc/+analysisPlot/plotRequest.m diff --git a/apps/electrochem/csc/+csc/+userInterface/plotXY.m b/apps/electrochem/csc/+csc/+analysisPlot/plotXY.m similarity index 87% rename from apps/electrochem/csc/+csc/+userInterface/plotXY.m rename to apps/electrochem/csc/+csc/+analysisPlot/plotXY.m index b47e3d659..60eb56956 100644 --- a/apps/electrochem/csc/+csc/+userInterface/plotXY.m +++ b/apps/electrochem/csc/+csc/+analysisPlot/plotXY.m @@ -1,7 +1,7 @@ % App-owned CSC plotting helper. Expected caller: CSC action/render callbacks. % Inputs are a target axes, prepared X/Y vectors, label struct, and display % options. Output is a status struct for runner logging. Side effects are -% limited to drawing on the supplied axes; assumes csc.userInterface.plotRequest prepared +% limited to drawing on the supplied axes; assumes analysisPlot.plotRequest prepared % app-specific data and log text. function info = plotXY(ax, x, y, labels, opts) %PLOTXY Plot one prepared CSC X/Y numeric series. @@ -32,7 +32,7 @@ y = y(:); if ~opts.holdPlot - labkit.ui.plot.clear(ax, "ResetScale", true); + labkit.app.plot.clearAxes(ax, ResetScale=true); end hLine = plot(ax, x, y, 'LineWidth', opts.lineWidth); @@ -41,9 +41,9 @@ xlabel(ax, labels.x, 'Interpreter', 'none'); ylabel(ax, labels.y, 'Interpreter', 'none'); if opts.holdPlot - labkit.ui.plot.fit(ax); + labkit.app.plot.fitAxesToGraphics(ax); else - labkit.ui.plot.fit(ax, hLine); + labkit.app.plot.fitAxesToGraphics(ax, hLine); end info.ok = true; diff --git a/apps/electrochem/csc/+csc/+userInterface/trimOverlayData.m b/apps/electrochem/csc/+csc/+analysisPlot/trimOverlayData.m similarity index 100% rename from apps/electrochem/csc/+csc/+userInterface/trimOverlayData.m rename to apps/electrochem/csc/+csc/+analysisPlot/trimOverlayData.m diff --git a/apps/electrochem/csc/+csc/+userInterface/analysisChoices.m b/apps/electrochem/csc/+csc/+analysisRun/analysisChoices.m similarity index 100% rename from apps/electrochem/csc/+csc/+userInterface/analysisChoices.m rename to apps/electrochem/csc/+csc/+analysisRun/analysisChoices.m diff --git a/apps/electrochem/csc/+csc/+userInterface/comparisonReadout.m b/apps/electrochem/csc/+csc/+analysisRun/comparisonReadout.m similarity index 85% rename from apps/electrochem/csc/+csc/+userInterface/comparisonReadout.m rename to apps/electrochem/csc/+csc/+analysisRun/comparisonReadout.m index baeb36a8e..3683ce817 100644 --- a/apps/electrochem/csc/+csc/+userInterface/comparisonReadout.m +++ b/apps/electrochem/csc/+csc/+analysisRun/comparisonReadout.m @@ -37,9 +37,9 @@ end readout.ok = true; - readout.qctText = csc.userInterface.formatChargeAndCSC(result.Qct, result.area_cm2); - readout.qcvText = csc.userInterface.formatChargeAndCSC(result.Qcv, result.area_cm2); - readout.diffText = csc.userInterface.formatChargeAndCSC(result.diff_C, result.area_cm2); + readout.qctText = csc.analysisRun.formatChargeAndCSC(result.Qct, result.area_cm2); + readout.qcvText = csc.analysisRun.formatChargeAndCSC(result.Qcv, result.area_cm2); + readout.diffText = csc.analysisRun.formatChargeAndCSC(result.diff_C, result.area_cm2); readout.relText = sprintf('%.6f %%', result.rel_pct); readout.dtErrText = sprintf('%.6e s', result.dtErr); readout.logMessage = sprintf(['Compare [%s]: Qct=%.6e C, Qcv=%.6e C, ', ... diff --git a/apps/electrochem/csc/+csc/+analysisRun/computeCSC.m b/apps/electrochem/csc/+csc/+analysisRun/computeCSC.m index 42969592c..e0f2b491d 100644 --- a/apps/electrochem/csc/+csc/+analysisRun/computeCSC.m +++ b/apps/electrochem/csc/+csc/+analysisRun/computeCSC.m @@ -84,7 +84,7 @@ % % See also csc.analysisRun.chargeDensity - choices = csc.userInterface.analysisChoices(); + choices = csc.analysisRun.analysisChoices(); if nargin < 2 opts = struct(); end @@ -188,7 +188,7 @@ case char(choices.modes(3)) function opts = fillOptions(opts) if ~isfield(opts, 'mode') - choices = csc.userInterface.analysisChoices(); + choices = csc.analysisRun.analysisChoices(); opts.mode = char(choices.modes(1)); end if ~isfield(opts, 'scanRate') diff --git a/apps/electrochem/csc/+csc/+userInterface/cycleResultsColumnNames.m b/apps/electrochem/csc/+csc/+analysisRun/cycleResultsColumnNames.m similarity index 93% rename from apps/electrochem/csc/+csc/+userInterface/cycleResultsColumnNames.m rename to apps/electrochem/csc/+csc/+analysisRun/cycleResultsColumnNames.m index 59b6e646f..90747836f 100644 --- a/apps/electrochem/csc/+csc/+userInterface/cycleResultsColumnNames.m +++ b/apps/electrochem/csc/+csc/+analysisRun/cycleResultsColumnNames.m @@ -10,7 +10,7 @@ end function label = modeLabel(mode) - choices = csc.userInterface.analysisChoices(); + choices = csc.analysisRun.analysisChoices(); switch char(string(mode)) case char(choices.modes(2)) label = '(cathodic mC/cm^2)'; diff --git a/apps/electrochem/csc/+csc/+userInterface/cycleResultsTableData.m b/apps/electrochem/csc/+csc/+analysisRun/cycleResultsTableData.m similarity index 97% rename from apps/electrochem/csc/+csc/+userInterface/cycleResultsTableData.m rename to apps/electrochem/csc/+csc/+analysisRun/cycleResultsTableData.m index b76f95af7..046cf6f03 100644 --- a/apps/electrochem/csc/+csc/+userInterface/cycleResultsTableData.m +++ b/apps/electrochem/csc/+csc/+analysisRun/cycleResultsTableData.m @@ -22,7 +22,7 @@ end function fields = modeFields(mode) - choices = csc.userInterface.analysisChoices(); + choices = csc.analysisRun.analysisChoices(); switch char(string(mode)) case char(choices.modes(2)) fields = struct('cv', 'CSCcvCath_mCcm2', ... diff --git a/apps/electrochem/csc/+csc/+userInterface/defaultPlotSelections.m b/apps/electrochem/csc/+csc/+analysisRun/defaultPlotSelections.m similarity index 100% rename from apps/electrochem/csc/+csc/+userInterface/defaultPlotSelections.m rename to apps/electrochem/csc/+csc/+analysisRun/defaultPlotSelections.m diff --git a/apps/electrochem/csc/+csc/+userInterface/formatChargeAndCSC.m b/apps/electrochem/csc/+csc/+analysisRun/formatChargeAndCSC.m similarity index 100% rename from apps/electrochem/csc/+csc/+userInterface/formatChargeAndCSC.m rename to apps/electrochem/csc/+csc/+analysisRun/formatChargeAndCSC.m diff --git a/apps/electrochem/csc/+csc/+debug/writeSamplePack.m b/apps/electrochem/csc/+csc/+debug/writeSamplePack.m index 40ff20412..15e17f047 100644 --- a/apps/electrochem/csc/+csc/+debug/writeSamplePack.m +++ b/apps/electrochem/csc/+csc/+debug/writeSamplePack.m @@ -1,39 +1,30 @@ -% Expected caller: csc.definitionActions during debug launch and unit tests. Input is a +% Expected caller: CSC debug launch and unit tests. Input is a % LabKit debug context. Output is a deterministic synthetic CV/CT DTA sample % pack. Side effects: writes anonymous debug input files and records a session % manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write CSC debug CV/CT DTA files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "csc"); - dtaFolder = fullfile(char(folders.sampleFolder), "dta"); - ensureFolder(dtaFolder); - - cvPath = string(fullfile(dtaFolder, "cvct_csc_debug.DTA")); - zeroScanPath = string(fullfile(dtaFolder, "cvct_csc_valid_zero_scanrate_debug.DTA")); - malformedPath = string(fullfile(dtaFolder, "cvct_csc_malformed_no_curve_debug.DTA")); + cvPath = sampleContext.samplePath("csc/representative.DTA"); + zeroScanPath = sampleContext.samplePath("csc/zero_scan_rate.DTA"); + malformedPath = sampleContext.samplePath("csc/malformed.DTA"); writeTextFile(cvPath, cvctText()); writeTextFile(zeroScanPath, cvctText(struct("ScanRateMv", 0))); writeTextFile(malformedPath, malformedCvctText()); - pack = struct( ... - "sampleFolder", folders.sampleFolder, ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", cvPath, ... - "boundaryFiles", struct( ... - "validEdge", zeroScanPath, ... - "malformed", malformedPath)); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_CSC_app", ... - "description", "Anonymous CV/CT DTA boundary pack for CSC debug launch.", ... - "inputs", struct( ... - "representativeCvctDta", cvPath, ... - "validEdgeCvctDta", zeroScanPath, ... - "malformedCvctDta", malformedPath), ... - "outputFolder", folders.outputFolder); - pack.manifest = manifest; - recordManifest(debugLog, manifest); + project = csc.projectSpec().Create(); + project.inputs.sources = sampleContext.sourceRecord( ... + "dta1", "cvct", cvPath, true); + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-cvct", InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact("representative", "cvct", cvPath), ... + sampleContext.artifact("zeroScanRate", "boundaryInput", zeroScanPath), ... + sampleContext.artifact("malformed", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function text = cvctText(opts) @@ -87,28 +78,6 @@ end end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 @@ -118,12 +87,6 @@ function writeTextFile(filepath, text) fprintf(fid, "%s", char(text)); end -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end - function value = tab() value = char(9); end diff --git a/apps/electrochem/csc/+csc/+resultFiles/buildResultsTable.m b/apps/electrochem/csc/+csc/+resultFiles/buildResultsTable.m index 17bda52a6..58499ea53 100644 --- a/apps/electrochem/csc/+csc/+resultFiles/buildResultsTable.m +++ b/apps/electrochem/csc/+csc/+resultFiles/buildResultsTable.m @@ -18,7 +18,7 @@ end function rows = collectRows(items, opts) - choices = csc.userInterface.analysisChoices(); + choices = csc.analysisRun.analysisChoices(); rowCells = {}; for iItem = 1:numel(items) item = items(iItem); diff --git a/apps/electrochem/csc/+csc/+resultFiles/exportResults.m b/apps/electrochem/csc/+csc/+resultFiles/exportResults.m new file mode 100644 index 000000000..6a835f69f --- /dev/null +++ b/apps/electrochem/csc/+csc/+resultFiles/exportResults.m @@ -0,0 +1,51 @@ +% App-owned implementation for csc.resultFiles.exportResults within the csc product workflow. +function applicationState = exportResults( ... + applicationState, callbackContext) +%EXPORTRESULTS Write the all-cycle CSC table and provenance manifest. +items = applicationState.session.cache.items; +if isempty(items) + callbackContext.alert("No CSC results to export.", "Export"); + return +end +choice = callbackContext.chooseOutputFile( ... + ["*.csv", "CSV files"], "csc_all_cycles.csv"); +if choice.Cancelled + callbackContext.appendStatus("CSC result export cancelled."); + return +end +filepath = string(choice.Value); +parameters = applicationState.project.parameters; +options = struct( ... + "mode", char(parameters.mode), ... + "area_cm2", parameters.area, ... + "ignoreEdgeCycles", logical(parameters.ignoreEdgeCycles)); +[ok, message] = csc.resultFiles.writeResultsCSV( ... + items, filepath, options); +if ~ok + callbackContext.alert(message, "Export"); + return +end + +[folder, name, extension] = fileparts(filepath); +output = labkit.app.result.File( ... + "cscResults", "primary", string(name) + string(extension), ... + MediaType="text/csv"); +package = resultPackage( ... + applicationState.project.inputs.sources, ... + applicationState.project.parameters, numel(items), {output}, ... + "csc_all_cycles.labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.lastResultsExport = struct( ... + "csvPath", filepath, "manifestPath", string(written.Value)); +callbackContext.appendStatus("Exported CSC CSV: " + filepath); +end + +function package = resultPackage( ... + sources, parameters, fileCount, outputs, manifestName) +package = labkit.app.result.Package( ... + Outputs=outputs, ... + Inputs=struct("sources", sources), ... + Parameters=parameters, ... + Summary=struct("fileCount", fileCount), ... + ManifestName=manifestName); +end diff --git a/apps/electrochem/csc/+csc/+resultFiles/exportVoltageCurrent.m b/apps/electrochem/csc/+csc/+resultFiles/exportVoltageCurrent.m new file mode 100644 index 000000000..7d909d554 --- /dev/null +++ b/apps/electrochem/csc/+csc/+resultFiles/exportVoltageCurrent.m @@ -0,0 +1,51 @@ +% App-owned implementation for csc.resultFiles.exportVoltageCurrent within the csc product workflow. +function applicationState = exportVoltageCurrent( ... + applicationState, callbackContext) +%EXPORTVOLTAGECURRENT Write column-oriented CV data and provenance. +items = applicationState.session.cache.items; +if isempty(items) + callbackContext.alert( ... + "No voltage/current data to export.", "Export"); + return +end +choice = callbackContext.chooseOutputFile( ... + ["*.csv", "CSV files"], "csc_cv_data.csv"); +if choice.Cancelled + callbackContext.appendStatus( ... + "Voltage/current export cancelled."); + return +end +options = struct("ignoreEdgeCycles", logical( ... + applicationState.project.parameters.ignoreEdgeCycles)); +[ok, message, info] = csc.resultFiles.writeVoltageCurrentCSV( ... + items, string(choice.Value), options); +if ~ok + callbackContext.alert(message, "Export"); + return +end + +outputs = cell(1, numel(info.files)); +for k = 1:numel(info.files) + [~, name, extension] = fileparts(info.files(k)); + role = "additional"; + if k == 1 + role = "primary"; + end + outputs{k} = labkit.app.result.File( ... + "cvData" + string(k), role, ... + string(name) + string(extension), MediaType="text/csv"); +end +folder = fileparts(char(info.files(1))); +package = labkit.app.result.Package( ... + Outputs=outputs, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct("fileCount", numel(items), "rows", info.rows), ... + ManifestName="csc_cv_data.labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.lastVoltageCurrentExport = struct( ... + "csvPaths", string(info.files), ... + "manifestPath", string(written.Value)); +callbackContext.appendStatus(sprintf( ... + "Exported %d CV data CSV file(s).", numel(info.files))); +end diff --git a/apps/electrochem/csc/+csc/+sourceFiles/loadProjectItems.m b/apps/electrochem/csc/+csc/+sourceFiles/loadProjectItems.m index d8ccad187..3f7f4c205 100644 --- a/apps/electrochem/csc/+csc/+sourceFiles/loadProjectItems.m +++ b/apps/electrochem/csc/+csc/+sourceFiles/loadProjectItems.m @@ -2,7 +2,7 @@ % records. Output is decoded CV/CT items; invalid required sources throw. function items = loadProjectItems(sources) items = struct([]); - paths = labkit.ui.runtime.sourcePaths(sources); + paths = string(sources); for k = 1:numel(sources) filepath = paths(k); [item, status] = labkit.dta.loadFile(filepath, "cvct"); diff --git a/apps/electrochem/csc/+csc/+sourceFiles/reloadSelected.m b/apps/electrochem/csc/+csc/+sourceFiles/reloadSelected.m new file mode 100644 index 000000000..88b922ed6 --- /dev/null +++ b/apps/electrochem/csc/+csc/+sourceFiles/reloadSelected.m @@ -0,0 +1,26 @@ +% App-owned implementation for csc.sourceFiles.reloadSelected within the csc product workflow. +function applicationState = reloadSelected( ... + applicationState, callbackContext) +%RELOADSELECTED Decode the currently selected portable DTA source again. +selection = applicationState.session.selection.files; +if isempty(selection.Indices) + return +end +index = selection.Indices(1); +paths = callbackContext.resolveSourcePaths( ... + applicationState.project.inputs.sources); +if index > numel(paths) + return +end +[item, status] = labkit.dta.loadFile(paths(index), "cvct"); +if ~status.ok + callbackContext.alert(status.message, "Reload failed"); + return +end +applicationState.session.cache.items(index) = item; +applicationState.session.selection.currentCurve = ... + csc.analysisRun.analysisChoices().allCycles; +applicationState.project.results.lastResultsExport = []; +applicationState.project.results.lastVoltageCurrentExport = []; +callbackContext.appendStatus("Reloaded: " + paths(index)); +end diff --git a/apps/electrochem/csc/+csc/+sourceFiles/selectFile.m b/apps/electrochem/csc/+csc/+sourceFiles/selectFile.m new file mode 100644 index 000000000..b547a728c --- /dev/null +++ b/apps/electrochem/csc/+csc/+sourceFiles/selectFile.m @@ -0,0 +1,7 @@ +% App-owned implementation for csc.sourceFiles.selectFile within the csc product workflow. +function applicationState = selectFile(applicationState, selection, ~) +%SELECTFILE Reset curve selection after the source list selection changes. +choices = csc.analysisRun.analysisChoices(); +applicationState.session.selection.files = selection; +applicationState.session.selection.currentCurve = choices.allCycles; +end diff --git a/apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m b/apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 70115c163..000000000 --- a/apps/electrochem/csc/+csc/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,126 +0,0 @@ -% Expected caller: csc.definition. Input is the runtime callback map. Output -% is a data-only workbench layout with semantic bindings and file actions. -function layout = buildWorkbenchLayout(callbacks) - choices = csc.userInterface.analysisChoices(); - layout = labkit.ui.layout.workbench("cscApp", ... - "Gamry DTA GUI (literature CSC)", ... - "controlTabs", controlTabs(choices, callbacks), ... - "workspace", plotsWorkspace()); -end - -function tabs = controlTabs(choices, callbacks) - tabs = { ... - labkit.ui.layout.tab("filesAnalysis", "Files + Analysis", { ... - filesSection(callbacks), ... - curveSection(choices), ... - plotSelectionsSection(choices)}), ... - labkit.ui.layout.tab("summaryResults", "Summary + Results", { ... - comparisonSection(choices, callbacks)}), ... - labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log")})})}; -end - -function section = filesSection(callbacks) - section = labkit.ui.layout.section("filesSection", "Files", { ... - labkit.ui.layout.filePanel("files", "Files", ... - "selectionMode", "single", ... - "chooseLabel", "Add DTA files", ... - "clearLabel", "Clear all", ... - "filters", {'*.DTA;*.dta', 'Gamry DTA files (*.DTA)'}, ... - "status", "No files loaded", ... - "emptyText", "No files loaded", ... - "onChoose", callbacks.openFilesChosen, ... - "onRemove", callbacks.removeSelected, ... - "onClear", callbacks.clearAll, ... - "onSelectionChange", ... - callbacks.fileSelectionChanged), ... - labkit.ui.layout.group("fileActions", "", { ... - labkit.ui.layout.action("reloadSelected", ... - "Reload selected", callbacks.reloadSelected)})}); -end - -function section = curveSection(choices) - section = labkit.ui.layout.section("curveSection", "Curve", { ... - readonlyField("filePath", "File:"), ... - readonlyField("scanRate", "Scan rate:"), ... - labkit.ui.layout.field("curve", "Curve:", ... - "kind", "dropdown", ... - "items", cellstr(choices.empty), ... - "value", choices.empty, ... - "Bind", "session.selection.currentCurve")}); -end - -function section = plotSelectionsSection(choices) - section = labkit.ui.layout.section("plotSelections", "Plot Selections", { ... - axisChoice("topX", "Top X:", choices.empty), ... - axisChoice("topY", "Top Y:", choices.empty), ... - plotCheckbox("topGrid", "Grid", true), ... - plotCheckbox("topHold", "Hold", false), ... - plotCheckbox("topTrim", "Show Trim", true), ... - axisChoice("bottomX", "Bottom X:", choices.empty), ... - axisChoice("bottomY", "Bottom Y:", choices.empty), ... - plotCheckbox("bottomGrid", "Grid", true), ... - plotCheckbox("bottomHold", "Hold", false), ... - plotCheckbox("bottomTrim", "Show Trim", true)}); -end - -function section = comparisonSection(choices, callbacks) - modes = cellstr(choices.modes); - section = labkit.ui.layout.section("comparisonSection", ... - "CSC / Comparison", { ... - labkit.ui.layout.field("mode", "Mode:", ... - "kind", "dropdown", "items", modes, "value", modes{1}, ... - "Bind", "project.parameters.mode"), ... - labkit.ui.layout.field("area", "Area (cm^2):", ... - "kind", "text", "value", "", ... - "Bind", "project.parameters.area"), ... - labkit.ui.layout.field("ignoreEdgeCycles", ... - "Ignore first/last cycle", ... - "kind", "checkbox", "value", false, ... - "Bind", "project.parameters.ignoreEdgeCycles"), ... - readonlyField("qct", "CT charge / CSC:"), ... - readonlyField("qcv", "CV charge / CSC:"), ... - readonlyField("diff", "Difference:"), ... - readonlyField("relativeDiff", "Relative diff:"), ... - readonlyField("dtError", "max|dt-|dV|/v|:"), ... - readonlyField("status", "Status:"), ... - labkit.ui.layout.resultTable("cycleResults", ... - "All Cycle CSC Results", ... - "columns", csc.userInterface.cycleResultsColumnNames(modes{1}), ... - "data", cell(0, 6)), ... - labkit.ui.layout.group("exportActions", "", { ... - labkit.ui.layout.action("exportResults", ... - "Export all cycles CSV", ... - callbacks.exportResults), ... - labkit.ui.layout.action("exportVoltageCurrent", ... - "Export CV data CSV", ... - callbacks.exportVoltageCurrent)})}); -end - -function workspace = plotsWorkspace() - workspace = labkit.ui.layout.workspace("plots", "Plots", { ... - labkit.ui.layout.previewArea("plotAxes", "Plots", ... - "layout", "stack", "count", 2, ... - "axisIds", {'top', 'bottom'}, ... - "axisTitles", {'Top Plot', 'Bottom Plot'}, ... - "xLabels", {'X', 'X'}, "yLabels", {'Y', 'Y'})}); -end - -function node = axisChoice(id, labelText, emptyChoice) - node = labkit.ui.layout.field(id, labelText, ... - "kind", "dropdown", ... - "items", cellstr(emptyChoice), "value", emptyChoice, ... - "Bind", "project.parameters." + id); -end - -function node = plotCheckbox(id, labelText, value) - node = labkit.ui.layout.field(id, labelText, ... - "kind", "checkbox", "value", value, ... - "Bind", "project.parameters." + id); -end - -function node = readonlyField(id, labelText) - node = labkit.ui.layout.field(id, labelText, ... - "kind", "readonly", "value", ""); -end diff --git a/apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m b/apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m deleted file mode 100644 index a6a7cddc1..000000000 --- a/apps/electrochem/csc/+csc/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,247 +0,0 @@ -% Expected caller: Runtime V2. Input is canonical CSC state. Output is one -% deterministic files/curve/comparison/two-axis presentation with no UI or IO. -function view = presentWorkbench(state) - items = state.session.cache.items; - parameters = state.project.parameters; - index = boundedIndex(state.session.selection.currentIndex, numel(items)); - choices = csc.userInterface.analysisChoices(); - - view = struct(); - view.controls.files = filePanelSpec(items, index); - if index == 0 - view = presentEmpty(view, parameters, choices); - view = presentAxes(view, emptyAxisModel("top"), ... - emptyAxisModel("bottom")); - return; - end - - item = items(index); - curveLabels = labelsForCurves(item.curves, choices); - selectedCurve = selectedCurveIndex( ... - state.session.selection.currentCurve, curveLabels); - columns = numericColumns(item.curves, selectedCurve, choices); - view.controls.filePath = struct("Value", string(item.filepath)); - view.controls.scanRate = struct("Value", scanRateText(item.scanRate)); - view.controls.curve = struct( ... - "Items", {cellstr(curveLabels)}, ... - "Value", curveLabels(selectedCurve + 1)); - view = presentPlotChoices(view, columns, parameters); - - opts = comparisonOptions(item, parameters); - results = csc.resultFiles.buildResultsTable(item, opts); - view.controls.cycleResults = struct( ... - "ColumnName", {csc.userInterface.cycleResultsColumnNames( ... - parameters.mode)}, ... - "Data", {csc.userInterface.cycleResultsTableData( ... - results, parameters.mode)}); - [view, analysis] = presentComparison( ... - view, item, selectedCurve, results, opts, choices); - top = axisModel(item, selectedCurve, parameters, "top", analysis); - bottom = axisModel(item, selectedCurve, parameters, "bottom", analysis); - view = presentAxes(view, top, bottom); -end - -function view = presentEmpty(view, parameters, choices) - view.controls.filePath = struct("Value", ""); - view.controls.scanRate = struct("Value", ""); - view.controls.curve = struct( ... - "Items", {{char(choices.empty)}}, "Value", choices.empty); - view = presentPlotChoices(view, choices.empty, parameters); - view.controls.cycleResults = struct( ... - "ColumnName", {csc.userInterface.cycleResultsColumnNames( ... - parameters.mode)}, "Data", {cell(0, 6)}); - view = setReadouts(view, "", "", "", "", "", "Ready"); -end - -function view = presentPlotChoices(view, columns, parameters) - columns = string(columns(:)).'; - if isempty(columns) - columns = csc.userInterface.analysisChoices().empty; - end - ids = ["topX", "topY", "bottomX", "bottomY"]; - for id = ids - value = string(parameters.(id)); - if ~any(columns == value) - value = columns(1); - end - view.controls.(id) = struct( ... - "Items", {cellstr(columns)}, "Value", value); - end -end - -function [view, analysis] = presentComparison( ... - view, item, selectedCurve, results, opts, choices) - analysis = struct(); - if selectedCurve == 0 - view = setReadouts(view, ... - "See all-cycle table", "See all-cycle table", ... - "See all-cycle table", "See all-cycle table", ... - maxDtErrorText(results), sprintf( ... - 'Showing %d cycle result(s) normalized by %s', ... - height(results), areaStatusText(opts.area_cm2))); - return; - end - analysis = csc.analysisRun.computeCSC( ... - item.curves(selectedCurve), opts); - readout = csc.userInterface.comparisonReadout(analysis, opts.mode); - view = setReadouts(view, readout.qctText, readout.qcvText, ... - readout.diffText, readout.relText, readout.dtErrText, ... - readout.statusText); -end - -function view = setReadouts(view, qct, qcv, difference, relative, dt, status) - ids = ["qct", "qcv", "diff", "relativeDiff", "dtError", "status"]; - values = {qct, qcv, difference, relative, dt, status}; - for k = 1:numel(ids) - view.controls.(ids(k)) = struct("Value", values{k}); - end -end - -function view = presentAxes(view, top, bottom) - view.previews.plotAxes.Axes.top = struct( ... - "Renderer", "cscAxis", "Model", top); - view.previews.plotAxes.Axes.bottom = struct( ... - "Renderer", "cscAxis", "Model", bottom); -end - -function model = axisModel(item, curveIndex, p, axisId, analysis) - model = emptyAxisModel(axisId); - if isempty(item.curves) - return; - end - model.valid = true; - model.curves = item.curves; - model.curveIndex = curveIndex; - model.xSelection = string(p.(axisId + "X")); - model.ySelection = string(p.(axisId + "Y")); - model.showGrid = logical(p.(axisId + "Grid")); - model.holdPlot = logical(p.(axisId + "Hold")); - model.showTrim = logical(p.(axisId + "Trim")); - model.analysis = analysis; - model.curveIndices = plottedCurveIndices( ... - numel(item.curves), p.ignoreEdgeCycles); -end - -function model = emptyAxisModel(axisId) - model = struct( ... - "valid", false, "axisId", axisId, ... - "title", upperFirst(axisId) + " Plot", ... - "curves", struct([]), "curveIndex", 0, ... - "xSelection", "", "ySelection", "", ... - "showGrid", true, "holdPlot", false, "showTrim", false, ... - "analysis", struct(), "curveIndices", []); -end - -function spec = filePanelSpec(items, index) - files = struct("id", {}, "path", {}, "status", {}); - for k = 1:numel(items) - files(end + 1) = struct( ... - "id", "item" + string(k), ... - "path", string(items(k).filepath), "status", ""); - end - selection = strings(0, 1); - if index > 0 - selection = "item" + string(index); - end - status = "No files loaded"; - if ~isempty(items) - status = string(numel(items)) + " file(s) loaded"; - end - spec = struct("Files", files, "Selection", selection, "Status", status); -end - -function labels = labelsForCurves(curves, choices) - labels = choices.allCycles; - for k = 1:numel(curves) - labels(end + 1) = string(sprintf('%s (%d rows)', ... - curves(k).name, size(curves(k).data, 1))); - end - if isempty(curves) - labels = choices.empty; - end -end - -function index = selectedCurveIndex(value, labels) - position = find(labels == string(value), 1); - if isempty(position) - index = 0; - else - index = position - 1; - end -end - -function columns = numericColumns(curves, selectedCurve, choices) - if isempty(curves) - columns = choices.empty; - return; - end - index = selectedCurve; - if index < 1 || index > numel(curves) - index = 1; - end - curve = curves(index); - columns = string(curve.headers(curve.numericMask)); - if isempty(columns) - columns = choices.empty; - end -end - -function opts = comparisonOptions(item, parameters) - opts = struct( ... - "mode", char(parameters.mode), ... - "scanRate", item.scanRate, ... - "area_cm2", parameters.area, ... - "ignoreEdgeCycles", logical(parameters.ignoreEdgeCycles)); -end - -function text = scanRateText(scanRate) - if isnan(scanRate) - text = "Not found"; - else - text = string(sprintf('%.6f V/s (%.3f mV/s)', ... - scanRate, scanRate * 1000)); - end -end - -function indices = plottedCurveIndices(count, ignoreEdges) - indices = 1:count; - if ignoreEdges && count > 0 - indices = indices(indices ~= 1 & indices ~= count); - end -end - -function text = maxDtErrorText(results) - if isempty(results) || height(results) == 0 || all(isnan(results.DtError_s)) - text = ""; - else - text = string(sprintf('max %.12e s', ... - max(results.DtError_s, [], 'omitnan'))); - end -end - -function text = areaStatusText(area) - parsed = str2double(strtrim(char(string(area)))); - if isnumeric(area) - parsed = area; - end - if isscalar(parsed) && isfinite(parsed) && parsed > 0 - text = sprintf('%.6g cm^2', parsed); - else - text = 'charge only (area not set)'; - end -end - -function text = upperFirst(value) - text = string(value); - chars = char(text); - chars(1) = upper(chars(1)); - text = string(chars); -end - -function index = boundedIndex(index, count) - if count == 0 - index = 0; - else - index = min(max(1, round(double(index))), count); - end -end diff --git a/apps/electrochem/csc/+csc/+userInterface/renderCscAxis.m b/apps/electrochem/csc/+csc/+userInterface/renderCscAxis.m deleted file mode 100644 index 90681cab9..000000000 --- a/apps/electrochem/csc/+csc/+userInterface/renderCscAxis.m +++ /dev/null @@ -1,63 +0,0 @@ -% Expected caller: registered CSC Runtime V2 renderer. Inputs are one axes -% and a prepared axis model. Side effects are limited to supplied graphics. -function renderCscAxis(ax, model) - if ~model.valid - labkit.ui.plot.clear(ax, "ResetScale", true); - title(ax, model.title); - xlabel(ax, 'X'); - ylabel(ax, 'Y'); - return; - end - if model.curveIndex == 0 - opts = struct( ... - "showGrid", model.showGrid, ... - "title", model.title + " (all cycles)", ... - "curveIndices", model.curveIndices); - csc.userInterface.plotAllCycles(ax, model.curves, ... - model.xSelection, model.ySelection, opts); - return; - end - - curve = model.curves(model.curveIndex); - request = csc.userInterface.plotRequest(curve, ... - model.xSelection, model.ySelection, upperFirst(model.axisId)); - opts = struct( ... - "holdPlot", model.holdPlot, ... - "showGrid", model.showGrid, ... - "lineWidth", 1.2); - info = csc.userInterface.plotXY( ... - ax, request.x, request.y, request.labels, opts); - clearTrim(ax); - if info.ok && model.showTrim && model.ySelection == "Im" && ... - isstruct(model.analysis) && isfield(model.analysis, 'ok') && ... - model.analysis.ok - drawTrimOverlay(ax, curve, model.xSelection, ... - model.ySelection, model.analysis); - end -end - -function drawTrimOverlay(ax, curve, xSelection, ySelection, result) - [xValues, ~, ~, ~] = labkit.dta.getCurveXY( ... - curve, xSelection, ySelection); - overlay = csc.userInterface.trimOverlayData( ... - true, ySelection, xValues, result); - if ~overlay.ok - return; - end - hold(ax, 'on'); - plot(ax, overlay.x, overlay.cathY, ... - 'Color', [0.1 0.6 0.1], 'LineWidth', 1.0, 'Tag', 'trimCath'); - plot(ax, overlay.x, overlay.anodY, ... - 'Color', [0.8 0.3 0.1], 'LineWidth', 1.0, 'Tag', 'trimAnod'); - hold(ax, 'off'); -end - -function clearTrim(ax) - delete(findobj(ax, 'Tag', 'trimCath')); - delete(findobj(ax, 'Tag', 'trimAnod')); -end - -function text = upperFirst(value) - text = char(value); - text(1) = upper(text(1)); -end diff --git a/apps/electrochem/csc/+csc/+workbench/buildLayout.m b/apps/electrochem/csc/+csc/+workbench/buildLayout.m new file mode 100644 index 000000000..746edc94e --- /dev/null +++ b/apps/electrochem/csc/+csc/+workbench/buildLayout.m @@ -0,0 +1,94 @@ +% App-owned implementation for csc.workbench.buildLayout within the csc product workflow. +function layout = buildLayout() +%BUILDLAYOUT Compose the CSC source, comparison, plot, and export workflow. +choices = csc.analysisRun.analysisChoices(); +files = labkit.app.layout.fileList("files", ... + Label="Files", ChooseLabel="Add DTA files", ... + ChooseTooltip="Add Gamry CV/CT DTA data used to calculate and compare charge-storage capacity.", ... + FolderLabel="Add folder", RecursiveFolderLabel="Add folder tree", ... + RemoveLabel="Remove selected", ClearLabel="Clear all", ... + EmptyText="No files loaded", ... + Filters=["*.DTA;*.dta", "Gamry DTA files (*.DTA)"], ... + SelectionMode="single", ... + Bind="project.inputs.sources", ... + SelectionBind="session.selection.files", ... + SourceRole="cvct", SourceIdPrefix="dta", ... + OnSelectionChanged=@csc.sourceFiles.selectFile); + +sourceSection = labkit.app.layout.section("filesSection", "Files", { ... + files, ... + labkit.app.layout.button("reloadSelected", ... + "Reload selected", @csc.sourceFiles.reloadSelected, ... + Tooltip="Re-read the selected DTA file and recompute its CV/CT curves and CSC comparison.")}); +curveSection = labkit.app.layout.section("curveSection", "Curve", { ... + readonlyField("filePath", "File:"), ... + readonlyField("scanRate", "Scan rate:"), ... + labkit.app.layout.field("curve", Label="Curve:", Kind="choice", ... + Choices=choices.empty, Bind="session.selection.currentCurve")}); +plotSection = labkit.app.layout.section( ... + "plotSelections", "Plot Selections", { ... + axisChoice("topX", "Top X:", choices.empty), ... + axisChoice("topY", "Top Y:", choices.empty), ... + logicalField("topGrid", "Grid"), ... + logicalField("topHold", "Hold"), ... + logicalField("topTrim", "Show Trim"), ... + axisChoice("bottomX", "Bottom X:", choices.empty), ... + axisChoice("bottomY", "Bottom Y:", choices.empty), ... + logicalField("bottomGrid", "Grid"), ... + logicalField("bottomHold", "Hold"), ... + logicalField("bottomTrim", "Show Trim")}); + +comparison = labkit.app.layout.section( ... + "comparisonSection", "CSC / Comparison", { ... + labkit.app.layout.field("mode", Label="Mode", Kind="choice", ... + Choices=choices.modes, Bind="project.parameters.mode"), ... + labkit.app.layout.field("area", Label="Area (cm^2)", Kind="text", ... + Bind="project.parameters.area"), ... + labkit.app.layout.field("ignoreEdgeCycles", ... + Label="Ignore first/last cycle", Kind="logical", ... + Bind="project.parameters.ignoreEdgeCycles"), ... + readonlyField("qct", "CT charge / CSC:"), ... + readonlyField("qcv", "CV charge / CSC:"), ... + readonlyField("diff", "Difference:"), ... + readonlyField("relativeDiff", "Relative diff:"), ... + readonlyField("dtError", "max|dt-|dV|/v|:"), ... + readonlyField("status", "Status:"), ... + labkit.app.layout.dataTable("cycleResults", ... + Title="All Cycle CSC Results"), ... + labkit.app.layout.group("exportActions", { ... + labkit.app.layout.button("exportResults", ... + "Export all cycles CSV", @csc.resultFiles.exportResults, ... + Tooltip="Export per-cycle CV charge, CT charge, CSC, and comparison diagnostics."), ... + labkit.app.layout.button("exportVoltageCurrent", ... + "Export CV data CSV", ... + @csc.resultFiles.exportVoltageCurrent, ... + Tooltip="Export the selected cyclic-voltammetry voltage and current samples used by the CSC calculation.")})}); + +controls = { ... + labkit.app.layout.tab("filesAnalysis", "Files + Analysis", { ... + sourceSection, curveSection, plotSection}), ... + labkit.app.layout.tab("summaryResults", "Summary + Results", { ... + comparison}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.logPanel("appLog")})}; +plot = labkit.app.layout.plotArea("plotAxes", ... + @csc.analysisPlot.draw, Title="Plots", Layout="stack", ... + AxisIds=["top", "bottom"], AxisTitles=["Top Plot", "Bottom Plot"], ... + XLabels=["X", "X"], YLabels=["Y", "Y"]); +layout = labkit.app.layout.workbench(controls, ... + Workspace=labkit.app.layout.workspace(plot, Title="Plots")); +end + +function node = axisChoice(id, label, choices) +node = labkit.app.layout.field(id, Label=label, Kind="choice", ... + Choices=choices, Bind="project.parameters." + id); +end + +function node = logicalField(id, label) +node = labkit.app.layout.field(id, Label=label, Kind="logical", ... + Bind="project.parameters." + id); +end + +function node = readonlyField(id, label) +node = labkit.app.layout.field(id, Label=label, Kind="readonly", Value=""); +end diff --git a/apps/electrochem/csc/+csc/+workbench/present.m b/apps/electrochem/csc/+csc/+workbench/present.m new file mode 100644 index 000000000..6f6233af2 --- /dev/null +++ b/apps/electrochem/csc/+csc/+workbench/present.m @@ -0,0 +1,202 @@ +% App-owned implementation for csc.workbench.present within the csc product workflow. +function view = present(applicationState) +%PRESENT Adapt CSC runtime state into choices, comparison, and named axes. +items = applicationState.session.cache.items; +parameters = applicationState.project.parameters; +selection = applicationState.session.selection; +choices = csc.analysisRun.analysisChoices(); +index = selectedIndex(selection.files, numel(items)); + +[item, curveLabels, curveIndex, columns] = selectedCurve( ... + items, index, selection.currentCurve, choices); +[axisValues, axisChoices] = effectiveAxes(parameters, columns, choices); +[tableData, tableColumns, analysis, readout] = comparisonPresentation( ... + item, curveIndex, parameters, choices); +model = struct( ... + "top", axisModel(item, curveIndex, axisValues, parameters, ... + "top", analysis), ... + "bottom", axisModel(item, curveIndex, axisValues, parameters, ... + "bottom", analysis)); + +view = labkit.app.view.Snapshot() ... + .choices("curve", curveLabels) ... + .value("curve", curveLabels(max(1, curveIndex + 1))) ... + .tableData("cycleResults", tableData, Columns=tableColumns) ... + .renderPlot("plotAxes", model) ... + .text("filePath", itemPath(item)) ... + .text("scanRate", scanRateText(item)); +for id = ["topX", "topY", "bottomX", "bottomY"] + view = view.choices(id, axisChoices).value(id, axisValues.(id)); +end +view = view.text("qct", readout.qctText) ... + .text("qcv", readout.qcvText) ... + .text("diff", readout.diffText) ... + .text("relativeDiff", readout.relText) ... + .text("dtError", readout.dtErrText) ... + .text("status", readout.statusText); +end + +function [item, labels, curveIndex, columns] = selectedCurve( ... + items, itemIndex, requestedCurve, choices) +item = []; +labels = choices.empty; +curveIndex = 0; +columns = choices.empty; +if itemIndex == 0 + return +end +item = items(itemIndex); +labels = choices.allCycles; +for k = 1:numel(item.curves) + labels(end + 1) = string(sprintf("%s (%d rows)", ... + item.curves(k).name, size(item.curves(k).data, 1))); +end +if isempty(item.curves) + labels = choices.empty; + return +end +position = find(labels == string(requestedCurve), 1); +if ~isempty(position) + curveIndex = position - 1; +end +sourceIndex = max(1, curveIndex); +curve = item.curves(sourceIndex); +columns = string(curve.headers(curve.numericMask)); +if isempty(columns) + columns = choices.empty; +end +end + +function [values, columns] = effectiveAxes(parameters, columns, choices) +columns = reshape(string(columns), 1, []); +if isempty(columns) + columns = choices.empty; +end +defaults = csc.analysisRun.defaultPlotSelections(cellstr(columns)); +values = struct(); +for id = ["topX", "topY", "bottomX", "bottomY"] + requested = string(parameters.(id)); + if ~any(columns == requested) + requested = string(defaults.(id)); + end + if strlength(requested) == 0 + requested = columns(1); + end + values.(id) = requested; +end +end + +function [data, columns, analysis, readout] = comparisonPresentation( ... + item, curveIndex, parameters, choices) +columns = string(csc.analysisRun.cycleResultsColumnNames(parameters.mode)); +data = cell(0, numel(columns)); +analysis = struct(); +readout = emptyReadout(); +if isempty(item) + return +end +options = comparisonOptions(item, parameters); +results = csc.resultFiles.buildResultsTable(item, options); +data = csc.analysisRun.cycleResultsTableData(results, parameters.mode); +if curveIndex == 0 + readout.qctText = "See all-cycle table"; + readout.qcvText = "See all-cycle table"; + readout.diffText = "See all-cycle table"; + readout.relText = "See all-cycle table"; + readout.dtErrText = maxDtErrorText(results); + readout.statusText = sprintf( ... + "Showing %d cycle result(s).", height(results)); + return +end +analysis = csc.analysisRun.computeCSC( ... + item.curves(curveIndex), options); +readout = csc.analysisRun.comparisonReadout( ... + analysis, choices.modes( ... + find(choices.modes == string(parameters.mode), 1))); +end + +function model = axisModel(item, curveIndex, values, parameters, axisId, analysis) +model = struct( ... + "valid", false, "axisId", axisId, ... + "title", upperFirst(axisId) + " Plot", ... + "curves", struct([]), "curveIndex", 0, ... + "xSelection", values.(axisId + "X"), ... + "ySelection", values.(axisId + "Y"), ... + "showGrid", logical(parameters.(axisId + "Grid")), ... + "holdPlot", logical(parameters.(axisId + "Hold")), ... + "showTrim", logical(parameters.(axisId + "Trim")), ... + "analysis", analysis, "curveIndices", []); +if isempty(item) || isempty(item.curves) + return +end +model.valid = true; +model.curves = item.curves; +model.curveIndex = curveIndex; +model.curveIndices = plottedCurveIndices( ... + numel(item.curves), parameters.ignoreEdgeCycles); +end + +function options = comparisonOptions(item, parameters) +options = struct( ... + "mode", char(parameters.mode), ... + "scanRate", item.scanRate, ... + "area_cm2", parameters.area, ... + "ignoreEdgeCycles", logical(parameters.ignoreEdgeCycles)); +end + +function value = emptyReadout() +value = struct("qctText", "", "qcvText", "", "diffText", "", ... + "relText", "", "dtErrText", "", "statusText", "Ready"); +end + +function text = maxDtErrorText(results) +text = ""; +if ~isempty(results) && height(results) > 0 && ... + any(isfinite(results.DtError_s)) + text = sprintf("max %.12e s", ... + max(results.DtError_s, [], "omitnan")); +end +end + +function indices = plottedCurveIndices(count, ignoreEdges) +indices = 1:count; +if ignoreEdges && count > 0 + indices = indices(indices ~= 1 & indices ~= count); +end +end + +function value = itemPath(item) +value = ""; +if ~isempty(item) && isfield(item, "filepath") + value = string(item.filepath); +end +end + +function value = scanRateText(item) +value = ""; +if isempty(item) + return +end +if isfinite(item.scanRate) + value = sprintf("%.6f V/s (%.3f mV/s)", ... + item.scanRate, item.scanRate * 1000); +else + value = "Not found"; +end +end + +function index = selectedIndex(selection, count) +index = 0; +if count > 0 + index = 1; + if ~isempty(selection.Indices) + index = min(max(1, selection.Indices(1)), count); + end +end +end + +function text = upperFirst(value) +text = char(value); +text(1) = upper(text(1)); +text = string(text); +end diff --git a/apps/electrochem/csc/+csc/createSession.m b/apps/electrochem/csc/+csc/createSession.m index 417232581..62d0e62d1 100644 --- a/apps/electrochem/csc/+csc/createSession.m +++ b/apps/electrochem/csc/+csc/createSession.m @@ -1,18 +1,16 @@ -%CREATESESSION Rebuild transient CSC curves and active selection. -% Expected caller: Runtime V2 through csc.definition. Decoded DTA curves and -% file/curve selection remain outside the durable project. -function session = createSession(project) - items = csc.sourceFiles.loadProjectItems(project.inputs.sources); - currentIndex = 0; - choices = csc.userInterface.analysisChoices(); - currentCurve = choices.empty; - if ~isempty(items) - currentIndex = 1; - currentCurve = choices.allCycles; - end - session = struct( ... - "selection", struct( ... - "currentIndex", currentIndex, ... - "currentCurve", currentCurve), ... - "cache", struct("items", items)); +% App-owned implementation for csc.createSession within the csc product workflow. +function session = createSession(project, context) +%CREATESESSION Rebuild transient CSC curves from portable source references. +arguments + project (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end +paths = context.resolveSourcePaths(project.inputs.sources); +items = csc.sourceFiles.loadProjectItems(paths); +fileSelection = labkit.app.event.ListSelection( ... + Indices=1:min(1, numel(paths))); +choices = csc.analysisRun.analysisChoices(); +session = struct("selection", struct( ... + "files", fileSelection, "currentCurve", choices.allCycles), ... + "cache", struct("items", items)); end diff --git a/apps/electrochem/csc/+csc/definition.m b/apps/electrochem/csc/+csc/definition.m index b951f3df6..d5be11605 100644 --- a/apps/electrochem/csc/+csc/definition.m +++ b/apps/electrochem/csc/+csc/definition.m @@ -1,23 +1,12 @@ -% App-owned runtime definition for labkit_CSC_app. Expected caller: the -% public app entrypoint. Output is a declarative LabKit app definition; side -% effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_CSC_app", ... - "Id", "csc", ... - "Title", "Gamry DTA GUI (literature CSC)", ... - "DisplayName", "CSC", ... - "Family", "Electrochem", ... - "AppVersion", "1.4.8", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "dta", ">=2.0 <3"), ... - "Project", csc.projectSpec(), ... - "CreateSession", @csc.createSession, ... - "Layout", @csc.userInterface.buildWorkbenchLayout, ... - "Actions", csc.definitionActions(), ... - "Present", @csc.userInterface.presentWorkbench, ... - "Renderers", struct("cscAxis", ... - @csc.userInterface.renderCscAxis), ... - "DebugSample", @csc.debug.writeSamplePack); +%DEFINE Declare CSC's explicit App SDK contract. +function app = definition() +app = labkit.app.Definition( ... + Entrypoint="labkit_CSC_app", AppId="csc", ... + Title="Gamry DTA GUI (literature CSC)", DisplayName="CSC", ... + Family="Electrochem", AppVersion="1.5.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2", "dta", ">=2.0 <3"), ... + ProjectSchema=csc.projectSpec(), CreateSession=@csc.createSession, ... + Workbench=csc.workbench.buildLayout(), ... + PresentWorkbench=@csc.workbench.present, ... + BuildDebugSample=@csc.debug.writeSamplePack); end diff --git a/apps/electrochem/csc/+csc/definitionActions.m b/apps/electrochem/csc/+csc/definitionActions.m deleted file mode 100644 index f2302de77..000000000 --- a/apps/electrochem/csc/+csc/definitionActions.m +++ /dev/null @@ -1,302 +0,0 @@ -% App-owned Runtime V2 action table for CSC. Handlers receive canonical -% state/events/services and own DTA sources, selection, reload, and exports. -function actions = definitionActions() - actions = struct( ... - "openFilesChosen", @onOpenFilesChosen, ... - "removeSelected", @onRemoveSelected, ... - "clearAll", @onClearAll, ... - "reloadSelected", @onReloadSelected, ... - "exportResults", @onExportResults, ... - "exportVoltageCurrent", @onExportVoltageCurrent, ... - "fileSelectionChanged", @onFileSelectionChanged); -end - -function state = onOpenFilesChosen(state, event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - if isempty(paths) - state = services.workflow.log(state, "Open file canceled."); - return; - end - - firstFailure = struct("filepath", "", "message", ""); - hasFailure = false; - for k = 1:numel(paths) - filepath = paths(k); - if isLoaded(state.session.cache.items, filepath) - state = services.workflow.log(state, ... - "Skipped duplicate: " + filepath); - continue; - end - [item, status] = labkit.dta.loadFile(filepath, "cvct"); - if ~status.ok - if ~hasFailure - firstFailure = struct( ... - "filepath", filepath, ... - "message", string(status.message)); - hasFailure = true; - end - state = services.workflow.log(state, ... - "Failed to load " + filepath + ": " + string(status.message)); - continue; - end - state.session.cache.items = appendItem( ... - state.session.cache.items, item); - state.session.selection.currentIndex = ... - numel(state.session.cache.items); - state = logLoadedItem(state, item, services); - end - state = reconcileProjectSources(state, services); - state = resetCurrentView(state); - state = clearExportReferences(state); - if hasFailure - services.dialogs.alert(sprintf('Failed to load:\n%s\n\n%s', ... - firstFailure.filepath, firstFailure.message), 'Load error'); - end -end - -function state = onRemoveSelected(state, event, services) - items = state.session.cache.items; - indices = services.events.indices(event, "removedFiles", numel(items)); - if isempty(indices) - return; - end - removed = string({items(indices).filepath}); - state.session.cache.items(indices) = []; - state = reconcileProjectSources(state, services); - state.session.selection.currentIndex = boundedIndex( ... - state.session.selection.currentIndex, numel(state.session.cache.items)); - state = resetCurrentView(state); - state = clearExportReferences(state); - for k = 1:numel(removed) - state = services.workflow.log(state, "Removed: " + removed(k)); - end -end - -function state = onClearAll(state, ~, services) - state.session.cache.items = struct([]); - state = reconcileProjectSources(state, services); - state.session.selection.currentIndex = 0; - state = resetCurrentView(state); - state = clearExportReferences(state); - state = services.workflow.log(state, "Cleared all files."); -end - -function state = onReloadSelected(state, ~, services) - index = boundedIndex(state.session.selection.currentIndex, ... - numel(state.session.cache.items)); - if index == 0 - services.dialogs.alert('No file selected.', 'Reload'); - state = services.workflow.log(state, ... - "Reload failed: no file selected."); - return; - end - filepath = string(state.session.cache.items(index).filepath); - [item, status] = labkit.dta.loadFile(filepath, "cvct"); - if ~status.ok - services.dialogs.alert(status.message, 'Reload'); - state = services.workflow.log(state, ... - "Reload failed: " + filepath + " | " + string(status.message)); - return; - end - state.session.cache.items(index) = item; - state = resetCurrentView(state); - state = clearExportReferences(state); - state = services.workflow.log(state, "Reloaded: " + filepath); -end - -function state = onFileSelectionChanged(state, event, services) - indices = services.events.indices(event, "selectedFiles", ... - numel(state.session.cache.items)); - if isempty(indices) - state.session.selection.currentIndex = 0; - else - state.session.selection.currentIndex = indices(1); - end - state = resetCurrentView(state); -end - -function state = onExportResults(state, ~, services) - items = state.session.cache.items; - if isempty(items) - services.dialogs.alert('No results to export.', 'Export'); - return; - end - [out, cancelled] = services.dialogs.outputFile( ... - 'csc_all_cycles.csv', 'Save all-cycle CSC CSV', ... - 'csc_all_cycles.csv'); - if cancelled - state = services.workflow.log(state, "Result export cancelled."); - return; - end - parameters = state.project.parameters; - opts = struct( ... - "mode", char(parameters.mode), ... - "area_cm2", parameters.area, ... - "ignoreEdgeCycles", logical(parameters.ignoreEdgeCycles)); - [ok, message] = csc.resultFiles.writeResultsCSV(items, out, opts); - if ~ok - services.dialogs.alert(message, 'Export'); - return; - end - [folder, name, extension] = fileparts(out); - output = services.results.output("cscResults", "primary", ... - string(name) + string(extension), "text/csv"); - spec = resultSpec(state, output, "csc_all_cycles.labkit.json"); - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.lastResultsExport = struct( ... - "csvPath", string(out), "manifestPath", string(manifestPath)); - state = services.workflow.log(state, ... - "Exported CSC CSV: " + string(out)); -end - -function state = onExportVoltageCurrent(state, ~, services) - items = state.session.cache.items; - if isempty(items) - services.dialogs.alert( ... - 'No voltage/current data to export.', 'Export'); - return; - end - [out, cancelled] = services.dialogs.outputFile( ... - 'csc_cv_data.csv', 'Export CV data CSV', 'csc_cv_data.csv'); - if cancelled - state = services.workflow.log(state, ... - "Voltage/current export cancelled."); - return; - end - opts = struct("ignoreEdgeCycles", ... - logical(state.project.parameters.ignoreEdgeCycles)); - [ok, message, info] = ... - csc.resultFiles.writeVoltageCurrentCSV(items, out, opts); - if ~ok - services.dialogs.alert(message, 'Export'); - return; - end - outputs = outputRecords(info.files, services); - folder = fileparts(char(info.files(1))); - spec = resultSpec(state, outputs, "csc_cv_data.labkit.json"); - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.lastVoltageCurrentExport = struct( ... - "csvPaths", string(info.files), ... - "manifestPath", string(manifestPath)); - if isscalar(info.files) - message = sprintf('Exported CV data CSV: %s (%d voltage rows)', ... - info.files(1), info.rows); - else - message = sprintf( ... - 'Exported %d CV data CSV files in %s (%d voltage rows)', ... - numel(info.files), folder, info.rows); - end - state = services.workflow.log(state, message); -end - -function spec = resultSpec(state, outputs, manifestName) - spec = struct( ... - "Outputs", outputs, ... - "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, ... - "Summary", struct("fileCount", ... - numel(state.session.cache.items)), ... - "ManifestName", manifestName); -end - -function outputs = outputRecords(files, services) - records = cell(numel(files), 1); - for k = 1:numel(files) - [~, name, extension] = fileparts(files(k)); - records{k} = services.results.output( ... - "cvData" + string(k), outputRole(k), ... - string(name) + string(extension), "text/csv"); - end - outputs = vertcat(records{:}); -end - -function role = outputRole(index) - if index == 1 - role = "primary"; - else - role = "additional"; - end -end - -function state = resetCurrentView(state) - choices = csc.userInterface.analysisChoices(); - index = boundedIndex(state.session.selection.currentIndex, ... - numel(state.session.cache.items)); - state.session.selection.currentIndex = index; - if index == 0 - state.session.selection.currentCurve = choices.empty; - defaults = struct( ... - "topX", choices.empty, "topY", choices.empty, ... - "bottomX", choices.empty, "bottomY", choices.empty); - else - item = state.session.cache.items(index); - state.session.selection.currentCurve = choices.allCycles; - columns = numericColumns(item.curves, choices.empty); - defaults = csc.userInterface.defaultPlotSelections(cellstr(columns)); - end - fields = ["topX", "topY", "bottomX", "bottomY"]; - for field = fields - value = string(defaults.(field)); - if strlength(value) == 0 - value = choices.empty; - end - state.project.parameters.(field) = value; - end -end - -function columns = numericColumns(curves, emptyChoice) - if isempty(curves) - columns = emptyChoice; - return; - end - columns = string(curves(1).headers(curves(1).numericMask)); - if isempty(columns) - columns = emptyChoice; - end -end - -function state = clearExportReferences(state) - state.project.results.lastResultsExport = []; - state.project.results.lastVoltageCurrentExport = []; -end - -function state = logLoadedItem(state, item, services) - for k = 1:numel(item.logmsg) - state = services.workflow.log(state, item.logmsg{k}); - end - state = services.workflow.log(state, ... - "Loaded: " + string(item.filepath)); -end - -function tf = isLoaded(items, filepath) - tf = ~isempty(items) && ... - any(string({items.filepath}) == string(filepath)); -end - -function items = appendItem(items, item) - if isempty(items) - items = item; - else - items(end + 1) = item; - end -end - -function state = reconcileProjectSources(state, services) - paths = strings(0, 1); - if ~isempty(state.session.cache.items) - paths = string({state.session.cache.items.filepath}).'; - end - state.project.inputs.sources = services.project.reconcileSources( ... - state.project.inputs.sources, paths, "cvct", "dta", true); -end - -function index = boundedIndex(index, count) - if count == 0 - index = 0; - else - index = min(max(1, round(double(index))), count); - end -end diff --git a/apps/electrochem/csc/+csc/projectSpec.m b/apps/electrochem/csc/+csc/projectSpec.m index 4f4e6ba4a..b41c47e9c 100644 --- a/apps/electrochem/csc/+csc/projectSpec.m +++ b/apps/electrochem/csc/+csc/projectSpec.m @@ -2,18 +2,15 @@ % Expected caller: csc.definition. Output owns the current payload version, % creation defaults, and validation. Side effects are none. function spec = projectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", []); + spec = labkit.app.project.Schema(Version=1, ... + Create=@createProject, Validate=@validateProject); end function project = createProject() - choices = csc.userInterface.analysisChoices(); + choices = csc.analysisRun.analysisChoices(); project = struct(); project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + struct([])); project.parameters = struct( ... "mode", choices.modes(1), ... "area", "", ... @@ -42,7 +39,7 @@ fields = ["mode", "area", "ignoreEdgeCycles", ... "topX", "topY", "topGrid", "topHold", "topTrim", ... "bottomX", "bottomY", "bottomGrid", "bottomHold", "bottomTrim"]; - choices = csc.userInterface.analysisChoices(); + choices = csc.analysisRun.analysisChoices(); assert(isstruct(p) && isscalar(p) && ... all(isfield(p, cellstr(fields))) && ... any(string(p.mode) == choices.modes) && textScalar(p.area) && ... diff --git a/apps/electrochem/csc/labkit_CSC_app.m b/apps/electrochem/csc/labkit_CSC_app.m index aa1d72d47..38a7a359e 100644 --- a/apps/electrochem/csc/labkit_CSC_app.m +++ b/apps/electrochem/csc/labkit_CSC_app.m @@ -20,6 +20,5 @@ % Optional normalization % CSC = Q / area (cm^2); both charge and normalized CSC are shown. % - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @csc.definition, varargin{:}); + [varargout{1:nargout}] = csc.definition().launch(varargin{:}); end diff --git a/apps/electrochem/eis/+eis/+analysisRun/valuesForAxis.m b/apps/electrochem/eis/+eis/+analysisRun/valuesForAxis.m index e7beb4888..28ce32db5 100644 --- a/apps/electrochem/eis/+eis/+analysisRun/valuesForAxis.m +++ b/apps/electrochem/eis/+eis/+analysisRun/valuesForAxis.m @@ -1,9 +1,9 @@ -% Expected caller: eis.userInterface.plotOverlay, eis.resultFiles, and unit +% Expected caller: eis.overlayPlot.plotOverlay, eis.resultFiles, and unit % tests. Inputs are an EIS item struct and axis label. Output is the selected % numeric vector. No side effects. function values = valuesForAxis(item, axisName) - items = eis.userInterface.axisItems(); + items = eis.overlayPlot.axisItems(); switch axisName case char(items(1)) values = itemField(item, 'freq_Hz', 'Freq'); diff --git a/apps/electrochem/eis/+eis/+debug/writeSamplePack.m b/apps/electrochem/eis/+eis/+debug/writeSamplePack.m index 944d0c82b..d84c06a39 100644 --- a/apps/electrochem/eis/+eis/+debug/writeSamplePack.m +++ b/apps/electrochem/eis/+eis/+debug/writeSamplePack.m @@ -1,37 +1,30 @@ -% Expected caller: eis.definitionActions startup action and unit tests. Input is a +% Expected caller: EIS debug launch and unit tests. Input is a % LabKit debug context. Output is a deterministic synthetic EIS DTA sample % pack. Side effects: writes anonymous debug input files and records a session % manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write EIS debug ZCURVE DTA files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "eis"); - dtaFolder = fullfile(char(folders.sampleFolder), "dta"); - ensureFolder(dtaFolder); - - eisPath = string(fullfile(dtaFolder, "eis_zcurve_debug.DTA")); - sparsePath = string(fullfile(dtaFolder, "eis_zcurve_sparse_valid_debug.DTA")); - malformedPath = string(fullfile(dtaFolder, "eis_malformed_missing_zcurve_debug.DTA")); + eisPath = sampleContext.samplePath("eis/representative.DTA"); + sparsePath = sampleContext.samplePath("eis/sparse.DTA"); + malformedPath = sampleContext.samplePath("eis/malformed.DTA"); writeTextFile(eisPath, eisText()); writeTextFile(sparsePath, eisText(struct("Sparse", true))); writeTextFile(malformedPath, malformedEisText()); - pack = struct( ... - "sampleFolder", folders.sampleFolder, ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", eisPath, ... - "boundaryFiles", struct("validEdge", sparsePath, "malformed", malformedPath)); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_EIS_app", ... - "description", "Anonymous ZCURVE DTA boundary pack for EIS debug launch.", ... - "inputs", struct( ... - "representativeEisDta", eisPath, ... - "validEdgeEisDta", sparsePath, ... - "malformedEisDta", malformedPath), ... - "outputFolder", folders.outputFolder); - pack.manifest = manifest; - recordManifest(debugLog, manifest); + project = eis.projectSpec().Create(); + project.inputs.sources = sampleContext.sourceRecord( ... + "dta1", "eis", eisPath, true); + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-impedance", InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact("representative", "eis", eisPath), ... + sampleContext.artifact("sparse", "boundaryInput", sparsePath), ... + sampleContext.artifact("malformed", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function text = eisText(opts) @@ -81,28 +74,6 @@ text = join(lines, newline) + newline; end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 @@ -112,12 +83,6 @@ function writeTextFile(filepath, text) fprintf(fid, "%s", char(text)); end -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end - function value = tab() value = char(9); end diff --git a/apps/electrochem/eis/+eis/+userInterface/axisItems.m b/apps/electrochem/eis/+eis/+overlayPlot/axisItems.m similarity index 100% rename from apps/electrochem/eis/+eis/+userInterface/axisItems.m rename to apps/electrochem/eis/+eis/+overlayPlot/axisItems.m diff --git a/apps/electrochem/eis/+eis/+userInterface/axisModeForSelection.m b/apps/electrochem/eis/+eis/+overlayPlot/axisModeForSelection.m similarity index 92% rename from apps/electrochem/eis/+eis/+userInterface/axisModeForSelection.m rename to apps/electrochem/eis/+eis/+overlayPlot/axisModeForSelection.m index c602e1004..3baa4c4d3 100644 --- a/apps/electrochem/eis/+eis/+userInterface/axisModeForSelection.m +++ b/apps/electrochem/eis/+eis/+overlayPlot/axisModeForSelection.m @@ -3,7 +3,7 @@ % needed for that pairing. function mode = axisModeForSelection(xName, yName, logX, logY) - items = eis.userInterface.axisItems(); + items = eis.overlayPlot.axisItems(); if nargin < 3 logX = false; end diff --git a/apps/electrochem/eis/+eis/+overlayPlot/draw.m b/apps/electrochem/eis/+eis/+overlayPlot/draw.m new file mode 100644 index 000000000..b5d814e8b --- /dev/null +++ b/apps/electrochem/eis/+eis/+overlayPlot/draw.m @@ -0,0 +1,25 @@ +% Expected caller: registered EIS plot renderer. Inputs are one axes +% and a prepared overlay model. Side effects are limited to supplied graphics. +function draw(axesById, model) + ax = axesById.main; + if model.hasItems + eis.overlayPlot.plotOverlay(ax, model.items, model.options); + return; + end + p = model.options; + labkit.app.plot.clearAxes(ax, ResetScale=true); + ax.XScale = scaleName(p.logX); + ax.YScale = scaleName(p.logY); + axis(ax, 'normal'); + title(ax, 'EIS Overlay'); + xlabel(ax, eis.overlayPlot.labelForAxis(p.xName)); + ylabel(ax, eis.overlayPlot.labelForAxis(p.yName)); +end + +function value = scaleName(useLog) + if useLog + value = 'log'; + else + value = 'linear'; + end +end diff --git a/apps/electrochem/eis/+eis/+userInterface/labelForAxis.m b/apps/electrochem/eis/+eis/+overlayPlot/labelForAxis.m similarity index 100% rename from apps/electrochem/eis/+eis/+userInterface/labelForAxis.m rename to apps/electrochem/eis/+eis/+overlayPlot/labelForAxis.m diff --git a/apps/electrochem/eis/+eis/+userInterface/plotOverlay.m b/apps/electrochem/eis/+eis/+overlayPlot/plotOverlay.m similarity index 90% rename from apps/electrochem/eis/+eis/+userInterface/plotOverlay.m rename to apps/electrochem/eis/+eis/+overlayPlot/plotOverlay.m index 5e5fce32b..b529926c3 100644 --- a/apps/electrochem/eis/+eis/+userInterface/plotOverlay.m +++ b/apps/electrochem/eis/+eis/+overlayPlot/plotOverlay.m @@ -1,4 +1,4 @@ -% Expected callers: the Runtime V2 axis renderer and plot export. Inputs are +% Expected callers: the App SDK axis renderer and plot export. Inputs are % an axes, EIS items, and plot options. Output is legend labels. Side effects % are limited to redrawing axes. @@ -8,7 +8,7 @@ end opts = fillPlotOptions(opts); - labkit.ui.plot.clear(ax, "ResetScale", true); + labkit.app.plot.clearAxes(ax, ResetScale=true); axis(ax, 'normal'); cmap = lines(numel(items)); @@ -35,7 +35,9 @@ % are still negative makes MATLAB warn before the redraw can refit them. ax.XScale = ternary(opts.logX, 'log', 'linear'); ax.YScale = ternary(opts.logY, 'log', 'linear'); - labkit.ui.plot.fit(ax, plottedLines(isgraphics(plottedLines))); + if any(isgraphics(plottedLines)) + axis(ax, 'tight'); + end xlabel(ax, labelForAxis(opts.xName)); ylabel(ax, labelForAxis(opts.yName)); @@ -54,7 +56,7 @@ legend(ax, 'off'); end - if eis.userInterface.axisModeForSelection(opts.xName, opts.yName, ... + if eis.overlayPlot.axisModeForSelection(opts.xName, opts.yName, ... opts.logX, opts.logY) == "equal" axis(ax, 'equal'); end @@ -65,7 +67,7 @@ end function opts = fillPlotOptions(opts) - items = eis.userInterface.axisItems(); + items = eis.overlayPlot.axisItems(); if ~isfield(opts, 'xName') opts.xName = char(items(5)); end diff --git a/apps/electrochem/eis/+eis/+resultFiles/buildExportTable.m b/apps/electrochem/eis/+eis/+resultFiles/buildExportTable.m index d820586a1..abf54aa24 100644 --- a/apps/electrochem/eis/+eis/+resultFiles/buildExportTable.m +++ b/apps/electrochem/eis/+eis/+resultFiles/buildExportTable.m @@ -1,4 +1,4 @@ -% Expected caller: eis.definitionActions and export tests. Inputs are EIS item +% Expected caller: EIS result export and export tests. Inputs are EIS item % structs, axis labels, and log flags. Output is the stable EIS export table. % No file side effects. diff --git a/apps/electrochem/eis/+eis/+resultFiles/exportCurrentPlot.m b/apps/electrochem/eis/+eis/+resultFiles/exportCurrentPlot.m new file mode 100644 index 000000000..58f986789 --- /dev/null +++ b/apps/electrochem/eis/+eis/+resultFiles/exportCurrentPlot.m @@ -0,0 +1,30 @@ +% App-owned implementation for eis.resultFiles.exportCurrentPlot within the eis product workflow. +function state = exportCurrentPlot(state, context) +%EXPORTCURRENTPLOT Write the selected EIS X/Y overlay data and result package. +arguments + state (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end +indices = state.session.selection.files.Indices; +items = state.session.cache.items; +indices = indices(indices <= numel(items)); +items = items(indices); +if isempty(items) + context.alert("No files selected for export.", "Export"); + return +end +chosen = context.chooseOutputFile(["*.csv" "CSV files (*.csv)"], pwd); +if chosen.Cancelled + return +end +path = string(chosen.Value); +p = state.project.parameters; +tableValue = eis.resultFiles.buildExportTable(items, p.xName, p.yName, p.logX, p.logY); +writetable(tableValue, path); +[folder, base, extension] = fileparts(path); +output = labkit.app.result.File("eisPlotData", "primary", string(base) + string(extension), MediaType="text/csv"); +package = labkit.app.result.Package(Outputs={output}, Inputs=struct("sources", state.project.inputs.sources), Parameters=p, Summary=struct("fileCount", numel(items))); +written = context.writeResultPackage(folder, package); +state.project.results.lastExport = struct("csvPath", path, "manifestPath", string(written.Value)); +context.appendStatus("Exported CSV: " + path); +end diff --git a/apps/electrochem/eis/+eis/+userInterface/buildSummary.m b/apps/electrochem/eis/+eis/+sourceFiles/buildSummary.m similarity index 100% rename from apps/electrochem/eis/+eis/+userInterface/buildSummary.m rename to apps/electrochem/eis/+eis/+sourceFiles/buildSummary.m diff --git a/apps/electrochem/eis/+eis/+sourceFiles/loadProjectItems.m b/apps/electrochem/eis/+eis/+sourceFiles/loadProjectItems.m index 8d5e0d9b9..77710680e 100644 --- a/apps/electrochem/eis/+eis/+sourceFiles/loadProjectItems.m +++ b/apps/electrochem/eis/+eis/+sourceFiles/loadProjectItems.m @@ -1,9 +1,9 @@ % Expected caller: EIS session creation. Input is resolved EIS source records. % Output is decoded EIS items; invalid required sources raise an app error. -function items = loadProjectItems(sources) +function items = loadProjectItems(paths) items = struct([]); - paths = labkit.ui.runtime.sourcePaths(sources); - for k = 1:numel(sources) + paths = string(paths); + for k = 1:numel(paths) filepath = paths(k); [item, status] = labkit.dta.loadFile(filepath, "eis"); if ~status.ok diff --git a/apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m b/apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 4b201d668..000000000 --- a/apps/electrochem/eis/+eis/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,121 +0,0 @@ -% Expected caller: eis.definition. Inputs are axis labels and a callback struct -% whose fields are app-owned callback handles. Output is a data-only UI 5 -% workbench layout for the EIS Overlay app. -function layout = buildWorkbenchLayout(callbacks) - - axisItems = cellstr(eis.userInterface.axisItems()); - - layout = labkit.ui.layout.workbench("eisOverlay", ... - "Gamry EIS Multi-DTA Plot GUI", ... - "controlTabs", controlTabs(axisItems, callbacks), ... - "workspace", plotWorkspace(axisItems), ... - "usage", usageLines()); -end - -function tabs = controlTabs(axisItems, callbacks) - tabs = {filesAnalysisTab(axisItems, callbacks), summaryResultsTab(), logTab()}; -end - -function tab = filesAnalysisTab(axisItems, callbacks) - tab = labkit.ui.layout.tab("filesAnalysis", "Files + Analysis", { ... - filesSection(callbacks), ... - plotOptionsSection(axisItems, callbacks)}); -end - -function tab = summaryResultsTab() - tab = labkit.ui.layout.tab("summaryResults", "Summary + Results", { ... - summarySection()}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log")})}); -end - -function section = filesSection(callbacks) - section = labkit.ui.layout.section("filesSection", "Files", { ... - labkit.ui.layout.filePanel("files", "Files", ... - "selectionMode", "multiple", ... - "chooseLabel", "Add DTA files", ... - "clearLabel", "Clear all", ... - "filters", {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... - "status", "No files loaded", ... - "onChoose", callbacks.openFilesChosen, ... - "onRemove", callbacks.removeSelected, ... - "onClear", callbacks.clearAll, ... - "onSelectionChange", callbacks.selectionChanged), ... - labkit.ui.layout.group("fileActions", "", { ... - labkit.ui.layout.action("exportPlot", ... - "Export current plot CSV", callbacks.exportCSV)})}); -end - -function section = plotOptionsSection(axisItems, ~) - section = labkit.ui.layout.section("plotOptions", "Plot Options", { ... - labkit.ui.layout.field("xAxis", "X axis:", ... - "kind", "dropdown", ... - "items", axisItems, ... - "value", axisItems{5}, ... - "Bind", "project.parameters.xName"), ... - labkit.ui.layout.field("yAxis", "Y axis:", ... - "kind", "dropdown", ... - "items", axisItems, ... - "value", axisItems{7}, ... - "Bind", "project.parameters.yName"), ... - labkit.ui.layout.panner("lineWidth", "Line width:", ... - "value", 1.4, ... - "limits", [0.1 10], ... - "step", 0.1, ... - "Bind", "project.parameters.lineWidth"), ... - labkit.ui.layout.panner("markerSize", "Marker size:", ... - "value", 6, ... - "limits", [1 20], ... - "step", 1, ... - "Bind", "project.parameters.markerSize"), ... - labkit.ui.layout.field("showMarkers", "Show markers", ... - "kind", "checkbox", ... - "value", true, ... - "Bind", "project.parameters.showMarkers"), ... - labkit.ui.layout.field("logX", "Log X", ... - "kind", "checkbox", ... - "value", false, ... - "Bind", "project.parameters.logX"), ... - labkit.ui.layout.field("logY", "Log Y", ... - "kind", "checkbox", ... - "value", false, ... - "Bind", "project.parameters.logY"), ... - labkit.ui.layout.field("showLegend", "Legend", ... - "kind", "checkbox", ... - "value", true, ... - "Bind", "project.parameters.showLegend"), ... - labkit.ui.layout.field("showGrid", "Grid", ... - "kind", "checkbox", ... - "value", true, ... - "Bind", "project.parameters.showGrid")}); -end - -function lines = usageLines() - lines = { ... - '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.'}; -end - -function section = summarySection() - section = labkit.ui.layout.section("summarySection", "Summary", { ... - labkit.ui.layout.statusPanel("summary", "Summary", ... - "value", {'No files loaded.'})}); -end - -function workspace = plotWorkspace(axisItems) - workspace = labkit.ui.layout.workspace("plotWorkspace", "Plot", { ... - labkit.ui.layout.previewArea("plot", "EIS Overlay", ... - "layout", "single", ... - "axisIds", {'overlay'}, ... - "axisTitles", {'EIS Overlay'}, ... - "xLabels", axisItems(5), ... - "yLabels", axisItems(7))}); -end diff --git a/apps/electrochem/eis/+eis/+userInterface/presentWorkbench.m b/apps/electrochem/eis/+eis/+userInterface/presentWorkbench.m deleted file mode 100644 index a97eee771..000000000 --- a/apps/electrochem/eis/+eis/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,55 +0,0 @@ -% Expected caller: Runtime V2. Input is canonical EIS state. Output is a -% deterministic files/summary/overlay model with no UI access or side effects. -function view = presentWorkbench(state) - items = state.session.cache.items; - selected = selectedItems(items, state.session.selection.paths); - view = struct(); - view.controls.files = filePanelSpec( ... - items, state.session.selection.paths); - if isempty(items) - summary = {'No files loaded.'}; - elseif isempty(selected) - summary = {'No files selected.'}; - else - summary = eis.userInterface.buildSummary(selected); - end - view.controls.summary = struct("Value", {summary}); - view.previews.plot.Axes.overlay = struct( ... - "Renderer", "overlay", ... - "Model", axisModel(selected, state.project.parameters)); -end - -function model = axisModel(items, parameters) - model = struct( ... - "items", items, ... - "options", parameters, ... - "hasItems", ~isempty(items)); -end - -function spec = filePanelSpec(items, selectedPaths) - files = struct("id", {}, "path", {}, "status", {}); - for k = 1:numel(items) - files(end + 1) = struct( ... - "id", "item" + string(k), ... - "path", string(items(k).filepath), "status", ""); - end - selection = strings(0, 1); - if ~isempty(items) - paths = string({items.filepath}); - indices = find(ismember(paths, selectedPaths(:))); - selection = "item" + string(indices(:)); - end - status = "No files loaded"; - if ~isempty(items) - status = string(numel(items)) + " file(s) loaded"; - end - spec = struct("Files", files, "Selection", selection, "Status", status); -end - -function items = selectedItems(items, selectedPaths) - if isempty(items) - return; - end - keep = ismember(string({items.filepath}), selectedPaths(:)); - items = items(keep); -end diff --git a/apps/electrochem/eis/+eis/+userInterface/renderOverlayAxis.m b/apps/electrochem/eis/+eis/+userInterface/renderOverlayAxis.m deleted file mode 100644 index 97de9b748..000000000 --- a/apps/electrochem/eis/+eis/+userInterface/renderOverlayAxis.m +++ /dev/null @@ -1,24 +0,0 @@ -% Expected caller: registered EIS Runtime V2 renderer. Inputs are one axes -% and a prepared overlay model. Side effects are limited to supplied graphics. -function renderOverlayAxis(ax, model) - if model.hasItems - eis.userInterface.plotOverlay(ax, model.items, model.options); - return; - end - p = model.options; - labkit.ui.plot.clear(ax, "ResetScale", true); - ax.XScale = scaleName(p.logX); - ax.YScale = scaleName(p.logY); - axis(ax, 'normal'); - title(ax, 'EIS Overlay'); - xlabel(ax, eis.userInterface.labelForAxis(p.xName)); - ylabel(ax, eis.userInterface.labelForAxis(p.yName)); -end - -function value = scaleName(useLog) - if useLog - value = 'log'; - else - value = 'linear'; - end -end diff --git a/apps/electrochem/eis/+eis/+workbench/buildLayout.m b/apps/electrochem/eis/+eis/+workbench/buildLayout.m new file mode 100644 index 000000000..cd4a7c785 --- /dev/null +++ b/apps/electrochem/eis/+eis/+workbench/buildLayout.m @@ -0,0 +1,51 @@ +% App-owned implementation for eis.workbench.buildLayout within the eis product workflow. +function layout = buildLayout() +%BUILDLAYOUT Assemble EIS file-bound overlay controls. +axes = eis.overlayPlot.axisItems(); +files = labkit.app.layout.fileList("files", Label="Files", ... + Filters=["*.DTA;*.dta", "Gamry DTA (*.DTA)", "*.*", "All files"], ... + ChooseLabel="Add DTA files", FolderLabel="Add folder", ... + ChooseTooltip="Add Gamry DTA files containing EIS ZCURVE impedance data.", ... + RecursiveFolderLabel="Add folder tree", ... + RemoveLabel="Remove selected", ClearLabel="Clear all", ... + EmptyText="No files loaded", ... + Bind="project.inputs.sources", SelectionBind="session.selection.files", ... + SourceRole="eis", SourceIdPrefix="dta"); +filesSection = labkit.app.layout.section("filesSection", "Files", { ... + files, ... + labkit.app.layout.button("exportPlot", "Export current plot CSV", ... + @eis.resultFiles.exportCurrentPlot, ... + Tooltip="Export the currently selected impedance X/Y quantities for every loaded EIS file on a shared row index.")}); +plotOptions = labkit.app.layout.section("plotOptions", "Plot Options", { ... + labkit.app.layout.field("xAxis", Label="X axis", Kind="choice", Choices=axes, Bind="project.parameters.xName"), ... + labkit.app.layout.field("yAxis", Label="Y axis", Kind="choice", Choices=axes, Bind="project.parameters.yName"), ... + labkit.app.layout.slider("lineWidth", Label="Line width", Limits=[.1 10], Step=.1, Bind="project.parameters.lineWidth"), ... + labkit.app.layout.slider("markerSize", Label="Marker size", Limits=[1 20], Step=1, Bind="project.parameters.markerSize"), ... + labkit.app.layout.field("showMarkers", Label="Show markers", Kind="logical", Bind="project.parameters.showMarkers"), ... + labkit.app.layout.field("logX", Label="Log X", Kind="logical", Bind="project.parameters.logX"), ... + labkit.app.layout.field("logY", Label="Log Y", Kind="logical", Bind="project.parameters.logY"), ... + labkit.app.layout.field("showLegend", Label="Legend", Kind="logical", Bind="project.parameters.showLegend"), ... + labkit.app.layout.field("showGrid", Label="Grid", Kind="logical", Bind="project.parameters.showGrid")}); +summary = labkit.app.layout.section("summarySection", "Summary", { ... + labkit.app.layout.statusPanel("summary", Title="Summary")}); +layout = labkit.app.layout.workbench({ ... + labkit.app.layout.tab("filesAnalysis", "Files + Analysis", {filesSection, plotOptions}), ... + labkit.app.layout.tab("summaryResults", "Summary + Results", {summary}), ... + labkit.app.layout.tab("log", "Log", {labkit.app.layout.logPanel("appLog")})}, ... + Workspace=labkit.app.layout.workspace( ... + labkit.app.layout.plotArea("plot", @eis.overlayPlot.draw, ... + Title="EIS Overlay", AxisIds="main", ... + AxisTitles="EIS Overlay", XLabels=axes(5), YLabels=axes(7)), ... + Title="Plot"), ... + Usage=usageLines()); +end + +function lines = usageLines() +lines = [ ... + "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."]; +end diff --git a/apps/electrochem/eis/+eis/+workbench/present.m b/apps/electrochem/eis/+eis/+workbench/present.m new file mode 100644 index 000000000..996000208 --- /dev/null +++ b/apps/electrochem/eis/+eis/+workbench/present.m @@ -0,0 +1,9 @@ +% App-owned implementation for eis.workbench.present within the eis product workflow. +function view = present(state) +model = struct("items", state.session.cache.items, "options", state.project.parameters, "hasItems", ~isempty(state.session.cache.items)); +summary = "No files loaded."; +if model.hasItems + summary = string(numel(model.items)) + " file(s) loaded."; +end +view = labkit.app.view.Snapshot().text("summary", summary).renderPlot("plot", model); +end diff --git a/apps/electrochem/eis/+eis/createSession.m b/apps/electrochem/eis/+eis/createSession.m index b866a0930..6366eb99b 100644 --- a/apps/electrochem/eis/+eis/createSession.m +++ b/apps/electrochem/eis/+eis/createSession.m @@ -1,13 +1,13 @@ -%CREATESESSION Rebuild transient EIS curves and selected source paths. -% Expected caller: Runtime V2 through eis.definition. Decoded ZCURVE data and -% selection remain outside the durable project. -function session = createSession(project) - items = eis.sourceFiles.loadProjectItems(project.inputs.sources); - selectedPaths = strings(0, 1); - if ~isempty(items) - selectedPaths = string({items.filepath}).'; - end - session = struct( ... - "selection", struct("paths", selectedPaths), ... - "cache", struct("items", items)); +% App-owned implementation for eis.createSession within the eis product workflow. +function session = createSession(project, context) +%CREATESESSION Rebuild EIS overlay curves from portable source references. +arguments + project (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end +paths = context.resolveSourcePaths(project.inputs.sources); +items = eis.sourceFiles.loadProjectItems(paths); +selection = labkit.app.event.ListSelection(Indices=1:numel(paths)); +session = struct("selection", struct("files", selection), ... + "cache", struct("items", items)); end diff --git a/apps/electrochem/eis/+eis/definition.m b/apps/electrochem/eis/+eis/definition.m index e4394eb19..f385d9270 100644 --- a/apps/electrochem/eis/+eis/definition.m +++ b/apps/electrochem/eis/+eis/definition.m @@ -1,23 +1,12 @@ -% App-owned runtime definition for labkit_EIS_app. Expected caller: the -% public app entrypoint. Output is a declarative LabKit app definition; side -% effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_EIS_app", ... - "Id", "eis", ... - "Title", "Gamry EIS Multi-DTA Plot GUI", ... - "DisplayName", "EIS", ... - "Family", "Electrochem", ... - "AppVersion", "1.4.7", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "dta", ">=2.0 <3"), ... - "Project", eis.projectSpec(), ... - "CreateSession", @eis.createSession, ... - "Layout", @eis.userInterface.buildWorkbenchLayout, ... - "Actions", eis.definitionActions(), ... - "Present", @eis.userInterface.presentWorkbench, ... - "Renderers", struct("overlay", ... - @eis.userInterface.renderOverlayAxis), ... - "DebugSample", @eis.debug.writeSamplePack); +%DEFINE Declare EIS Overlay's explicit App SDK contract. +function app = definition() +app = labkit.app.Definition( ... + Entrypoint="labkit_EIS_app", AppId="eis", ... + Title="Gamry EIS Multi-DTA Plot GUI", DisplayName="EIS Overlay", ... + Family="Electrochem", AppVersion="1.5.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2", "dta", ">=2.0 <3"), ... + ProjectSchema=eis.projectSpec(), CreateSession=@eis.createSession, ... + Workbench=eis.workbench.buildLayout(), ... + PresentWorkbench=@eis.workbench.present, ... + BuildDebugSample=@eis.debug.writeSamplePack); end diff --git a/apps/electrochem/eis/+eis/definitionActions.m b/apps/electrochem/eis/+eis/definitionActions.m deleted file mode 100644 index e4dd57bc4..000000000 --- a/apps/electrochem/eis/+eis/definitionActions.m +++ /dev/null @@ -1,177 +0,0 @@ -% App-owned Runtime V2 action table for EIS Overlay. Handlers own source -% loading/removal, semantic selection, and plot-data export side effects. -function actions = definitionActions() - actions = struct( ... - "openFilesChosen", @onOpenFilesChosen, ... - "removeSelected", @onRemoveSelected, ... - "clearAll", @onClearAll, ... - "exportCSV", @onExportCSV, ... - "selectionChanged", @onSelectionChanged); -end - -function state = onOpenFilesChosen(state, event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - if isempty(paths) - state = services.workflow.log(state, "Open cancelled."); - return; - end - firstFailure = struct("filepath", "", "message", ""); - hasFailure = false; - for k = 1:numel(paths) - filepath = paths(k); - if isLoaded(state.session.cache.items, filepath) - state = services.workflow.log(state, ... - "Skipped already loaded: " + filepath); - continue; - end - [item, status] = labkit.dta.loadFile(filepath, "eis"); - if ~status.ok - if ~hasFailure - firstFailure = struct( ... - "filepath", filepath, ... - "message", string(status.message)); - hasFailure = true; - end - state = services.workflow.log(state, ... - "Failed: " + filepath + " | " + string(status.message)); - continue; - end - state.session.cache.items = appendItem( ... - state.session.cache.items, item); - state = logLoadedItem(state, item, services); - end - state = reconcileProjectSources(state, services); - state.session.selection.paths = ... - string({state.session.cache.items.filepath}).'; - state.project.results.lastExport = []; - if hasFailure - services.dialogs.alert(sprintf('Failed to load:\n%s\n\n%s', ... - firstFailure.filepath, firstFailure.message), 'Load error'); - end -end - -function state = onRemoveSelected(state, event, services) - paths = services.events.paths(event, "removedFiles"); - if isempty(paths) - return; - end - [state.session.cache.items, removed] = removeItems( ... - state.session.cache.items, paths); - state = reconcileProjectSources(state, services); - state.session.selection.paths = setdiff( ... - state.session.selection.paths, removed, 'stable'); - state.project.results.lastExport = []; - for k = 1:numel(removed) - state = services.workflow.log(state, "Removed: " + removed(k)); - end -end - -function state = onClearAll(state, ~, services) - state.project.results.lastExport = []; - state.session.cache.items = struct([]); - state = reconcileProjectSources(state, services); - state.session.selection.paths = strings(0, 1); - state = services.workflow.log(state, "Cleared all files."); -end - -function state = onSelectionChanged(state, event, services) - state.session.selection.paths = ... - services.events.paths(event, "selectedFiles"); -end - -function state = onExportCSV(state, ~, services) - items = selectedItems(state); - if isempty(items) - services.dialogs.alert('No files selected for export.', 'Export'); - return; - end - [out, cancelled] = services.dialogs.outputFile( ... - 'gamry_eis_plot_export.csv', 'Save current X/Y plot CSV', ... - 'gamry_eis_plot_export.csv'); - if cancelled - state = services.workflow.log(state, "Plot export cancelled."); - return; - end - p = state.project.parameters; - tableValue = eis.resultFiles.buildExportTable( ... - items, p.xName, p.yName, p.logX, p.logY); - writetable(tableValue, out); - [folder, name, extension] = fileparts(out); - output = services.results.output("eisPlotData", "primary", ... - string(name) + string(extension), "text/csv"); - spec = struct( ... - "Outputs", output, ... - "Inputs", selectedSources(state), ... - "Parameters", p, ... - "Summary", struct("fileCount", numel(items)), ... - "ManifestName", "gamry_eis_plot_export.labkit.json"); - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.lastExport = struct( ... - "csvPath", string(out), "manifestPath", string(manifestPath)); - state = services.workflow.log(state, "Exported CSV: " + string(out)); -end - -function items = selectedItems(state) - items = state.session.cache.items; - if isempty(items) - return; - end - keep = ismember(string({items.filepath}), ... - state.session.selection.paths(:)); - items = items(keep); -end - -function sources = selectedSources(state) - sources = state.project.inputs.sources; - if isempty(sources) - return; - end - paths = labkit.ui.runtime.sourcePaths(sources); - sources = sources(ismember(paths, state.session.selection.paths(:))); -end - -function state = logLoadedItem(state, item, services) - for k = 1:numel(item.logmsg) - state = services.workflow.log(state, item.logmsg{k}); - end - state = services.workflow.log(state, ... - string(item.name) + ": " + string(item.message)); - state = services.workflow.log(state, ... - "Loaded: " + string(item.filepath)); -end - -function tf = isLoaded(items, filepath) - tf = ~isempty(items) && ... - any(string({items.filepath}) == string(filepath)); -end - -function items = appendItem(items, item) - if isempty(items) - items = item; - else - items(end + 1) = item; - end -end - -function [items, removed] = removeItems(items, paths) - removed = strings(0, 1); - if isempty(items) - return; - end - itemPaths = string({items.filepath}); - keep = ~ismember(itemPaths, paths(:)); - removed = itemPaths(~keep).'; - items = items(keep); -end - -function state = reconcileProjectSources(state, services) - paths = strings(0, 1); - if ~isempty(state.session.cache.items) - paths = string({state.session.cache.items.filepath}).'; - end - state.project.inputs.sources = services.project.reconcileSources( ... - state.project.inputs.sources, paths, "eis", "dta", true); -end diff --git a/apps/electrochem/eis/+eis/projectSpec.m b/apps/electrochem/eis/+eis/projectSpec.m index 4b07cac28..07eade364 100644 --- a/apps/electrochem/eis/+eis/projectSpec.m +++ b/apps/electrochem/eis/+eis/projectSpec.m @@ -2,18 +2,15 @@ % Expected caller: eis.definition. Output owns the current payload version, % creation defaults, and validation. Side effects are none. function spec = projectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", []); + spec = labkit.app.project.Schema(Version=1, ... + Create=@createProject, Validate=@validateProject); end function project = createProject() - axes = eis.userInterface.axisItems(); + axes = eis.overlayPlot.axisItems(); project = struct(); project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + struct([])); project.parameters = struct( ... "xName", axes(5), "yName", axes(7), ... "lineWidth", 1.4, "markerSize", 6, ... @@ -30,7 +27,7 @@ p = project.parameters; fields = ["xName", "yName", "lineWidth", "markerSize", ... "showMarkers", "logX", "logY", "showLegend", "showGrid"]; - axes = eis.userInterface.axisItems(); + axes = eis.overlayPlot.axisItems(); assert(isstruct(p) && isscalar(p) && ... all(isfield(p, cellstr(fields))) && ... any(string(p.xName) == axes) && any(string(p.yName) == axes) && ... diff --git a/apps/electrochem/eis/labkit_EIS_app.m b/apps/electrochem/eis/labkit_EIS_app.m index 02369ebb1..575c86ce6 100644 --- a/apps/electrochem/eis/labkit_EIS_app.m +++ b/apps/electrochem/eis/labkit_EIS_app.m @@ -2,6 +2,5 @@ %LABKIT_EIS_APP EIS overlay/export app. % Single-file app that composes +labkit GUI/DTA APIs and owns EIS workflow choices. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @eis.definition, varargin{:}); + [varargout{1:nargout}] = eis.definition().launch(varargin{:}); end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/addResistanceITAnnotations.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/addResistanceITAnnotations.m similarity index 100% rename from apps/electrochem/vt_resistance/+vt_resistance/+userInterface/addResistanceITAnnotations.m rename to apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/addResistanceITAnnotations.m diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/addResistanceVTAnnotations.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/addResistanceVTAnnotations.m similarity index 100% rename from apps/electrochem/vt_resistance/+vt_resistance/+userInterface/addResistanceVTAnnotations.m rename to apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/addResistanceVTAnnotations.m diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/draw.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/draw.m new file mode 100644 index 000000000..ed594f95a --- /dev/null +++ b/apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/draw.m @@ -0,0 +1,119 @@ +% Expected caller: VT Resistance plot area renderer. Inputs are +% one axes and a prepared app-owned model. Side effects are limited to +% replacing graphics on the supplied axes. +function draw(axesById, model) +drawOne(axesById.top, model.top); +drawOne(axesById.bottom, model.bottom); +end + +function drawOne(ax, model) + labkit.app.plot.clearAxes(ax, "ResetScale", true); + if ~model.valid + title(ax, model.title); + if strlength(model.message) > 0 + text(ax, 0.5, 0.5, model.message, ... + 'Units', 'normalized', 'HorizontalAlignment', 'center'); + end + return; + end + + a = model.analysis; + c = plotCoordinates(a, model.xChoice); + choices = vt_resistance.analysisRun.analysisChoices(); + if model.yChoice == choices.yAxes(1) + lineHandle = plot(ax, c.x, a.Vf, 'LineWidth', 1.25, ... + 'Color', [0 0.4470 0.7410]); + yLabel = 'Vf (V vs Ref.)'; + plotTitle = sprintf('%s | VT | Ravg = %.6g ohm', ... + model.itemName, a.Ravg_abs_ohm); + isVoltage = true; + else + lineHandle = plot(ax, c.x, a.Im, 'LineWidth', 1.25, ... + 'Color', [0.8500 0.3250 0.0980]); + yLabel = 'Im (A)'; + plotTitle = sprintf('%s | IT | Ic %.4g A, Ia %.4g A', ... + model.itemName, a.Ic_est_A, a.Ia_est_A); + isVoltage = false; + end + labkit.app.plot.fitAxesToGraphics(ax, lineHandle); + hold(ax, 'on'); + if model.showShading + addShading(ax, c); + end + if model.showMarkers + addMarkers(ax, c); + if isVoltage + vt_resistance.analysisPlot.addResistanceVTAnnotations(ax, a, ... + c.cathBaseStart, c.cathBaseEnd, ... + c.anodBaseStart, c.anodBaseEnd, ... + c.cathSteadyStart, c.cathSteadyEnd, ... + c.anodSteadyStart, c.anodSteadyEnd, ... + c.cathStart, c.cathEnd, c.anodStart, c.anodEnd); + else + vt_resistance.analysisPlot.addResistanceITAnnotations(ax, a, ... + c.cathSteadyStart, c.cathSteadyEnd, ... + c.anodSteadyStart, c.anodSteadyEnd, ... + c.cathStart, c.cathEnd, c.anodStart, c.anodEnd); + end + end + hold(ax, 'off'); + title(ax, plotTitle, 'Interpreter', 'none'); + xlabel(ax, c.xLabel); + ylabel(ax, yLabel); + setGrid(ax, model.showGrid); +end + +function c = plotCoordinates(a, xChoice) + choices = vt_resistance.analysisRun.analysisChoices(); + useSamples = string(xChoice) == choices.xAxes(2); + c.x = a.t; + c.xLabel = choices.xAxes(1); + names = ["cathStart", "cathEnd", "anodStart", "anodEnd", ... + "cathBaseStart", "cathBaseEnd", "anodBaseStart", "anodBaseEnd", ... + "cathSteadyStart", "cathSteadyEnd", ... + "anodSteadyStart", "anodSteadyEnd"]; + times = [a.pulse.cath_start, a.pulse.cath_end, ... + a.pulse.anod_start, a.pulse.anod_end, ... + a.pulse.pre_start, a.pulse.pre_end, ... + a.anodBaselineStart, a.anodBaselineEnd, ... + a.cathSteadyStart, a.cathSteadyEnd, ... + a.anodSteadyStart, a.anodSteadyEnd]; + if useSamples + c.x = a.pt; + c.xLabel = choices.xAxes(2); + for k = 1:numel(times) + c.(names(k)) = vt_resistance.analysisRun.interp1Safe( ... + a.t, a.pt, times(k)); + end + else + for k = 1:numel(times) + c.(names(k)) = times(k); + end + end +end + +function addShading(ax, c) + vt_resistance.analysisPlot.shadeWindow(ax, c.cathStart, c.cathEnd, ... + [0.90 0.95 1.00], 0.12); + vt_resistance.analysisPlot.shadeWindow(ax, c.anodStart, c.anodEnd, ... + [1.00 0.94 0.88], 0.12); + vt_resistance.analysisPlot.shadeWindow(ax, ... + c.cathSteadyStart, c.cathSteadyEnd, [0.65 0.82 1.00], 0.22); + vt_resistance.analysisPlot.shadeWindow(ax, ... + c.anodSteadyStart, c.anodSteadyEnd, [1.00 0.75 0.55], 0.22); +end + +function addMarkers(ax, c) + xline(ax, c.cathStart, ':', 'Cath start', 'Color', [0.2 0.4 0.8]); + xline(ax, c.cathEnd, ':', 'Cath end', 'Color', [0.2 0.4 0.8]); + xline(ax, c.anodStart, ':', 'Anod start', 'Color', [0.8 0.4 0.2]); + xline(ax, c.anodEnd, ':', 'Anod end', 'Color', [0.8 0.4 0.2]); +end + +function setGrid(ax, enabled) + if enabled + grid(ax, 'on'); + else + grid(ax, 'off'); + end +end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/shadeWindow.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/shadeWindow.m similarity index 100% rename from apps/electrochem/vt_resistance/+vt_resistance/+userInterface/shadeWindow.m rename to apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/shadeWindow.m diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/analysisChoices.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/analysisChoices.m similarity index 100% rename from apps/electrochem/vt_resistance/+vt_resistance/+userInterface/analysisChoices.m rename to apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/analysisChoices.m diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/buildBatchTableData.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/buildBatchTableData.m new file mode 100644 index 000000000..202e39215 --- /dev/null +++ b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/buildBatchTableData.m @@ -0,0 +1,49 @@ +% Expected caller: VT resistance presenter and unit tests. Input is item structs. +% Output is the stable UI table cell data. No file side effects. + +function C = buildBatchTableData(items) +%BUILDBATCHTABLEDATA Build VT resistance uitable data. + + C = cell(numel(items), 9); + for i = 1:numel(items) + item = items(i); + C{i, 1} = itemName(item); + A = itemAnalysis(item); + if isempty(A) || ~isfield(A, 'ok') || ~A.ok + C{i, 2} = NaN; + C{i, 3} = NaN; + C{i, 4} = NaN; + C{i, 5} = NaN; + C{i, 6} = NaN; + C{i, 7} = NaN; + C{i, 8} = NaN; + C{i, 9} = 'parse/analyze failed'; + continue; + end + + C{i, 2} = A.Ic_est_A; + C{i, 3} = A.Ia_est_A; + C{i, 4} = A.Vc_ss_V; + C{i, 5} = A.Va_ss_V; + C{i, 6} = A.Rc_abs_ohm; + C{i, 7} = A.Ra_abs_ohm; + C{i, 8} = A.Ravg_abs_ohm; + C{i, 9} = A.detectMode; + end +end + +function name = itemName(item) + if isfield(item, 'name') + name = item.name; + else + name = ''; + end +end + +function A = itemAnalysis(item) + if isfield(item, 'analysis') + A = item.analysis; + else + A = []; + end +end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/computeResistance.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/computeResistance.m index eb170df45..08c856ca5 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/computeResistance.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/computeResistance.m @@ -192,7 +192,7 @@ A.Rc_dV_ohm = safeDivide(A.dVc_V, A.Ic_est_A); A.Ra_dV_ohm = safeDivide(A.dVa_V, A.Ia_est_A); - choices = vt_resistance.userInterface.analysisChoices(); + choices = vt_resistance.analysisRun.analysisChoices(); if string(A.voltageMode) == choices.voltageModes(2) A.Rc_ohm = A.Rc_raw_ohm; A.Ra_ohm = A.Ra_raw_ohm; @@ -214,7 +214,7 @@ end function opts = fillResistanceOptions(opts) - choices = vt_resistance.userInterface.analysisChoices(); + choices = vt_resistance.analysisRun.analysisChoices(); if ~isfield(opts, 'windowMode') opts.windowMode = choices.steadyWindows(1); end @@ -262,7 +262,7 @@ function [t1, t2] = selectSteadyWindow(p1, p2, modeText) t1 = p1; t2 = p2; - choices = vt_resistance.userInterface.analysisChoices(); + choices = vt_resistance.analysisRun.analysisChoices(); if string(modeText) == choices.steadyWindows(2) && ... isfinite(p1) && isfinite(p2) && p2 > p1 dt = p2 - p1; diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/formatDurationUs.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/formatDurationUs.m similarity index 83% rename from apps/electrochem/vt_resistance/+vt_resistance/+userInterface/formatDurationUs.m rename to apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/formatDurationUs.m index d2f93c081..85e842d75 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/formatDurationUs.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/formatDurationUs.m @@ -1,4 +1,4 @@ -% Expected caller: VT resistance app runner. Input is a duration in seconds. +% Expected caller: VT resistance summary builder. Input is seconds. % Output is the stable microsecond display text. No side effects. function txt = formatDurationUs(dt_s) diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/optionsFromParameters.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/optionsFromParameters.m index d5c7a2f79..bdf26cbd0 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/optionsFromParameters.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/optionsFromParameters.m @@ -1,4 +1,4 @@ -% Expected callers: VT Resistance actions and session reconstruction. Input is +% Expected callers: VT callbacks and session reconstruction. Input is % the durable parameter struct. Output is the calculation option contract. function opts = optionsFromParameters(parameters) opts = struct( ... diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/recomputeItems.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/recomputeItems.m index 2c99359e7..313f51e95 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/recomputeItems.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/recomputeItems.m @@ -1,4 +1,4 @@ -% Expected caller: VT resistance app actions and tests. Inputs are loaded DTA +% Expected caller: VT analysis callbacks and tests. Inputs are loaded DTA % item structs and one shared resistance option struct. Output contains every % item recomputed with exactly those options. Side effects are none. function items = recomputeItems(items, opts) diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/settingsChanged.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/settingsChanged.m new file mode 100644 index 000000000..641098123 --- /dev/null +++ b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/settingsChanged.m @@ -0,0 +1,17 @@ +% App-owned implementation for vt_resistance.analysisRun.settingsChanged within the vt_resistance product workflow. +function applicationState = settingsChanged( ... + applicationState, ~, callbackContext) +%SETTINGSCHANGED Recompute every loaded item with shared VT parameters. +items = applicationState.session.cache.items; +if isempty(items) + applicationState.project.results.lastExport = []; + return +end +options = vt_resistance.analysisRun.optionsFromParameters( ... + applicationState.project.parameters); +applicationState.session.cache.items = ... + vt_resistance.analysisRun.recomputeItems(items, options); +applicationState.project.results.lastExport = []; +callbackContext.appendStatus(sprintf( ... + "Reanalyzed %d loaded VT file(s).", numel(items))); +end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+debug/writeSamplePack.m b/apps/electrochem/vt_resistance/+vt_resistance/+debug/writeSamplePack.m index de7501cfb..17f998ed5 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+debug/writeSamplePack.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+debug/writeSamplePack.m @@ -1,37 +1,30 @@ -% Expected caller: vt_resistance.definitionActions startup action and unit tests. +% Expected caller: VT Resistance definition debug sample and unit tests. % Input is a LabKit debug context. Output is a deterministic synthetic chrono % DTA sample pack. Side effects: writes anonymous debug input files and records % a session manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write VT resistance debug chrono DTA files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "vt_resistance"); - dtaFolder = fullfile(char(folders.sampleFolder), "dta"); - ensureFolder(dtaFolder); - - currentPath = string(fullfile(dtaFolder, "chrono_vt_current_debug.DTA")); - weakPath = string(fullfile(dtaFolder, "chrono_vt_valid_low_resistance_debug.DTA")); - malformedPath = string(fullfile(dtaFolder, "chrono_vt_malformed_missing_table_debug.DTA")); + currentPath = sampleContext.samplePath("vt_resistance/representative.DTA"); + weakPath = sampleContext.samplePath("vt_resistance/low_resistance.DTA"); + malformedPath = sampleContext.samplePath("vt_resistance/malformed.DTA"); writeTextFile(currentPath, chronoText()); writeTextFile(weakPath, weakChronoText()); writeTextFile(malformedPath, malformedChronoText()); - pack = struct( ... - "sampleFolder", folders.sampleFolder, ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", currentPath, ... - "boundaryFiles", struct("validEdge", weakPath, "malformed", malformedPath)); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_VTResistance_app", ... - "description", "Anonymous chrono DTA boundary pack for resistance debug launch.", ... - "inputs", struct( ... - "representativeChronoDta", currentPath, ... - "validEdgeChronoDta", weakPath, ... - "malformedChronoDta", malformedPath), ... - "outputFolder", folders.outputFolder); - pack.manifest = manifest; - recordManifest(debugLog, manifest); + project = vt_resistance.projectSpec().Create(); + project.inputs.sources = sampleContext.sourceRecord( ... + "dta1", "chrono", currentPath, true); + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-resistance", InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact("representative", "chrono", currentPath), ... + sampleContext.artifact("lowResistance", "boundaryInput", weakPath), ... + sampleContext.artifact("malformed", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function text = weakChronoText() @@ -122,28 +115,6 @@ end end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 @@ -153,12 +124,6 @@ function writeTextFile(filepath, text) fprintf(fid, "%s", char(text)); end -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end - function value = tab() value = char(9); end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/buildResultsTable.m b/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/buildResultsTable.m index 3e50ec54a..2e90f44a6 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/buildResultsTable.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/buildResultsTable.m @@ -1,4 +1,4 @@ -% Expected caller: VT resistance app runner and export tests. Input is item +% Expected caller: VT resistance export and tests. Input is item % structs. Output is the stable VT resistance CSV result table. No file side % effects. diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/exportResults.m b/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/exportResults.m new file mode 100644 index 000000000..216566a82 --- /dev/null +++ b/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/exportResults.m @@ -0,0 +1,35 @@ +% App-owned implementation for vt_resistance.resultFiles.exportResults within the vt_resistance product workflow. +function applicationState = exportResults( ... + applicationState, callbackContext) +%EXPORTRESULTS Write the VT batch CSV and provenance manifest. +items = applicationState.session.cache.items; +if isempty(items) + callbackContext.alert("No results to export.", "Export"); + return +end +choice = callbackContext.chooseOutputFile( ... + ["*.csv", "CSV files"], "vt_steady_resistance_results.csv"); +if choice.Cancelled + callbackContext.appendStatus("VT result export cancelled."); + return +end +filepath = string(choice.Value); +[ok, message] = vt_resistance.resultFiles.writeResultsCSV(items, filepath); +if ~ok + callbackContext.alert(message, "Export"); + return +end +[folder, name, extension] = fileparts(filepath); +output = labkit.app.result.File("vtResistanceResults", "primary", ... + string(name) + string(extension), MediaType="text/csv"); +package = labkit.app.result.Package( ... + Outputs={output}, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct("fileCount", numel(items)), ... + ManifestName="vt_steady_resistance_results.labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.lastExport = struct( ... + "csvPath", filepath, "manifestPath", string(written.Value)); +callbackContext.appendStatus("Exported VT CSV: " + filepath); +end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/writeResultsCSV.m b/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/writeResultsCSV.m index 619777640..b3252d0d3 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/writeResultsCSV.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/writeResultsCSV.m @@ -1,4 +1,4 @@ -% Expected caller: VT resistance app runner and export tests. Inputs are item +% Expected caller: VT resistance export callback and tests. Inputs are item % structs and output filepath. Side effect is writing the stable VT CSV file. function [ok, msg] = writeResultsCSV(items, filepath) diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/loadItem.m b/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/loadItem.m index 6f05042b5..b03fe64a1 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/loadItem.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/loadItem.m @@ -1,5 +1,5 @@ % Expected callers: VT Resistance file-selection, project hydration, and -% export actions. Input is one DTA path plus durable parameters. Output is +% export callbacks. Input is one DTA path plus durable parameters. Output is % one decoded and analyzed chrono item plus the facade load status. function [item, status] = loadItem(filepath, parameters) [item, status] = labkit.dta.loadFile(filepath, "chrono"); diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/loadProjectItems.m b/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/loadProjectItems.m index 7d938a662..8d98f8dc7 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/loadProjectItems.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/loadProjectItems.m @@ -1,10 +1,10 @@ -% Expected caller: VT Resistance session creation. Inputs are resolved source -% records and durable parameters. Output is the rebuildable decoded and +% Expected caller: VT Resistance session creation. Inputs are resolved paths +% and durable parameters. Output is the rebuildable decoded and % analyzed DTA item vector; invalid required sources raise an app error. function items = loadProjectItems(sources, parameters) items = struct([]); opts = vt_resistance.analysisRun.optionsFromParameters(parameters); - paths = labkit.ui.runtime.sourcePaths(sources); + paths = string(sources); for k = 1:numel(sources) filepath = paths(k); [item, status] = labkit.dta.loadFile(filepath, "chrono"); diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildBatchTableData.m b/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildBatchTableData.m deleted file mode 100644 index ba45be716..000000000 --- a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildBatchTableData.m +++ /dev/null @@ -1,49 +0,0 @@ -% Expected caller: VT resistance app runner and unit tests. Input is item structs. -% Output is the stable UI table cell data. No file side effects. - -function C = buildBatchTableData(items) -%BUILDBATCHTABLEDATA Build VT resistance uitable data. - - C = cell(numel(items), 9); - for i = 1:numel(items) - item = items(i); - C{i, 1} = itemName(item); - A = itemAnalysis(item); - if isempty(A) || ~isfield(A, 'ok') || ~A.ok - C{i, 2} = NaN; - C{i, 3} = NaN; - C{i, 4} = NaN; - C{i, 5} = NaN; - C{i, 6} = NaN; - C{i, 7} = NaN; - C{i, 8} = NaN; - C{i, 9} = 'parse/analyze failed'; - continue; - end - - C{i, 2} = A.Ic_est_A; - C{i, 3} = A.Ia_est_A; - C{i, 4} = A.Vc_ss_V; - C{i, 5} = A.Va_ss_V; - C{i, 6} = A.Rc_abs_ohm; - C{i, 7} = A.Ra_abs_ohm; - C{i, 8} = A.Ravg_abs_ohm; - C{i, 9} = A.detectMode; - end -end - -function name = itemName(item) - if isfield(item, 'name') - name = item.name; - else - name = ''; - end -end - -function A = itemAnalysis(item) - if isfield(item, 'analysis') - A = item.analysis; - else - A = []; - end -end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m b/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 7caa62307..000000000 --- a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,165 +0,0 @@ -% Expected caller: vt_resistance.definition. Input is a callback struct whose -% fields are app-owned callback handles. Output is a data-only UI 5 workbench -% layout for the VT Resistance app. -function layout = buildWorkbenchLayout(callbacks) - - choices = vt_resistance.userInterface.analysisChoices(); - pulseModes = cellstr(choices.pulseModes); - steadyWindows = cellstr(choices.steadyWindows); - voltageModes = cellstr(choices.voltageModes); - xChoices = cellstr(choices.xAxes); - yChoices = cellstr(choices.yAxes); - - layout = labkit.ui.layout.workbench("vtResistance", ... - "Gamry VT Steady Resistance GUI", ... - "controlTabs", controlTabs(pulseModes, steadyWindows, voltageModes, ... - xChoices, yChoices, callbacks), ... - "workspace", plotsWorkspace()); -end - -function tabs = controlTabs(pulseModes, steadyWindows, voltageModes, ... - xChoices, yChoices, callbacks) - tabs = { ... - filesAnalysisTab(pulseModes, steadyWindows, voltageModes, ... - xChoices, yChoices, callbacks), ... - summaryResultsTab(), ... - logTab()}; -end - -function tab = filesAnalysisTab(pulseModes, steadyWindows, voltageModes, ... - xChoices, yChoices, callbacks) - tab = labkit.ui.layout.tab("filesAnalysis", "Files + Analysis", { ... - filesSection(callbacks), ... - analysisSettingsSection(pulseModes, steadyWindows, voltageModes), ... - plotDisplaySection(), ... - plotSelectionsSection(xChoices, yChoices)}); -end - -function tab = summaryResultsTab() - tab = labkit.ui.layout.tab("summaryResults", "Summary + Results", { ... - currentSummarySection(), ... - batchResultsSection()}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log")})}); -end - -function section = filesSection(callbacks) - section = labkit.ui.layout.section("filesSection", "Files", { ... - labkit.ui.layout.filePanel("files", "Files", ... - "selectionMode", "single", ... - "chooseLabel", "Add DTA files", ... - "clearLabel", "Clear all", ... - "filters", {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... - "status", "No files loaded", ... - "emptyText", "No files loaded", ... - "onChoose", callbacks.openFilesChosen, ... - "onRemove", callbacks.removeSelected, ... - "onClear", callbacks.clearAll, ... - "onSelectionChange", callbacks.fileSelectionChanged), ... - labkit.ui.layout.group("fileActions", "", { ... - labkit.ui.layout.action("exportResults", ... - "Export results CSV", callbacks.exportResults)})}); -end - -function section = analysisSettingsSection(pulseModes, steadyWindows, ... - voltageModes) - section = labkit.ui.layout.section("analysisSettings", "Analysis Settings", { ... - analysisChoice("pulseMode", "Pulse detection:", pulseModes, ... - pulseModes{1}), ... - analysisChoice("steadyWindow", "Steady window:", steadyWindows, ... - steadyWindows{1}), ... - analysisChoice("voltageMode", "Resistance voltage:", voltageModes, ... - voltageModes{1})}); -end - -function section = plotDisplaySection() - section = labkit.ui.layout.section("plotDisplay", "Plot Display", { ... - labkit.ui.layout.field("showMarkers", "Show markers", ... - "kind", "checkbox", ... - "value", true, ... - "Bind", "project.parameters.showMarkers"), ... - labkit.ui.layout.field("showShading", "Shade windows", ... - "kind", "checkbox", ... - "value", true, ... - "Bind", "project.parameters.showShading")}); -end - -function section = plotSelectionsSection(xChoices, yChoices) - section = labkit.ui.layout.section("plotSelections", "Plot Selections", { ... - axisChoice("topX", "Top X:", xChoices, xChoices{1}), ... - axisChoice("topY", "Top Y:", yChoices, yChoices{1}), ... - gridChoice("topGrid"), ... - axisChoice("bottomX", "Bottom X:", xChoices, xChoices{1}), ... - axisChoice("bottomY", "Bottom Y:", yChoices, yChoices{2}), ... - gridChoice("bottomGrid")}); -end - -function section = currentSummarySection() - section = labkit.ui.layout.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:")}); -end - -function section = batchResultsSection() - section = labkit.ui.layout.section("batchResults", "Batch Results", { ... - labkit.ui.layout.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))}); -end - -function workspace = plotsWorkspace() - workspace = labkit.ui.layout.workspace("plots", "Plots", { ... - labkit.ui.layout.previewArea("plotAxes", "Plots", ... - "layout", "stack", ... - "count", 2, ... - "axisIds", {'top', 'bottom'}, ... - "axisTitles", {'Top Plot', 'Bottom Plot'})}); -end - -function layoutNode = analysisChoice(id, labelText, items, value) - layoutNode = labkit.ui.layout.field(id, labelText, ... - "kind", "dropdown", ... - "items", items, ... - "value", value, ... - "Bind", "project.parameters." + id, ... - "Event", "analysisChanged", ... - "debounceMs", 0); -end - -function layoutNode = axisChoice(id, labelText, items, value) - layoutNode = labkit.ui.layout.field(id, labelText, ... - "kind", "dropdown", ... - "items", items, ... - "value", value, ... - "Bind", "project.parameters." + id); -end - -function layoutNode = gridChoice(id) - layoutNode = labkit.ui.layout.field(id, "Grid", ... - "kind", "checkbox", ... - "value", true, ... - "Bind", "project.parameters." + id); -end - -function layoutNode = summaryField(id, labelText) - layoutNode = labkit.ui.layout.field(id, labelText, ... - "kind", "readonly", ... - "value", "-"); -end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/presentWorkbench.m b/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/presentWorkbench.m deleted file mode 100644 index bbaa6894a..000000000 --- a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,184 +0,0 @@ -% Expected caller: Runtime V2. Input is canonical VT Resistance state. Output -% is one deterministic control/table/summary/two-axis presentation with no UI -% registry access or side effects. -function view = presentWorkbench(state) - items = state.session.cache.items; - sources = state.project.inputs.sources; - parameters = state.project.parameters; - index = boundedIndex(state.session.selection.currentIndex, numel(sources)); - itemIndex = loadedItemIndex(items, sources, index); - - view = struct(); - view.controls.files = filePanelSpec(sources, items, index); - view.controls.results = struct(); - view.controls.results.Data = ... - vt_resistance.userInterface.buildBatchTableData(items); - view = applySummary(view, currentSummary(items, itemIndex)); - view.previews.plotAxes.Axes.top = struct( ... - "Renderer", "resistanceAxis", ... - "Model", axisModel(items, itemIndex, parameters, "top")); - view.previews.plotAxes.Axes.bottom = struct( ... - "Renderer", "resistanceAxis", ... - "Model", axisModel(items, itemIndex, parameters, "bottom")); -end - -function spec = filePanelSpec(sources, items, index) - files = struct("id", {}, "path", {}, "status", {}); - paths = labkit.ui.runtime.sourcePaths(sources); - for k = 1:numel(paths) - files(end + 1) = struct( ... - "id", "item" + string(k), ... - "path", paths(k), ... - "status", pathStatus(items, paths(k))); - end - selection = strings(0, 1); - if index > 0 - selection = "item" + string(index); - end - status = "No files loaded"; - if ~isempty(paths) - status = string(numel(paths)) + " file(s) registered"; - end - spec = struct("Files", files, "Selection", selection, "Status", status); -end - -function value = pathStatus(items, filepath) - itemIndex = find(string({items.filepath}) == filepath, 1); - if isempty(itemIndex) - value = "deferred"; - else - value = analysisStatus(items(itemIndex)); - end -end - -function value = analysisStatus(item) - value = ""; - if isfield(item, 'analysis') && isstruct(item.analysis) && ... - isfield(item.analysis, 'ok') && ~item.analysis.ok - value = "analysis failed"; - end -end - -function summary = currentSummary(items, index) - fields = ["controlMode", "detect", "window", "cathIV", "anodIV", ... - "cathBase", "anodBase", "cathBaseWindow", "anodBaseWindow", ... - "cathR", "anodR", "averageR", "status"]; - summary = cell2struct(repmat({'-'}, 1, numel(fields)), ... - cellstr(fields), 2); - if index == 0 - return; - end - item = items(index); - summary.controlMode = chronoControlModeText(item); - if isempty(item.analysis) || ~item.analysis.ok - if ~isempty(item.analysis) && isfield(item.analysis, 'message') - summary.status = item.analysis.message; - else - summary.status = 'No valid analysis'; - end - return; - end - a = item.analysis; - summary.detect = sprintf('%s | %s', a.detectMode, a.detectMsg); - summary.window = sprintf('%s | %s', a.windowMode, a.voltageMode); - summary.cathIV = sprintf('I=%.6e A | Vss=%.6f V | dV=%.6f V', ... - a.Ic_est_A, a.Vc_ss_V, a.dVc_V); - summary.anodIV = sprintf('I=%.6e A | Vss=%.6f V | dV=%.6f V', ... - a.Ia_est_A, a.Va_ss_V, a.dVa_V); - summary.cathBase = sprintf('%.6f V', a.Vc_baseline_V); - summary.anodBase = sprintf('%.6f V', a.Va_baseline_V); - summary.cathBaseWindow = ... - vt_resistance.userInterface.formatDurationUs(a.cathBaselineWindow_s); - summary.anodBaseWindow = ... - vt_resistance.userInterface.formatDurationUs(a.anodBaselineWindow_s); - summary.cathR = sprintf('%.6g ohm (signed %.6g)', ... - a.Rc_abs_ohm, a.Rc_ohm); - summary.anodR = sprintf('%.6g ohm (signed %.6g)', ... - a.Ra_abs_ohm, a.Ra_ohm); - summary.averageR = sprintf('%.6g ohm', a.Ravg_abs_ohm); - summary.status = a.message; -end - -function view = applySummary(view, summary) - fields = string(fieldnames(summary)); - for k = 1:numel(fields) - view.controls.(fields(k)) = struct( ... - "Value", summary.(fields(k))); - end -end - -function out = chronoControlModeText(item) - out = 'Unknown chrono control mode'; - if ~isfield(item, 'controlMode') - return; - end - switch string(item.controlMode) - case "current" - out = 'Current-controlled chrono'; - case "voltage" - out = 'Voltage-controlled chrono'; - end -end - -function model = axisModel(items, index, parameters, whichAxis) - model = struct( ... - "valid", false, "message", "", "title", axisTitle(whichAxis), ... - "analysis", struct(), "itemName", "", "xChoice", "", ... - "yChoice", "", "showMarkers", logical(parameters.showMarkers), ... - "showShading", logical(parameters.showShading), ... - "showGrid", axisGrid(parameters, whichAxis)); - if index == 0 - return; - end - item = items(index); - if isempty(item.analysis) || ~item.analysis.ok - model.message = "No valid analysis"; - return; - end - model.valid = true; - model.analysis = item.analysis; - model.itemName = string(item.name); - if whichAxis == "top" - model.xChoice = string(parameters.topX); - model.yChoice = string(parameters.topY); - else - model.xChoice = string(parameters.bottomX); - model.yChoice = string(parameters.bottomY); - end -end - -function value = axisGrid(parameters, whichAxis) - if whichAxis == "top" - value = logical(parameters.topGrid); - else - value = logical(parameters.bottomGrid); - end -end - -function value = axisTitle(whichAxis) - if whichAxis == "top" - value = "Top Plot"; - else - value = "Bottom Plot"; - end -end - -function index = boundedIndex(index, count) - if count == 0 - index = 0; - else - index = min(max(1, round(double(index))), count); - end -end - -function itemIndex = loadedItemIndex(items, sources, sourceIndex) - itemIndex = 0; - if sourceIndex == 0 || isempty(items) - return; - end - paths = labkit.ui.runtime.sourcePaths(sources); - match = find(string({items.filepath}) == paths(sourceIndex), 1); - if ~isempty(match) - itemIndex = match; - end -end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/renderResistanceAxis.m b/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/renderResistanceAxis.m deleted file mode 100644 index 615b7be86..000000000 --- a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/renderResistanceAxis.m +++ /dev/null @@ -1,114 +0,0 @@ -% Expected caller: registered VT Resistance Runtime V2 renderer. Inputs are -% one axes and a prepared app-owned model. Side effects are limited to -% replacing graphics on the supplied axes. -function renderResistanceAxis(ax, model) - labkit.ui.plot.clear(ax, "ResetScale", true); - if ~model.valid - title(ax, model.title); - if strlength(model.message) > 0 - text(ax, 0.5, 0.5, model.message, ... - 'Units', 'normalized', 'HorizontalAlignment', 'center'); - end - return; - end - - a = model.analysis; - c = plotCoordinates(a, model.xChoice); - choices = vt_resistance.userInterface.analysisChoices(); - if model.yChoice == choices.yAxes(1) - lineHandle = plot(ax, c.x, a.Vf, 'LineWidth', 1.25, ... - 'Color', [0 0.4470 0.7410]); - yLabel = 'Vf (V vs Ref.)'; - plotTitle = sprintf('%s | VT | Ravg = %.6g ohm', ... - model.itemName, a.Ravg_abs_ohm); - isVoltage = true; - else - lineHandle = plot(ax, c.x, a.Im, 'LineWidth', 1.25, ... - 'Color', [0.8500 0.3250 0.0980]); - yLabel = 'Im (A)'; - plotTitle = sprintf('%s | IT | Ic %.4g A, Ia %.4g A', ... - model.itemName, a.Ic_est_A, a.Ia_est_A); - isVoltage = false; - end - labkit.ui.plot.fit(ax, lineHandle); - hold(ax, 'on'); - if model.showShading - addShading(ax, c); - end - if model.showMarkers - addMarkers(ax, c); - if isVoltage - vt_resistance.userInterface.addResistanceVTAnnotations(ax, a, ... - c.cathBaseStart, c.cathBaseEnd, ... - c.anodBaseStart, c.anodBaseEnd, ... - c.cathSteadyStart, c.cathSteadyEnd, ... - c.anodSteadyStart, c.anodSteadyEnd, ... - c.cathStart, c.cathEnd, c.anodStart, c.anodEnd); - else - vt_resistance.userInterface.addResistanceITAnnotations(ax, a, ... - c.cathSteadyStart, c.cathSteadyEnd, ... - c.anodSteadyStart, c.anodSteadyEnd, ... - c.cathStart, c.cathEnd, c.anodStart, c.anodEnd); - end - end - hold(ax, 'off'); - title(ax, plotTitle, 'Interpreter', 'none'); - xlabel(ax, c.xLabel); - ylabel(ax, yLabel); - setGrid(ax, model.showGrid); -end - -function c = plotCoordinates(a, xChoice) - choices = vt_resistance.userInterface.analysisChoices(); - useSamples = string(xChoice) == choices.xAxes(2); - c.x = a.t; - c.xLabel = choices.xAxes(1); - names = ["cathStart", "cathEnd", "anodStart", "anodEnd", ... - "cathBaseStart", "cathBaseEnd", "anodBaseStart", "anodBaseEnd", ... - "cathSteadyStart", "cathSteadyEnd", ... - "anodSteadyStart", "anodSteadyEnd"]; - times = [a.pulse.cath_start, a.pulse.cath_end, ... - a.pulse.anod_start, a.pulse.anod_end, ... - a.pulse.pre_start, a.pulse.pre_end, ... - a.anodBaselineStart, a.anodBaselineEnd, ... - a.cathSteadyStart, a.cathSteadyEnd, ... - a.anodSteadyStart, a.anodSteadyEnd]; - if useSamples - c.x = a.pt; - c.xLabel = choices.xAxes(2); - for k = 1:numel(times) - c.(names(k)) = vt_resistance.analysisRun.interp1Safe( ... - a.t, a.pt, times(k)); - end - else - for k = 1:numel(times) - c.(names(k)) = times(k); - end - end -end - -function addShading(ax, c) - vt_resistance.userInterface.shadeWindow(ax, c.cathStart, c.cathEnd, ... - [0.90 0.95 1.00], 0.12); - vt_resistance.userInterface.shadeWindow(ax, c.anodStart, c.anodEnd, ... - [1.00 0.94 0.88], 0.12); - vt_resistance.userInterface.shadeWindow(ax, ... - c.cathSteadyStart, c.cathSteadyEnd, [0.65 0.82 1.00], 0.22); - vt_resistance.userInterface.shadeWindow(ax, ... - c.anodSteadyStart, c.anodSteadyEnd, [1.00 0.75 0.55], 0.22); -end - -function addMarkers(ax, c) - xline(ax, c.cathStart, ':', 'Cath start', 'Color', [0.2 0.4 0.8]); - xline(ax, c.cathEnd, ':', 'Cath end', 'Color', [0.2 0.4 0.8]); - xline(ax, c.anodStart, ':', 'Anod start', 'Color', [0.8 0.4 0.2]); - xline(ax, c.anodEnd, ':', 'Anod end', 'Color', [0.8 0.4 0.2]); -end - -function setGrid(ax, enabled) - if enabled - grid(ax, 'on'); - else - grid(ax, 'off'); - end -end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m b/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m new file mode 100644 index 000000000..633d89b05 --- /dev/null +++ b/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m @@ -0,0 +1,89 @@ +% App-owned implementation for vt_resistance.workbench.buildLayout within the vt_resistance product workflow. +function layout = buildLayout() +%BUILDLAYOUT Compose VT Resistance's file, analysis, plot, and export flow. +choices = vt_resistance.analysisRun.analysisChoices(); +files = labkit.app.layout.fileList("files", Label="Files", ... + Filters=["*.DTA;*.dta", "Gamry DTA (*.DTA)"], SelectionMode="single", ... + ChooseLabel="Add DTA files", FolderLabel="Add folder", ... + ChooseTooltip="Add Gamry chrono DTA files containing pulse voltage and current traces for resistance analysis.", ... + RecursiveFolderLabel="Add folder tree", ... + RemoveLabel="Remove selected", ClearLabel="Clear all", ... + EmptyText="No files loaded", ... + Bind="project.inputs.sources", SelectionBind="session.selection.files", ... + SourceRole="chrono", SourceIdPrefix="dta"); + +analysis = labkit.app.layout.section("analysisSettings", ... + "Analysis Settings", { ... + choiceField("pulseMode", "Pulse detection:", choices.pulseModes, ... + @vt_resistance.analysisRun.settingsChanged), ... + choiceField("steadyWindow", "Steady window:", choices.steadyWindows, ... + @vt_resistance.analysisRun.settingsChanged), ... + choiceField("voltageMode", "Resistance voltage:", choices.voltageModes, ... + @vt_resistance.analysisRun.settingsChanged)}); +display = labkit.app.layout.section("plotDisplay", "Plot Display", { ... + logicalField("showMarkers", "Show markers"), ... + logicalField("showShading", "Shade windows")}); +plotSelections = labkit.app.layout.section("plotSelections", ... + "Plot Selections", { ... + choiceField("topX", "Top X:", choices.xAxes), ... + choiceField("topY", "Top Y:", choices.yAxes), ... + logicalField("topGrid", "Grid"), ... + choiceField("bottomX", "Bottom X:", choices.xAxes), ... + choiceField("bottomY", "Bottom Y:", choices.yAxes), ... + logicalField("bottomGrid", "Grid")}); +summary = labkit.app.layout.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:")}); +results = labkit.app.layout.section("batchResults", "Batch Results", { ... + labkit.app.layout.dataTable("results", Title="Batch Results")}); + +controls = { ... + labkit.app.layout.tab("filesAnalysis", "Files + Analysis", { ... + labkit.app.layout.section("filesSection", "Files", { ... + files, labkit.app.layout.button("exportResults", ... + "Export results CSV", @vt_resistance.resultFiles.exportResults, ... + Tooltip="Export cathodic, anodic, and averaged voltage-transient resistance results with detection diagnostics.")}), ... + analysis, display, plotSelections}), ... + labkit.app.layout.tab("summaryResults", "Summary + Results", { ... + summary, results}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.logPanel("appLog")})}; +plots = labkit.app.layout.plotArea("plotAxes", ... + @vt_resistance.analysisPlot.draw, Title="Plots", Layout="stack", ... + AxisIds=["top", "bottom"], AxisTitles=["Top Plot", "Bottom Plot"]); +layout = labkit.app.layout.workbench(controls, ... + Workspace=labkit.app.layout.workspace(plots, Title="Plots")); +end + +function node = choiceField(id, label, choices, callback) +arguments + id + label + choices + callback = [] +end +node = labkit.app.layout.field(id, Label=label, Kind="choice", ... + Choices=choices, Bind="project.parameters." + id, ... + OnValueChanged=callback); +end + +function node = logicalField(id, label) +node = labkit.app.layout.field(id, Label=label, Kind="logical", ... + Bind="project.parameters." + id); +end + +function node = summaryField(id, label) +node = labkit.app.layout.field(id, Label=label, Kind="readonly", Value="-"); +end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+workbench/present.m b/apps/electrochem/vt_resistance/+vt_resistance/+workbench/present.m new file mode 100644 index 000000000..c58adc168 --- /dev/null +++ b/apps/electrochem/vt_resistance/+vt_resistance/+workbench/present.m @@ -0,0 +1,111 @@ +% App-owned implementation for vt_resistance.workbench.present within the vt_resistance product workflow. +function view = present(applicationState) +%PRESENT Adapt VT state into its table, summary, and named plot models. +items = applicationState.session.cache.items; +parameters = applicationState.project.parameters; +selection = applicationState.session.selection.files; +index = selectedIndex(selection, numel(items)); +tableData = vt_resistance.analysisRun.buildBatchTableData(items); +summary = currentSummary(items, index); +model = struct("top", axisModel(items, index, parameters, "top"), ... + "bottom", axisModel(items, index, parameters, "bottom")); +columns = ["File", "Ic (A)", "Ia (A)", "Vc ss (V)", "Va ss (V)", ... + "R cath (ohm)", "R anod (ohm)", "R avg (ohm)", "Detection"]; +view = labkit.app.view.Snapshot() ... + .tableData("results", tableData, Columns=columns) ... + .renderPlot("plotAxes", model); +ids = string(fieldnames(summary)); +for k = 1:numel(ids) + view = view.text(ids(k), string(summary.(ids(k)))); +end +end + +function model = axisModel(items, index, p, whichAxis) +model = struct("valid", false, "message", "", ... + "title", upperFirst(whichAxis) + " Plot", ... + "analysis", struct(), "itemName", "", "xChoice", "", "yChoice", "", ... + "showMarkers", logical(p.showMarkers), ... + "showShading", logical(p.showShading), "showGrid", true); +if index == 0 || isempty(items(index).analysis) || ~items(index).analysis.ok + return +end +model.valid = true; +model.analysis = items(index).analysis; +model.itemName = string(items(index).name); +if whichAxis == "top" + model.xChoice = string(p.topX); + model.yChoice = string(p.topY); + model.showGrid = logical(p.topGrid); +else + model.xChoice = string(p.bottomX); + model.yChoice = string(p.bottomY); + model.showGrid = logical(p.bottomGrid); +end +end + +function value = upperFirst(value) +value = char(string(value)); +value(1) = upper(value(1)); +value = string(value); +end + +function index = selectedIndex(selection, count) +index = 0; +if count > 0 + index = 1; + if ~isempty(selection.Indices) + index = min(max(1, selection.Indices(1)), count); + end +end +end + +function summary = currentSummary(items, index) +names = ["controlMode", "detect", "window", "cathIV", "anodIV", ... + "cathBase", "anodBase", "cathBaseWindow", "anodBaseWindow", ... + "cathR", "anodR", "averageR", "status"]; +summary = cell2struct(repmat({'-'}, 1, numel(names)), cellstr(names), 2); +if index == 0 + return +end +item = items(index); +summary.controlMode = controlModeText(item); +if isempty(item.analysis) || ~item.analysis.ok + if ~isempty(item.analysis) && isfield(item.analysis, "message") + summary.status = item.analysis.message; + else + summary.status = "No valid analysis"; + end + return +end +a = item.analysis; +summary.detect = sprintf("%s | %s", a.detectMode, a.detectMsg); +summary.window = sprintf("%s | %s", a.windowMode, a.voltageMode); +summary.cathIV = sprintf("I=%.6e A | Vss=%.6f V | dV=%.6f V", ... + a.Ic_est_A, a.Vc_ss_V, a.dVc_V); +summary.anodIV = sprintf("I=%.6e A | Vss=%.6f V | dV=%.6f V", ... + a.Ia_est_A, a.Va_ss_V, a.dVa_V); +summary.cathBase = sprintf("%.6f V", a.Vc_baseline_V); +summary.anodBase = sprintf("%.6f V", a.Va_baseline_V); +summary.cathBaseWindow = ... + vt_resistance.analysisRun.formatDurationUs(a.cathBaselineWindow_s); +summary.anodBaseWindow = ... + vt_resistance.analysisRun.formatDurationUs(a.anodBaselineWindow_s); +summary.cathR = sprintf("%.6g ohm (signed %.6g)", ... + a.Rc_abs_ohm, a.Rc_ohm); +summary.anodR = sprintf("%.6g ohm (signed %.6g)", ... + a.Ra_abs_ohm, a.Ra_ohm); +summary.averageR = sprintf("%.6g ohm", a.Ravg_abs_ohm); +summary.status = a.message; +end + +function text = controlModeText(item) +text = "Unknown chrono control mode"; +if ~isfield(item, "controlMode") + return +end +if string(item.controlMode) == "current" + text = "Current-controlled chrono"; +elseif string(item.controlMode) == "voltage" + text = "Voltage-controlled chrono"; +end +end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/createSession.m b/apps/electrochem/vt_resistance/+vt_resistance/createSession.m index 1c45ad381..1024470e4 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/createSession.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/createSession.m @@ -1,22 +1,18 @@ -%CREATESESSION Rebuild the selected VT Resistance preview item lazily. -% Expected caller: Runtime V2 through vt_resistance.definition. Only the first -% source is decoded immediately; full-batch analysis remains export-time work. -function session = createSession(project) - items = struct([]); - currentIndex = 0; - if ~isempty(project.inputs.sources) - currentIndex = 1; - filepath = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources(1)); - [item, status] = vt_resistance.sourceFiles.loadItem( ... - filepath, project.parameters); - if ~status.ok - error('vt_resistance:SourceLoadFailed', ... - 'Could not load %s: %s', filepath, status.message); - end - items = item; - end - session = struct( ... - "selection", struct("currentIndex", currentIndex), ... - "cache", struct("items", items)); +% App-owned implementation for vt_resistance.createSession within the vt_resistance product workflow. +function session = createSession(project, context) +%CREATESESSION Rebuild VT Resistance's lazy selected preview. +arguments + project (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end +paths = strings(0, 1); +if ~isempty(project.inputs.sources) + paths = context.resolveSourcePaths(project.inputs.sources); +end +items = vt_resistance.sourceFiles.loadProjectItems( ... + paths, project.parameters); +selection = labkit.app.event.ListSelection( ... + Indices=1:min(1, numel(items))); +session = struct("selection", struct("files", selection), ... + "cache", struct("items", items)); end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/definition.m b/apps/electrochem/vt_resistance/+vt_resistance/definition.m index 565257e62..9c67f68a8 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/definition.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/definition.m @@ -1,23 +1,12 @@ -% App-owned runtime definition for labkit_VTResistance_app. Expected caller: -% the public app entrypoint. Output is a declarative LabKit app definition; -% side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_VTResistance_app", ... - "Id", "vt_resistance", ... - "Title", "Gamry VT Steady Resistance GUI", ... - "DisplayName", "VT Resistance", ... - "Family", "Electrochem", ... - "AppVersion", "1.4.7", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "dta", ">=2.0 <3"), ... - "Project", vt_resistance.projectSpec(), ... - "CreateSession", @vt_resistance.createSession, ... - "Layout", @vt_resistance.userInterface.buildWorkbenchLayout, ... - "Actions", vt_resistance.definitionActions(), ... - "Present", @vt_resistance.userInterface.presentWorkbench, ... - "Renderers", struct("resistanceAxis", ... - @vt_resistance.userInterface.renderResistanceAxis), ... - "DebugSample", @vt_resistance.debug.writeSamplePack); +%DEFINE Declare VT Resistance's explicit App SDK contract. +function app = definition() +app = labkit.app.Definition( ... + Entrypoint="labkit_VTResistance_app", AppId="vt_resistance", ... + Title="VT Steady Resistance", DisplayName="VT Resistance", ... + Family="Electrochem", AppVersion="1.5.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2", "dta", ">=2.0 <3"), ... + ProjectSchema=vt_resistance.projectSpec(), CreateSession=@vt_resistance.createSession, ... + Workbench=vt_resistance.workbench.buildLayout(), ... + PresentWorkbench=@vt_resistance.workbench.present, ... + BuildDebugSample=@vt_resistance.debug.writeSamplePack); end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m b/apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m deleted file mode 100644 index 6361444fe..000000000 --- a/apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m +++ /dev/null @@ -1,254 +0,0 @@ -% App-owned Runtime V2 action table for VT Resistance. Handlers receive -% canonical state/events/services and own DTA selection, resistance analysis, -% and result export without reading or mutating UI controls. -function actions = definitionActions() - actions = struct( ... - "openFilesChosen", @onOpenFilesChosen, ... - "removeSelected", @onRemoveSelected, ... - "clearAll", @onClearAll, ... - "exportResults", @onExportResults, ... - "fileSelectionChanged", @onFileSelectionChanged, ... - "analysisChanged", @onAnalysisChanged); -end - -function state = onOpenFilesChosen(state, event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - if isempty(paths) - state = services.workflow.log(state, "Open cancelled."); - return; - end - - existingPaths = labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources); - addedPaths = strings(numel(paths), 1); - added = 0; - for k = 1:numel(paths) - filepath = paths(k); - if any(existingPaths == filepath) || ... - any(addedPaths(1:added) == filepath) - state = services.workflow.log(state, ... - "Skipped duplicate: " + filepath); - continue; - end - added = added + 1; - addedPaths(added) = filepath; - state = services.workflow.log(state, "Registered: " + filepath); - end - addedPaths = addedPaths(1:added); - state.project.inputs.sources = services.project.reconcileSources( ... - state.project.inputs.sources, [existingPaths; addedPaths], ... - "chrono", "dta", true); - state.session.selection.currentIndex = numel(state.project.inputs.sources); - state = ensureCurrentItemLoaded(state, services); - state.project.parameters = resetPlotSelections(state.project.parameters); - state.project.results.lastExport = []; - if added > 1 - state = services.workflow.log(state, sprintf( ... - 'Deferred %d non-visible file(s) until selection or export.', ... - added - 1)); - end -end - -function state = onRemoveSelected(state, event, services) - sources = state.project.inputs.sources; - indices = services.events.indices(event, "removedFiles", numel(sources)); - if isempty(indices) - return; - end - removed = labkit.ui.runtime.sourcePaths(sources(indices)); - state.session.cache.items = removeItemsByPath( ... - state.session.cache.items, removed); - remainingPaths = labkit.ui.runtime.sourcePaths(sources); - remainingPaths(indices) = []; - state.project.inputs.sources = services.project.reconcileSources( ... - sources, remainingPaths, "chrono", "dta", true); - state.session.selection.currentIndex = boundedIndex( ... - state.session.selection.currentIndex, numel(state.project.inputs.sources)); - state.project.parameters = resetPlotSelections(state.project.parameters); - state.project.results.lastExport = []; - for k = 1:numel(removed) - state = services.workflow.log(state, "Removed: " + removed(k)); - end -end - -function state = onClearAll(state, ~, services) - state.project.inputs.sources = services.project.reconcileSources( ... - state.project.inputs.sources, strings(0, 1), ... - "chrono", "dta", true); - state.project.results.lastExport = []; - state.session.cache.items = struct([]); - state.session.selection.currentIndex = 0; - state.project.parameters = resetPlotSelections(state.project.parameters); - state = services.workflow.log(state, "Cleared all files."); -end - -function state = onFileSelectionChanged(state, event, services) - indices = services.events.indices(event, "selectedFiles", ... - numel(state.project.inputs.sources)); - if isempty(indices) - state.session.selection.currentIndex = 0; - else - state.session.selection.currentIndex = indices(1); - end - state = ensureCurrentItemLoaded(state, services); - state.project.parameters = resetPlotSelections(state.project.parameters); -end - -function state = onAnalysisChanged(state, ~, services) - state = analyzeAllFiles(state, services); -end - -function state = analyzeAllFiles(state, services) - if isempty(state.session.cache.items) - state.project.results.lastExport = []; - return; - end - opts = vt_resistance.analysisRun.optionsFromParameters( ... - state.project.parameters); - state.session.cache.items = vt_resistance.analysisRun.recomputeItems( ... - state.session.cache.items, opts); - state.project.results.lastExport = []; - state = services.workflow.log(state, sprintf( ... - 'Reanalyzed %d loaded file(s) with shared analysis settings.', ... - numel(state.session.cache.items))); -end - -function state = onExportResults(state, ~, services) - if isempty(state.project.inputs.sources) - services.dialogs.alert('No results to export.', 'Export'); - return; - end - [state, ok] = ensureAllItemsLoaded(state, services); - if ~ok - return; - end - state = analyzeAllFiles(state, services); - filename = 'vt_steady_resistance_results.csv'; - [out, cancelled] = services.dialogs.outputFile( ... - filename, 'Save results CSV', filename); - if cancelled - state = services.workflow.log(state, "Result export cancelled."); - return; - end - [ok, message] = vt_resistance.resultFiles.writeResultsCSV( ... - state.session.cache.items, out); - if ~ok - services.dialogs.alert(message, 'Export'); - return; - end - [folder, name, extension] = fileparts(out); - output = services.results.output("vtResistanceResults", "primary", ... - string(name) + string(extension), "text/csv"); - spec = struct( ... - "Outputs", output, ... - "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, ... - "Summary", struct("fileCount", numel(state.session.cache.items)), ... - "ManifestName", "vt_steady_resistance_results.labkit.json"); - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.lastExport = struct( ... - "csvPath", string(out), "manifestPath", string(manifestPath)); - state = services.workflow.log(state, "Exported CSV: " + string(out)); -end - -function state = logAnalysis(state, item, services) - analysis = item.analysis; - if analysis.ok - state = services.workflow.log(state, string(sprintf( ... - '%s: Rc=%.6g ohm, Ra=%.6g ohm, Ravg=%.6g ohm', ... - item.name, analysis.Rc_abs_ohm, analysis.Ra_abs_ohm, ... - analysis.Ravg_abs_ohm))); - elseif isfield(analysis, 'logOnFailure') && analysis.logOnFailure - state = services.workflow.log(state, ... - string(item.name) + ": " + string(analysis.message)); - end -end - -function parameters = resetPlotSelections(parameters) - choices = vt_resistance.userInterface.analysisChoices(); - parameters.topX = choices.xAxes(1); - parameters.topY = choices.yAxes(1); - parameters.topGrid = true; - parameters.bottomX = choices.xAxes(1); - parameters.bottomY = choices.yAxes(2); - parameters.bottomGrid = true; -end - -function index = boundedIndex(index, count) - if count == 0 - index = 0; - else - index = min(max(1, round(double(index))), count); - end -end - -function state = ensureCurrentItemLoaded(state, services) - index = boundedIndex(state.session.selection.currentIndex, ... - numel(state.project.inputs.sources)); - if index == 0 - return; - end - filepath = labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources(index)); - if itemIsLoaded(state.session.cache.items, filepath) - return; - end - [item, status] = vt_resistance.sourceFiles.loadItem( ... - filepath, state.project.parameters); - if ~status.ok - state = services.workflow.log(state, ... - "Failed to load " + filepath + ": " + string(status.message)); - services.dialogs.alert(sprintf('Failed to load:\n%s\n\n%s', ... - filepath, status.message), 'Load error'); - return; - end - state.session.cache.items = appendItem(state.session.cache.items, item); - for messageIndex = 1:numel(item.logmsg) - state = services.workflow.log(state, item.logmsg{messageIndex}); - end - state = logAnalysis(state, item, services); - state = services.workflow.log(state, "Loaded: " + filepath); -end - -function [state, ok] = ensureAllItemsLoaded(state, services) - ok = true; - paths = labkit.ui.runtime.sourcePaths(state.project.inputs.sources); - for k = 1:numel(paths) - if itemIsLoaded(state.session.cache.items, paths(k)) - continue; - end - [item, status] = vt_resistance.sourceFiles.loadItem( ... - paths(k), state.project.parameters); - if ~status.ok - ok = false; - state = services.workflow.log(state, ... - "Failed to load " + paths(k) + ": " + string(status.message)); - services.dialogs.alert(sprintf('Failed to load:\n%s\n\n%s', ... - paths(k), status.message), 'Load error'); - return; - end - state.session.cache.items = appendItem(state.session.cache.items, item); - end -end - -function tf = itemIsLoaded(items, filepath) - tf = ~isempty(items) && any(string({items.filepath}) == filepath); -end - -function items = removeItemsByPath(items, paths) - if isempty(items) - return; - end - items(ismember(string({items.filepath}), paths(:))) = []; -end - -function items = appendItem(items, item) - if isempty(items) - items = item; - else - items(end + 1) = item; - end -end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/projectSpec.m b/apps/electrochem/vt_resistance/+vt_resistance/projectSpec.m index 5ed0c0f3f..404321cd8 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/projectSpec.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/projectSpec.m @@ -2,18 +2,14 @@ % Expected caller: vt_resistance.definition. Output owns the current payload % version, creation defaults, and validation. Side effects are none. function spec = projectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", []); + spec = labkit.app.project.Schema(Version=1, ... + Create=@createProject, Validate=@validateProject); end function project = createProject() - choices = vt_resistance.userInterface.analysisChoices(); + choices = vt_resistance.analysisRun.analysisChoices(); project = struct(); - project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + project.inputs = struct("sources", struct([])); project.parameters = struct( ... "pulseMode", choices.pulseModes(1), ... "steadyWindow", choices.steadyWindows(1), ... @@ -35,7 +31,7 @@ assert(isfield(project.inputs, 'sources'), ... 'vt_resistance:InvalidProject', ... 'VT Resistance project sources are missing.'); - choices = vt_resistance.userInterface.analysisChoices(); + choices = vt_resistance.analysisRun.analysisChoices(); p = project.parameters; fields = ["pulseMode", "steadyWindow", "voltageMode", ... "showMarkers", "showShading", "topX", "topY", "topGrid", ... diff --git a/apps/electrochem/vt_resistance/labkit_VTResistance_app.m b/apps/electrochem/vt_resistance/labkit_VTResistance_app.m index 82b88b338..b8f411afc 100644 --- a/apps/electrochem/vt_resistance/labkit_VTResistance_app.m +++ b/apps/electrochem/vt_resistance/labkit_VTResistance_app.m @@ -10,6 +10,5 @@ % - Estimate steady phase voltage by median(Vf) in the same selected window. % - Compute baseline-corrected resistance as abs((Vss - Vbaseline) / Iss). - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @vt_resistance.definition, varargin{:}); + [varargout{1:nargout}] = vt_resistance.definition().launch(varargin{:}); end diff --git a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsChanged.m b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsChanged.m new file mode 100644 index 000000000..ac5ee01b1 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsChanged.m @@ -0,0 +1,13 @@ +% App-owned implementation for gait_analysis.analysisRun.optionsChanged within the gait_analysis product workflow. +function applicationState = optionsChanged(applicationState, ~, ~) +%OPTIONSCHANGED Sanitize options and invalidate derived gait results. +applicationState.project.parameters = ... + gait_analysis.analysisRun.sanitizeOptions( ... + applicationState.project.parameters); +result = gait_analysis.analysisRun.emptyResult(); +result.message = "Analysis options changed; rerun analysis."; +applicationState.project.results.analysis = result; +applicationState.project.results.lastExport = []; +applicationState.session.cache.lastRunFingerprint = ""; +applicationState.session.selection.currentStepIndex = 1; +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsLayoutSections.m b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsLayoutSections.m new file mode 100644 index 000000000..2639cd6d4 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsLayoutSections.m @@ -0,0 +1,42 @@ +% App-owned implementation for gait_analysis.analysisRun.optionsLayoutSections within the gait_analysis product workflow. +function sections = optionsLayoutSections() +%OPTIONSLAYOUTSECTIONS Declare gait roles, calibration, and detection rules. +roles = labkit.app.layout.section("roleSection", "Keypoint Roles", { ... + optionField("iliacPoint", "Iliac point", "text", "iliac"), ... + optionField("hipPoint", "Hip point", "text", "hip"), ... + optionField("kneePoint", "Knee point", "text", "knee"), ... + optionField("anklePoint", "Ankle point", "text", "ankle"), ... + optionField("footPoint", "Foot point", "text", "foot")}); +calibration = labkit.app.layout.section( ... + "calibrationSection", "Time + Scale", { ... + optionField("frameRate", "Frame rate Hz", "numeric", 30), ... + optionField("pixelsPerUnit", "Pixels per unit", "numeric", 1), ... + optionField("unitName", "Unit name", "text", "px"), ... + optionField("originAtFirstFrameFirstPoint", ... + "Use first-frame first point as origin", "logical", false)}); +detection = labkit.app.layout.section( ... + "detectionSection", "Step Detection", { ... + optionField("smoothWindow", ... + "Smooth window frames", "numeric", 5), ... + optionField("detectionProminence", ... + "Minimum foot-X prominence", "numeric", 20), ... + optionField("detectionMinHeightSigma", ... + "Peak-height sigma", "numeric", 2), ... + optionField("minLiftOffIntervalSeconds", ... + "Minimum interval seconds", "numeric", 0.2), ... + optionField("minSwingFrames", ... + "Min swing frames", "numeric", 3), ... + optionField("maxSwingFrames", ... + "Max swing frames", "numeric", 300), ... + optionField("minStepLength", ... + "Min step length", "numeric", 1), ... + optionField("maxHipTranslation", ... + "Max hip translation", "numeric", 1000000)}); +sections = {roles, calibration, detection}; +end + +function node = optionField(id, label, kind, value) +node = labkit.app.layout.field(id, Label=label, Kind=kind, ... + Value=value, Bind="project.parameters." + id, ... + OnValueChanged=@gait_analysis.analysisRun.optionsChanged); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/present.m b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/present.m new file mode 100644 index 000000000..444580426 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/present.m @@ -0,0 +1,7 @@ +% App-owned implementation for gait_analysis.analysisRun.present within the gait_analysis product workflow. +function view = present(pose, result) +%PRESENT Describe analysis availability and current result status. +view = labkit.app.view.Snapshot() ... + .enabled("runAnalysis", pose.ok) ... + .value("analysisStatus", string(result.message)); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m new file mode 100644 index 000000000..cbbddc71e --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m @@ -0,0 +1,38 @@ +% App-owned implementation for gait_analysis.analysisRun.runFromWorkbench within the gait_analysis product workflow. +function state = runFromWorkbench(state, context) +%RUNFROMWORKBENCH Compute gait results from the rebuilt pose session. +arguments + state (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end +pose = state.session.cache.pose; +if ~pose.ok + context.alert("Open a current Video Marker MAT before running gait analysis.", "No pose data"); + return +end +options = gait_analysis.analysisRun.sanitizeOptions( ... + state.project.parameters); +task = gait_analysis.analysisRun.runTask( ... + state.session.cache.filepath, pose, options); +if state.project.results.analysis.ok && ... + state.session.cache.lastRunFingerprint == task.fingerprint + context.appendStatus( ... + "Gait analysis is already up to date; skipped duplicate run."); + return +end +try + result = gait_analysis.analysisRun.computeGait(pose, options); +catch cause + context.reportError("Gait analysis failed", cause); + context.alert(cause.message, "Gait analysis failed"); + context.appendStatus("Gait analysis failed: " + cause.message); + return +end +state.project.parameters = options; +state.project.results.analysis = result; +state.project.results.lastExport = []; +state.session.cache.lastRunFingerprint = task.fingerprint; +state.session.selection.currentStepIndex = 1; +context.appendStatus(sprintf("Gait analysis complete: %d valid step(s).", ... + sum(result.stepTable.is_valid))); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runLayoutSection.m b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runLayoutSection.m new file mode 100644 index 000000000..d68a0a4e5 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runLayoutSection.m @@ -0,0 +1,10 @@ +% App-owned implementation for gait_analysis.analysisRun.runLayoutSection within the gait_analysis product workflow. +function section = runLayoutSection() +%RUNLAYOUTSECTION Declare analysis execution and status controls. +section = labkit.app.layout.section("runSection", "Run", { ... + labkit.app.layout.button("runAnalysis", "Run analysis", ... + @gait_analysis.analysisRun.runFromWorkbench, ... + Tooltip="Detect gait steps from the selected pose data and calculate the configured temporal and spatial metrics."), ... + labkit.app.layout.field("analysisStatus", ... + Label="Status", Kind="readonly", Value="No analysis run")}); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/sanitizeOptions.m b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/sanitizeOptions.m new file mode 100644 index 000000000..38d199bea --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/sanitizeOptions.m @@ -0,0 +1,17 @@ +% App-owned implementation for gait_analysis.analysisRun.sanitizeOptions within the gait_analysis product workflow. +function options = sanitizeOptions(options) +%SANITIZEOPTIONS Replace invalid numeric UI values with App defaults. +defaults = gait_analysis.analysisRun.defaultOptions(); +numericNames = ["frameRate", "pixelsPerUnit", "smoothWindow", ... + "detectionProminence", "detectionMinHeightSigma", ... + "minLiftOffIntervalSeconds", "minSwingFrames", ... + "maxSwingFrames", "minStepLength", "maxHipTranslation"]; +for name = numericNames + value = double(options.(name)); + if isempty(value) || ~isscalar(value) || ~isfinite(value) + options.(name) = defaults.(name); + end +end +options.originAtFirstFrameFirstPoint = ... + logical(options.originAtFirstFrameFirstPoint); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+debug/writeSamplePack.m b/apps/gait/gait_analysis/+gait_analysis/+debug/writeSamplePack.m index 57da96f60..acdca16a6 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+debug/writeSamplePack.m +++ b/apps/gait/gait_analysis/+gait_analysis/+debug/writeSamplePack.m @@ -1,10 +1,9 @@ %WRITESAMPLEPACK Create a synthetic current Video Marker project for Gait. % Expected caller: debug startup and GUI tests. The MAT contains no source % path, sample identifier, or lab data. -function pack = writeSamplePack(debugLog) - root = fullfile(tempdir, "labkit_gait_analysis_debug"); - if ~isfolder(root) - mkdir(root); +function pack = writeSamplePack(sampleContext) + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext end project = syntheticVideoMarkerPayload(); labkitProject = struct( ... @@ -13,21 +12,25 @@ "app", struct("id", "video_marker", "payloadVersion", 2), ... "document", struct(), "producer", struct(), ... "sources", struct([]), "payload", project); - posePath = fullfile(root, "synthetic.video_marker.autosave.mat"); + posePath = sampleContext.samplePath( ... + "gait_analysis/video_marker_project.mat"); save(posePath, 'labkitProject'); - pack = struct("sampleFolder", string(root), ... - "representativeFiles", string(posePath)); - if nargin > 0 && ismethod(debugLog, "trace") - debugLog.trace("Gait analysis debug Video Marker project written."); - end + initialProject = gait_analysis.projectSpec().Create(); + initialProject.inputs.sources = sampleContext.sourceRecord( ... + "pose1", "poseCoordinates", posePath, true); + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-pose-coordinates", ... + InitialProject=initialProject, ... + Artifacts={sampleContext.artifact( ... + "poseCoordinates", "poseCoordinates", posePath)}); end function project = syntheticVideoMarkerPayload() pointNames = ["iliac"; "hip"; "knee"; "ankle"; "foot"]; project = struct(); project.inputs = struct( ... - "sources", labkit.ui.runtime.emptySourceRecords(), ... + "sources", struct([]), ... "videoMetadata", struct( ... "frameCount", 12, "frameRate", 30, "duration", 12/30, ... "height", 16, "width", 128)); @@ -40,7 +43,7 @@ "edges", [1 2; 2 3; 3 4; 4 5]), ... "frames", syntheticAnnotations(), ... "calibration", ... - labkit.ui.interaction.scaleBarCalibration([], [], "px")); + labkit.app.interaction.scaleCalibration([], [], "px")); project.results = struct(); project.extensions = struct(); end diff --git a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/draw.m b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/draw.m new file mode 100644 index 000000000..7d469fcfc --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/draw.m @@ -0,0 +1,156 @@ +% Expected caller: Gait Analysis plot-area renderer. Inputs are axes by ID +% and a pure gait preview model. Side effects are limited to those axes. +function draw(axesById, model) +drawOne(axesById.skeleton, withKind(model, "skeleton")); +drawOne(axesById.angles, withKind(model, "angles")); +drawOne(axesById.segments, withKind(model, "segments")); +end + +function model = withKind(model, kind) +model.kind = string(kind); +end + +function drawOne(ax, model) + labkit.app.plot.clearAxes(ax, "ResetScale", true); + if ~model.pose.ok + ax.YDir = "normal"; + labkit.app.plot.showMessage(ax, ... + "Load pose data to preview gait analysis."); + elseif model.kind == "skeleton" + drawSkeletons(ax, model); + elseif ~model.result.ok + ax.YDir = "normal"; + labkit.app.plot.showMessage(ax, ... + "Run analysis to inspect one segmented step cycle."); + elseif isempty(model.result.stepTable) + ax.YDir = "normal"; + labkit.app.plot.showMessage(ax, ... + "No complete lift-off to landing step was detected."); + elseif model.kind == "angles" + drawAngles(ax, model); + else + drawSegments(ax, model); + end + disableHitTesting(ax); +end + +function drawSkeletons(ax, model) + pose = model.pose; + frames = 1:size(pose.coords, 1); + titleText = "All overlaid skeleton trajectories"; + if model.result.ok && ~isempty(model.result.stepTable) + row = model.result.stepTable(model.selectedStep, :); + frames = row.lift_off_frame:row.landing_frame; + titleText = sprintf("Step %d | frames %d-%d", ... + model.selectedStep, row.lift_off_frame, row.landing_frame); + end + ax.YDir = "reverse"; + hold(ax, "on"); + edges = pose.skeleton.edges; + for k = 1:size(edges, 1) + first = edges(k, 1); + second = edges(k, 2); + x = [pose.coords(frames, first, 1), ... + pose.coords(frames, second, 1), NaN(numel(frames), 1)].'; + y = [pose.coords(frames, first, 2), ... + pose.coords(frames, second, 2), NaN(numel(frames), 1)].'; + plot(ax, x(:), y(:), "-", "Color", [0.55 0.55 0.55]); + end + for k = 1:numel(pose.pointNames) + plot(ax, pose.coords(frames, k, 1), pose.coords(frames, k, 2), ... + ".-", ... + "DisplayName", char(pose.pointNames(k))); + end + hold(ax, "off"); + title(ax, titleText); + xlabel(ax, "Pixel X"); + ylabel(ax, "Pixel Y"); + grid(ax, "on"); + legend(ax, "Location", "best"); + if model.result.ok && ~isempty(model.result.stepTable) + addStepAnnotation(ax, model.result.stepTable(model.selectedStep, :)); + end +end + +function drawAngles(ax, model) + value = selectedFrames(model); + ax.YDir = "normal"; + x = value.time_s; + label = "Time (s)"; + if all(~isfinite(x)) + x = value.frame_index; + label = "Frame"; + end + hold(ax, "on"); + plot(ax, x, value.hip_angle_deg, "DisplayName", "Hip"); + plot(ax, x, value.knee_angle_deg, "DisplayName", "Knee"); + plot(ax, x, value.ankle_angle_deg, "DisplayName", "Ankle"); + hold(ax, "off"); + title(ax, sprintf("Step %d joint angles", model.selectedStep)); + xlabel(ax, label); + ylabel(ax, "Angle (deg)"); + grid(ax, "on"); + legend(ax, "Location", "best"); +end + +function drawSegments(ax, model) + value = selectedFrames(model); + ax.YDir = "normal"; + x = value.time_s; + label = "Time (s)"; + if all(~isfinite(x)) + x = value.frame_index; + label = "Frame"; + end + hold(ax, "on"); + plot(ax, x, value.iliac_hip_length, "DisplayName", "Iliac-Hip"); + plot(ax, x, value.hip_knee_length, "DisplayName", "Hip-Knee"); + plot(ax, x, value.knee_ankle_length, "DisplayName", "Knee-Ankle"); + plot(ax, x, value.ankle_foot_length, "DisplayName", "Ankle-Foot"); + hold(ax, "off"); + title(ax, sprintf("Step %d segment lengths", model.selectedStep)); + xlabel(ax, label); + unit = model.result.stepTable.coordinate_unit(model.selectedStep); + ylabel(ax, "Length (" + unit + ")"); + grid(ax, "on"); + legend(ax, "Location", "best"); +end + +function value = selectedFrames(model) + row = model.result.stepTable(model.selectedStep, :); + value = model.result.frameTable( ... + row.lift_off_frame:row.landing_frame, :); +end + +function addStepAnnotation(ax, row) + unit = char(row.coordinate_unit); + lines = [ ... + sprintf("Swing %.4g s | step length %.4g %s", ... + row.swing_time_s, row.step_length, unit); ... + sprintf("Translation (%s): iliac %.4g, hip %.4g, knee %.4g", ... + unit, row.iliac_translation, row.hip_translation, row.knee_translation); ... + sprintf("ankle %.4g, foot %.4g %s", ... + row.ankle_translation, row.foot_translation, unit); ... + sprintf("Hip %.4g-%.4g deg (ROM %.4g)", ... + row.hip_min_deg, row.hip_max_deg, row.hip_rom_deg); ... + sprintf("Knee %.4g-%.4g deg (ROM %.4g)", ... + row.knee_min_deg, row.knee_max_deg, row.knee_rom_deg); ... + sprintf("Ankle %.4g-%.4g deg (ROM %.4g)", ... + row.ankle_min_deg, row.ankle_max_deg, row.ankle_rom_deg)]; + text(ax, 0.01, 0.99, strjoin(lines, newline), ... + "Units", "normalized", "VerticalAlignment", "top", ... + "BackgroundColor", [1 1 1], "Margin", 3, ... + "Interpreter", "none"); +end + +function disableHitTesting(ax) +graphics = allchild(ax); +for k = 1:numel(graphics) + if isprop(graphics(k), "HitTest") + graphics(k).HitTest = "off"; + end + if isprop(graphics(k), "PickableParts") + graphics(k).PickableParts = "none"; + end +end +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/layoutArea.m b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/layoutArea.m new file mode 100644 index 000000000..302184cb6 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/layoutArea.m @@ -0,0 +1,11 @@ +% App-owned implementation for gait_analysis.gaitPreview.layoutArea within the gait_analysis product workflow. +function area = layoutArea() +%LAYOUTAREA Declare the three stacked Gait Preview axes. +area = labkit.app.layout.plotArea("gaitAxes", ... + @gait_analysis.gaitPreview.draw, ... + Title="Gait Preview", Layout="stack", ... + AxisIds=["skeleton", "angles", "segments"], ... + AxisTitles=["Skeleton trajectories", ... + "Joint angles", "Segment lengths"], ... + ScrollZoomAxes=["xy", "x", "x"]); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/present.m b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/present.m new file mode 100644 index 000000000..be6d00876 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/present.m @@ -0,0 +1,5 @@ +% App-owned implementation for gait_analysis.gaitPreview.present within the gait_analysis product workflow. +function view = present(model) +%PRESENT Supply the current model to the Gait Preview renderer. +view = labkit.app.view.Snapshot().renderPlot("gaitAxes", model); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+resultFiles/chooseFolder.m b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/chooseFolder.m new file mode 100644 index 000000000..d785fa716 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/chooseFolder.m @@ -0,0 +1,13 @@ +% App-owned implementation for gait_analysis.resultFiles.chooseFolder within the gait_analysis product workflow. +function applicationState = chooseFolder(applicationState, callbackContext) +%CHOOSEFOLDER Select the destination for the gait CSV set. +choice = callbackContext.chooseOutputFolder( ... + applicationState.session.workflow.outputFolder); +if choice.Cancelled + callbackContext.appendStatus("Output folder selection cancelled."); + return +end +applicationState.session.workflow.outputFolder = string(choice.Value); +callbackContext.appendStatus( ... + "Output folder: " + string(choice.Value)); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+resultFiles/exportResults.m b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/exportResults.m new file mode 100644 index 000000000..94c481346 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/exportResults.m @@ -0,0 +1,56 @@ +% App-owned implementation for gait_analysis.resultFiles.exportResults within the gait_analysis product workflow. +function applicationState = exportResults( ... + applicationState, callbackContext) +%EXPORTRESULTS Write gait CSV outputs and their result manifest. +result = applicationState.project.results.analysis; +if ~result.ok + callbackContext.alert( ... + "Run gait analysis before exporting CSV files.", "No result"); + return +end +folder = applicationState.session.workflow.outputFolder; +if strlength(folder) == 0 + choice = callbackContext.chooseOutputFolder( ... + fileparts(applicationState.session.cache.filepath)); + if choice.Cancelled + callbackContext.appendStatus("Gait export cancelled."); + return + end + folder = string(choice.Value); + applicationState.session.workflow.outputFolder = folder; +end +[~, stem] = fileparts(applicationState.session.cache.filepath); +if strlength(string(stem)) == 0 + stem = "gait_analysis"; +end +try + outputs = gait_analysis.resultFiles.writeOutputs(folder, stem, result); +catch exception + callbackContext.reportError("Gait export failed", exception); + callbackContext.alert(exception.message, ... + "Could not export gait CSV files"); + return +end +files = { ... + resultFile("frames", outputs.frameCsv), ... + resultFile("coordinates", outputs.coordinateCsv), ... + resultFile("steps", outputs.stepCsv), ... + resultFile("summary", outputs.summaryCsv)}; +package = labkit.app.result.Package( ... + Outputs=files, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct("validStepCount", sum(result.stepTable.is_valid)), ... + ManifestName=string(stem) + "_gait.labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.lastExport = struct( ... + "outputs", outputs, "manifestPath", string(written.Value)); +callbackContext.appendStatus( ... + "Exported gait CSV set and manifest: " + string(written.Value)); +end + +function file = resultFile(id, filepath) +[~, name, extension] = fileparts(filepath); +file = labkit.app.result.File(id, "primary", ... + string(name) + string(extension), MediaType="text/csv"); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+resultFiles/layoutSection.m b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/layoutSection.m new file mode 100644 index 000000000..db24d88eb --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/layoutSection.m @@ -0,0 +1,14 @@ +% App-owned implementation for gait_analysis.resultFiles.layoutSection within the gait_analysis product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare gait result-folder selection and export. +section = labkit.app.layout.section("exportSection", "Export", { ... + labkit.app.layout.button("chooseOutputFolder", ... + "Choose output folder", @gait_analysis.resultFiles.chooseFolder, ... + Tooltip="Choose the destination for gait summary and per-step CSV files."), ... + labkit.app.layout.field("outputFolder", ... + Label="Output folder", Kind="readonly", ... + Value="No output folder chosen"), ... + labkit.app.layout.button("exportResults", "Export CSV set", ... + @gait_analysis.resultFiles.exportResults, ... + Tooltip="Export the gait summary and detected-step measurements as portable CSV files.")}); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+resultFiles/present.m b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/present.m new file mode 100644 index 000000000..5fd46fa34 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/present.m @@ -0,0 +1,12 @@ +% App-owned implementation for gait_analysis.resultFiles.present within the gait_analysis product workflow. +function view = present(folder, resultAvailable) +%PRESENT Describe the current export destination and availability. +text = "No output folder chosen"; +if strlength(string(folder)) > 0 + text = string(folder); +end +view = labkit.app.view.Snapshot() ... + .value("outputFolder", text) ... + .enabled("chooseOutputFolder", true) ... + .enabled("exportResults", resultAvailable); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+resultFiles/writeOutputs.m b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/writeOutputs.m index 351959202..b5fcc09d6 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+resultFiles/writeOutputs.m +++ b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/writeOutputs.m @@ -1,5 +1,5 @@ %WRITEOUTPUTS Write gait analysis CSV outputs. -% Expected caller: export action and tests. Creates a frame table, step table, +% Expected caller: export callback and tests. Creates a frame table, step table, % coordinate table, and summary table using the provided filename stem. function outputs = writeOutputs(outputFolder, stem, result) outputFolder = string(outputFolder); diff --git a/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m b/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m new file mode 100644 index 000000000..d71ccbda3 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m @@ -0,0 +1,24 @@ +% App-owned implementation for gait_analysis.sourceFiles.adoptPose within the gait_analysis product workflow. +function applicationState = adoptPose( ... + applicationState, selection, callbackContext) +%ADOPTPOSE Apply source-owned timing, scale, and role facts after import. +arguments + applicationState (1, 1) struct + selection (1, 1) labkit.app.event.ListSelection + callbackContext (1, 1) labkit.app.CallbackContext +end +pose = applicationState.session.cache.pose; +applicationState.project.results.analysis = ... + gait_analysis.analysisRun.emptyResult(); +applicationState.project.results.lastExport = []; +applicationState.session.cache.lastRunFingerprint = ""; +applicationState.session.selection.currentStepIndex = 1; +if ~pose.ok || isempty(selection.Indices) + return +end +applicationState.project.parameters = ... + gait_analysis.analysisRun.optionsForPose( ... + pose, applicationState.project.parameters); +callbackContext.appendStatus( ... + "Loaded pose file: " + applicationState.session.cache.filepath); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/layoutSection.m b/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/layoutSection.m new file mode 100644 index 000000000..2619b7b48 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/layoutSection.m @@ -0,0 +1,21 @@ +% App-owned implementation for gait_analysis.sourceFiles.layoutSection within the gait_analysis product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare the compact Video Marker project source. +files = labkit.app.layout.fileList("poseFile", ... + Label="Project or autosave", ... + Filters=["*.mat", "Video Marker project or autosave (*.mat)"], ... + SelectionMode="single", MaxFiles=1, ... + Bind="project.inputs.sources", ... + SelectionBind="session.selection.files", ... + OnSelectionChanged=@gait_analysis.sourceFiles.adoptPose, ... + SourceRole="poseCoordinates", SourceIdPrefix="pose", Required=true, ... + ChooseLabel="Open Video Marker MAT", ... + ChooseTooltip="Open a Video Marker project containing frame-by-frame landmark coordinates for gait-step analysis.", ... + EmptyText="No Video Marker project loaded"); +section = labkit.app.layout.section( ... + "sourceSection", "Video Marker Project", { ... + files, ... + labkit.app.layout.field("sourceSummary", ... + Label="Source summary", Kind="readonly", ... + Value="No pose file loaded")}); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/present.m b/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/present.m new file mode 100644 index 000000000..f46495439 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/present.m @@ -0,0 +1,25 @@ +% App-owned implementation for gait_analysis.sourceFiles.present within the gait_analysis product workflow. +function view = present(sources, selection, filepath, pose) +%PRESENT Describe the selected pose source and decoded summary. +if isempty(sources) + selection = labkit.app.event.ListSelection(); +end +fileStatus = "No pose file loaded"; +if strlength(string(filepath)) > 0 + fileStatus = string(filepath); +end +view = labkit.app.view.Snapshot() ... + .listSelection("poseFile", selection) ... + .text("poseFile", fileStatus) ... + .value("sourceSummary", sourceSummary(pose)); +end + +function value = sourceSummary(pose) +value = "No pose file loaded"; +if pose.ok + value = string(sprintf( ... + "%d frames | %d points | %.6g Hz | %s | unit %s", ... + size(pose.coords, 1), numel(pose.pointNames), ... + pose.frameRate, pose.sourceFormat, pose.unitName)); +end +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/boundedIndex.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/boundedIndex.m new file mode 100644 index 000000000..cee5391b7 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/boundedIndex.m @@ -0,0 +1,10 @@ +% App-owned implementation for gait_analysis.stepPreview.boundedIndex within the gait_analysis product workflow. +function index = boundedIndex(applicationState, requested) +%BOUNDEDINDEX Clamp one requested step row to available results. +count = height(applicationState.project.results.analysis.stepTable); +if count == 0 + index = 1; +else + index = min(max(1, round(double(requested))), count); +end +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/layoutSections.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/layoutSections.m new file mode 100644 index 000000000..467d0ce3f --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/layoutSections.m @@ -0,0 +1,21 @@ +% App-owned implementation for gait_analysis.stepPreview.layoutSections within the gait_analysis product workflow. +function sections = layoutSections() +%LAYOUTSECTIONS Declare result summary and selected-step review controls. +summary = labkit.app.layout.section("summarySection", "Summary", { ... + labkit.app.layout.dataTable("summaryTable", ... + Title="Summary", Columns=["Metric", "Value"])}); +navigation = labkit.app.layout.group("stepNavigation", { ... + labkit.app.layout.button("previousStep", "Previous step", ... + @gait_analysis.stepPreview.previous, ... + Tooltip="Review the preceding detected gait step and its swing-time and step-length measurements."), ... + labkit.app.layout.button("nextStep", "Next step", ... + @gait_analysis.stepPreview.next, ... + Tooltip="Review the next detected gait step and its swing-time and step-length measurements.")}, Layout="horizontal"); +steps = labkit.app.layout.section("stepSection", "Steps", { ... + navigation, ... + labkit.app.layout.dataTable("stepTable", ... + Title="Step Preview", ... + Columns=["Step", "Valid", "Swing_s", "Step length"], ... + OnCellSelectionChanged=@gait_analysis.stepPreview.select)}); +sections = {summary, steps}; +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/next.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/next.m new file mode 100644 index 000000000..ef65ea695 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/next.m @@ -0,0 +1,7 @@ +% App-owned implementation for gait_analysis.stepPreview.next within the gait_analysis product workflow. +function applicationState = next(applicationState, ~) +%NEXT Select the following detected gait step. +applicationState.session.selection.currentStepIndex = ... + gait_analysis.stepPreview.boundedIndex(applicationState, ... + applicationState.session.selection.currentStepIndex + 1); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/present.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/present.m new file mode 100644 index 000000000..d1aabe4ef --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/present.m @@ -0,0 +1,37 @@ +% App-owned implementation for gait_analysis.stepPreview.present within the gait_analysis product workflow. +function view = present(result, selectedStep) +%PRESENT Describe result tables and selected-step navigation. +[summary, steps] = resultTables(result); +stepCount = height(result.stepTable); +selectedCells = zeros(0, 2); +if stepCount > 0 + selectedCells = [selectedStep 1]; +end +view = labkit.app.view.Snapshot() ... + .tableData("summaryTable", summary, ... + Columns=["Metric", "Value"]) ... + .tableData("stepTable", steps, ... + Columns=["Step", "Valid", "Swing_s", "Step length"]) ... + .tableCellSelection("stepTable", ... + labkit.app.event.TableCellSelection(selectedCells)) ... + .enabled("previousStep", result.ok && selectedStep > 1) ... + .enabled("nextStep", result.ok && selectedStep < stepCount); +end + +function [summary, steps] = resultTables(result) +summary = {"Status", char(result.message)}; +steps = cell(0, 4); +if ~result.ok + return +end +summary = [cellstr(result.summaryTable.Metric), ... + cellstr(result.summaryTable.Value)]; +count = height(result.stepTable); +steps = cell(count, 4); +for k = 1:count + steps(k, :) = {result.stepTable.step_index(k), ... + logical(result.stepTable.is_valid(k)), ... + result.stepTable.swing_time_s(k), ... + result.stepTable.step_length(k)}; +end +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/previous.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/previous.m new file mode 100644 index 000000000..df944a4e2 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/previous.m @@ -0,0 +1,7 @@ +% App-owned implementation for gait_analysis.stepPreview.previous within the gait_analysis product workflow. +function applicationState = previous(applicationState, ~) +%PREVIOUS Select the preceding detected gait step. +applicationState.session.selection.currentStepIndex = ... + gait_analysis.stepPreview.boundedIndex(applicationState, ... + applicationState.session.selection.currentStepIndex - 1); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m new file mode 100644 index 000000000..8f3688d45 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m @@ -0,0 +1,16 @@ +% App-owned implementation for gait_analysis.stepPreview.select within the gait_analysis product workflow. +function applicationState = select( ... + applicationState, selection, callbackContext) +%SELECT Use the selected result-table row as the active gait step. +arguments + applicationState (1, 1) struct + selection (1, 1) labkit.app.event.TableCellSelection + callbackContext (1, 1) labkit.app.CallbackContext +end +if isempty(selection.CellIndices) + return +end +applicationState.session.selection.currentStepIndex = ... + gait_analysis.stepPreview.boundedIndex( ... + applicationState, selection.CellIndices(1, 1)); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m b/apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 6f8c7061b..000000000 --- a/apps/gait/gait_analysis/+gait_analysis/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,165 +0,0 @@ -% Expected caller: gait_analysis.definition. Inputs are app-owned callbacks. -% Output is a data-only UI 5 workbench layout for Gait Analysis. -function layout = buildWorkbenchLayout(callbacks, ~) - layout = labkit.ui.layout.workbench("gaitAnalysisApp", "Gait Analysis", ... - "controlTabs", controlTabs(callbacks), ... - "workspace", gaitWorkspace(callbacks), ... - "usageTitle", "Workflow Notes", ... - "usage", workflowNotesLines()); -end - -function tabs = controlTabs(callbacks) - tabs = {sourceTab(callbacks), optionsTab(callbacks), resultsTab(callbacks), logTab()}; -end - -function tab = sourceTab(callbacks) - filters = {'*.mat', 'Video Marker project or autosave (*.mat)'}; - tab = labkit.ui.layout.tab("source", "Source", { ... - labkit.ui.layout.section("sourceSection", "Video Marker Project", { ... - labkit.ui.layout.filePanel("poseFile", "Project or autosave", ... - "mode", "single", ... - "filters", filters, ... - "chooseLabel", "Open Video Marker MAT", ... - "status", "No Video Marker project loaded", ... - "emptyText", "No Video Marker project loaded", ... - "onChoose", callbacks.openPoseFile), ... - labkit.ui.layout.field("sourceSummary", "Source summary", ... - "kind", "readonly", ... - "value", "No pose file loaded")}), ... - labkit.ui.layout.section("runSection", "Run", { ... - labkit.ui.layout.action("runAnalysis", "Run analysis", ... - callbacks.runAnalysis, "enabled", false), ... - labkit.ui.layout.field("analysisStatus", "Status", ... - "kind", "readonly", ... - "value", "No analysis run")})}); -end - -function tab = optionsTab(~) - tab = labkit.ui.layout.tab("options", "Roles + Detection", { ... - roleSection(), ... - calibrationSection(), ... - detectionSection()}); -end - -function section = roleSection() - section = labkit.ui.layout.section("roleSection", "Keypoint Roles", { ... - labkit.ui.layout.field("iliacPoint", "Iliac point", ... - "kind", "text", "value", "iliac", ... - "Bind", "project.parameters.iliacPoint", "Event", "optionsChanged"), ... - labkit.ui.layout.field("hipPoint", "Hip point", ... - "kind", "text", "value", "hip", ... - "Bind", "project.parameters.hipPoint", "Event", "optionsChanged"), ... - labkit.ui.layout.field("kneePoint", "Knee point", ... - "kind", "text", "value", "knee", ... - "Bind", "project.parameters.kneePoint", "Event", "optionsChanged"), ... - labkit.ui.layout.field("anklePoint", "Ankle point", ... - "kind", "text", "value", "ankle", ... - "Bind", "project.parameters.anklePoint", "Event", "optionsChanged"), ... - labkit.ui.layout.field("footPoint", "Foot point", ... - "kind", "text", "value", "foot", ... - "Bind", "project.parameters.footPoint", "Event", "optionsChanged")}); -end - -function section = calibrationSection() - section = labkit.ui.layout.section("calibrationSection", "Time + Scale", { ... - labkit.ui.layout.field("frameRate", "Frame rate Hz", ... - "kind", "number", "value", 30, ... - "Bind", "project.parameters.frameRate", "Event", "optionsChanged"), ... - labkit.ui.layout.field("pixelsPerUnit", "Pixels per unit", ... - "kind", "number", "value", 1, ... - "Bind", "project.parameters.pixelsPerUnit", "Event", "optionsChanged"), ... - labkit.ui.layout.field("unitName", "Unit name", ... - "kind", "text", "value", "px", ... - "Bind", "project.parameters.unitName", "Event", "optionsChanged"), ... - labkit.ui.layout.field("originAtFirstFrameFirstPoint", ... - "Use first-frame first point as origin", ... - "kind", "checkbox", "value", false, ... - "Bind", "project.parameters.originAtFirstFrameFirstPoint", ... - "Event", "optionsChanged")}); -end - -function section = detectionSection() - section = labkit.ui.layout.section("detectionSection", "Step Detection", { ... - labkit.ui.layout.field("smoothWindow", "Smooth window frames", ... - "kind", "number", "value", 5, ... - "Bind", "project.parameters.smoothWindow", "Event", "optionsChanged"), ... - labkit.ui.layout.field("detectionProminence", ... - "Minimum foot-X prominence", ... - "kind", "number", "value", 20, ... - "Bind", "project.parameters.detectionProminence", ... - "Event", "optionsChanged"), ... - labkit.ui.layout.field("detectionMinHeightSigma", ... - "Peak-height sigma", ... - "kind", "number", "value", 2, ... - "Bind", "project.parameters.detectionMinHeightSigma", ... - "Event", "optionsChanged"), ... - labkit.ui.layout.field("minLiftOffIntervalSeconds", ... - "Minimum interval seconds", ... - "kind", "number", "value", 0.2, ... - "Bind", "project.parameters.minLiftOffIntervalSeconds", ... - "Event", "optionsChanged"), ... - labkit.ui.layout.field("minSwingFrames", "Min swing frames", ... - "kind", "number", "value", 3, ... - "Bind", "project.parameters.minSwingFrames", "Event", "optionsChanged"), ... - labkit.ui.layout.field("maxSwingFrames", "Max swing frames", ... - "kind", "number", "value", 300, ... - "Bind", "project.parameters.maxSwingFrames", "Event", "optionsChanged"), ... - labkit.ui.layout.field("minStepLength", "Min step length", ... - "kind", "number", "value", 1, ... - "Bind", "project.parameters.minStepLength", "Event", "optionsChanged"), ... - labkit.ui.layout.field("maxHipTranslation", "Max hip translation", ... - "kind", "number", "value", 1000000, ... - "Bind", "project.parameters.maxHipTranslation", "Event", "optionsChanged")}); -end - -function tab = resultsTab(callbacks) - tab = labkit.ui.layout.tab("results", "Results + Export", { ... - labkit.ui.layout.section("summarySection", "Summary", { ... - labkit.ui.layout.resultTable("summaryTable", "Summary", ... - "columns", {'Metric', 'Value'}, ... - "data", {'Status', 'No analysis run'})}), ... - labkit.ui.layout.section("stepSection", "Steps", { ... - labkit.ui.layout.group("stepNavigation", "Selected step", { ... - labkit.ui.layout.action("previousStep", "Previous step", ... - callbacks.previousStep, "enabled", false), ... - labkit.ui.layout.action("nextStep", "Next step", ... - callbacks.nextStep, "enabled", false)}), ... - labkit.ui.layout.resultTable("stepTable", "Step Preview", ... - "columns", {'Step', 'Valid', 'Swing_s', 'Step length'}, ... - "data", cell(0, 4), ... - "onSelectionChange", callbacks.stepSelected)}), ... - labkit.ui.layout.section("exportSection", "Export", { ... - labkit.ui.layout.action("chooseOutputFolder", "Choose output folder", ... - callbacks.chooseOutputFolder), ... - labkit.ui.layout.field("outputFolder", "Output folder", ... - "kind", "readonly", ... - "value", "No output folder chosen"), ... - labkit.ui.layout.action("exportResults", "Export CSV set", ... - callbacks.exportResults, "enabled", false)})}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log", ... - "value", {'Ready.'})})}); -end - -function workspace = gaitWorkspace(~) - workspace = labkit.ui.layout.workspace("gaitPreview", "Gait Preview", { ... - labkit.ui.layout.previewArea("gaitAxes", "Gait Preview", ... - "layout", "stack", ... - "axisIds", {'skeleton', 'angles', 'segments'}, ... - "axisTitles", {'Skeleton trajectories', ... - 'Joint angles', 'Segment lengths'}, ... - "scrollZoomAxes", {'xy', 'x', 'x'})}); -end - -function lines = workflowNotesLines() - lines = { ... - '1. Open a current Video Marker project or autosave MAT.', ... - '2. Embedded frame rate, skeleton, calibration, and annotations are the analysis source.', ... - '3. Loading immediately shows all overlaid skeleton trajectories.', ... - '4. Run analysis, then select one step to review its skeleton, angles, lengths, and translations.', ... - '5. Export coordinates include raw pixel columns plus optional scaled/origin-shifted columns.'}; -end diff --git a/apps/gait/gait_analysis/+gait_analysis/+userInterface/drawGaitPreview.m b/apps/gait/gait_analysis/+gait_analysis/+userInterface/drawGaitPreview.m deleted file mode 100644 index 25124a7c0..000000000 --- a/apps/gait/gait_analysis/+gait_analysis/+userInterface/drawGaitPreview.m +++ /dev/null @@ -1,132 +0,0 @@ -% Expected caller: Runtime V2 registered renderer. Inputs are one axes and a -% pure gait preview model. Side effect is limited to redrawing that axes. -function drawGaitPreview(ax, model) - labkit.ui.plot.clear(ax, "ResetScale", true); - if ~model.pose.ok - ax.YDir = "normal"; - labkit.ui.plot.message(ax, "Load pose data to preview gait analysis."); - elseif model.kind == "skeleton" - drawSkeletons(ax, model); - elseif ~model.result.ok - ax.YDir = "normal"; - labkit.ui.plot.message(ax, ... - "Run analysis to inspect one segmented step cycle."); - elseif isempty(model.result.stepTable) - ax.YDir = "normal"; - labkit.ui.plot.message(ax, ... - "No complete lift-off to landing step was detected."); - elseif model.kind == "angles" - drawAngles(ax, model); - else - drawSegments(ax, model); - end -end - -function drawSkeletons(ax, model) - pose = model.pose; - frames = 1:size(pose.coords, 1); - titleText = "All overlaid skeleton trajectories"; - if model.result.ok && ~isempty(model.result.stepTable) - row = model.result.stepTable(model.selectedStep, :); - frames = row.lift_off_frame:row.landing_frame; - titleText = sprintf("Step %d | frames %d-%d", ... - model.selectedStep, row.lift_off_frame, row.landing_frame); - end - ax.YDir = "reverse"; - hold(ax, "on"); - edges = pose.skeleton.edges; - for k = 1:size(edges, 1) - first = edges(k, 1); - second = edges(k, 2); - x = [pose.coords(frames, first, 1), ... - pose.coords(frames, second, 1), NaN(numel(frames), 1)].'; - y = [pose.coords(frames, first, 2), ... - pose.coords(frames, second, 2), NaN(numel(frames), 1)].'; - plot(ax, x(:), y(:), "-", "Color", [0.55 0.55 0.55]); - end - for k = 1:numel(pose.pointNames) - plot(ax, pose.coords(frames, k, 1), pose.coords(frames, k, 2), ... - ".-", ... - "DisplayName", char(pose.pointNames(k))); - end - hold(ax, "off"); - title(ax, titleText); - xlabel(ax, "Pixel X"); - ylabel(ax, "Pixel Y"); - grid(ax, "on"); - legend(ax, "Location", "best"); - if model.result.ok && ~isempty(model.result.stepTable) - addStepAnnotation(ax, model.result.stepTable(model.selectedStep, :)); - end -end - -function drawAngles(ax, model) - value = selectedFrames(model); - ax.YDir = "normal"; - x = value.time_s; - label = "Time (s)"; - if all(~isfinite(x)) - x = value.frame_index; - label = "Frame"; - end - hold(ax, "on"); - plot(ax, x, value.hip_angle_deg, "DisplayName", "Hip"); - plot(ax, x, value.knee_angle_deg, "DisplayName", "Knee"); - plot(ax, x, value.ankle_angle_deg, "DisplayName", "Ankle"); - hold(ax, "off"); - title(ax, sprintf("Step %d joint angles", model.selectedStep)); - xlabel(ax, label); - ylabel(ax, "Angle (deg)"); - grid(ax, "on"); - legend(ax, "Location", "best"); -end - -function drawSegments(ax, model) - value = selectedFrames(model); - ax.YDir = "normal"; - x = value.time_s; - label = "Time (s)"; - if all(~isfinite(x)) - x = value.frame_index; - label = "Frame"; - end - hold(ax, "on"); - plot(ax, x, value.iliac_hip_length, "DisplayName", "Iliac-Hip"); - plot(ax, x, value.hip_knee_length, "DisplayName", "Hip-Knee"); - plot(ax, x, value.knee_ankle_length, "DisplayName", "Knee-Ankle"); - plot(ax, x, value.ankle_foot_length, "DisplayName", "Ankle-Foot"); - hold(ax, "off"); - title(ax, sprintf("Step %d segment lengths", model.selectedStep)); - xlabel(ax, label); - unit = model.result.stepTable.coordinate_unit(model.selectedStep); - ylabel(ax, "Length (" + unit + ")"); - grid(ax, "on"); - legend(ax, "Location", "best"); -end - -function value = selectedFrames(model) - row = model.result.stepTable(model.selectedStep, :); - value = model.result.frameTable( ... - row.lift_off_frame:row.landing_frame, :); -end - -function addStepAnnotation(ax, row) - unit = char(row.coordinate_unit); - lines = [ ... - sprintf("Swing %.4g s | step length %.4g %s", ... - row.swing_time_s, row.step_length, unit); ... - sprintf("Translation (%s): iliac %.4g, hip %.4g, knee %.4g", ... - unit, row.iliac_translation, row.hip_translation, row.knee_translation); ... - sprintf("ankle %.4g, foot %.4g %s", ... - row.ankle_translation, row.foot_translation, unit); ... - sprintf("Hip %.4g-%.4g deg (ROM %.4g)", ... - row.hip_min_deg, row.hip_max_deg, row.hip_rom_deg); ... - sprintf("Knee %.4g-%.4g deg (ROM %.4g)", ... - row.knee_min_deg, row.knee_max_deg, row.knee_rom_deg); ... - sprintf("Ankle %.4g-%.4g deg (ROM %.4g)", ... - row.ankle_min_deg, row.ankle_max_deg, row.ankle_rom_deg)]; - text(ax, 0.01, 0.99, strjoin(lines, newline), ... - "Units", "normalized", "VerticalAlignment", "top", ... - "BackgroundColor", [1 1 1], "Margin", 3, ... - "Interpreter", "none"); -end diff --git a/apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m b/apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m deleted file mode 100644 index 5d69d227b..000000000 --- a/apps/gait/gait_analysis/+gait_analysis/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,95 +0,0 @@ -% Expected caller: Runtime V2. Input is canonical gait state. Output is pure -% controls, result tables, log, and one registered preview model. -function view = presentWorkbench(state) - pose = state.session.cache.pose; - result = state.project.results.analysis; - view = struct(); - view.controls.poseFile = sourcePanel(state.project.inputs.sources); - view.controls.sourceSummary = valueSpec(sourceSummary(pose)); - view.controls.outputFolder = valueSpec(outputFolderText( ... - state.session.workflow.outputFolder)); - view.controls.analysisStatus = valueSpec(result.message); - view.controls.runAnalysis = enabledSpec(pose.ok); - view.controls.exportResults = enabledSpec(result.ok); - stepCount = height(result.stepTable); - selectedStep = min(max(1, double( ... - state.session.selection.currentStepIndex)), max(1, stepCount)); - view.controls.previousStep = enabledSpec(result.ok && selectedStep > 1); - view.controls.nextStep = enabledSpec(result.ok && selectedStep < stepCount); - if result.ok - view.controls.summaryTable = tableSpec(summaryData(result.summaryTable)); - view.controls.stepTable = tableSpec(stepPreviewData(result.stepTable)); - else - view.controls.summaryTable = tableSpec({'Status', char(result.message)}); - view.controls.stepTable = tableSpec(cell(0, 4)); - end - base = struct("pose", pose, "result", result, ... - "selectedStep", selectedStep); - view.previews.gaitAxes.Axes.skeleton = axisSpec(base, "skeleton"); - view.previews.gaitAxes.Axes.angles = axisSpec(base, "angles"); - view.previews.gaitAxes.Axes.segments = axisSpec(base, "segments"); -end - -function spec = axisSpec(model, kind) - model.kind = string(kind); - spec = struct("Renderer", "gaitPreview", "Model", model); -end - -function spec = sourcePanel(sources) - files = struct("id", {}, "path", {}, "status", {}); - status = "No pose file loaded"; - filepath = labkit.ui.runtime.sourcePaths(sources, "pose"); - if strlength(filepath) > 0 - files = struct("id", "item1", "path", filepath, "status", ""); - status = filepath; - end - spec = struct("Files", files, "Status", status); -end - -function text = sourceSummary(pose) - text = "No pose file loaded"; - if pose.ok - text = string(sprintf('%d frames | %d points | %.6g Hz | %s | unit %s', ... - size(pose.coords, 1), numel(pose.pointNames), ... - pose.frameRate, pose.sourceFormat, pose.unitName)); - end -end - -function text = outputFolderText(folder) - text = "No output folder chosen"; - if strlength(string(folder)) > 0 - text = string(folder); - end -end - -function data = summaryData(value) - data = cell(0, 2); - if ~isempty(value) - data = [cellstr(value.Metric), cellstr(value.Value)]; - end -end - -function data = stepPreviewData(value) - count = height(value); - data = cell(count, 4); - for k = 1:count - data{k, 1} = value.step_index(k); - data{k, 2} = logical(value.is_valid(k)); - data{k, 3} = value.swing_time_s(k); - data{k, 4} = value.step_length(k); - end -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = tableSpec(value) - spec = struct(); - spec.Data = value; -end - -function spec = enabledSpec(value) - spec = struct("Enabled", logical(value)); -end diff --git a/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m b/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m new file mode 100644 index 000000000..0f2d42e77 --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m @@ -0,0 +1,29 @@ +% App-owned implementation for gait_analysis.workbench.buildLayout within the gait_analysis product workflow. +function layout = buildLayout() +%BUILDLAYOUT Assemble the visible Gait Analysis product surface. +source = labkit.app.layout.tab("source", "Source", { ... + gait_analysis.sourceFiles.layoutSection(), ... + gait_analysis.analysisRun.runLayoutSection()}); +options = labkit.app.layout.tab( ... + "options", "Roles + Detection", ... + gait_analysis.analysisRun.optionsLayoutSections()); +review = gait_analysis.stepPreview.layoutSections(); +results = labkit.app.layout.tab( ... + "results", "Results + Export", ... + [review, {gait_analysis.resultFiles.layoutSection()}]); +log = labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("appLog", Title="Log")})}); +preview = gait_analysis.gaitPreview.layoutArea(); +usage = [ ... + "1. Open a current Video Marker project or autosave MAT.", ... + "2. Embedded frame rate, skeleton, calibration, and annotations are the analysis source.", ... + "3. Loading immediately shows all overlaid skeleton trajectories.", ... + "4. Run analysis, then select one step to review its skeleton, angles, lengths, and translations.", ... + "5. Export coordinates include raw pixel columns plus optional scaled/origin-shifted columns."]; +layout = labkit.app.layout.workbench( ... + {source, options, results, log}, ... + Workspace=labkit.app.layout.workspace( ... + preview, Title="Gait Preview"), ... + UsageTitle="Workflow Notes", Usage=usage); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/+workbench/present.m b/apps/gait/gait_analysis/+gait_analysis/+workbench/present.m new file mode 100644 index 000000000..21a52062f --- /dev/null +++ b/apps/gait/gait_analysis/+gait_analysis/+workbench/present.m @@ -0,0 +1,20 @@ +% App-owned implementation for gait_analysis.workbench.present within the gait_analysis product workflow. +function view = present(applicationState) +%PRESENT Compose the complete Gait Analysis semantic snapshot. +pose = applicationState.session.cache.pose; +result = applicationState.project.results.analysis; +selectedStep = gait_analysis.stepPreview.boundedIndex( ... + applicationState, ... + applicationState.session.selection.currentStepIndex); +model = struct("pose", pose, "result", result, ... + "selectedStep", selectedStep); +view = gait_analysis.sourceFiles.present( ... + applicationState.project.inputs.sources, ... + applicationState.session.selection.files, ... + applicationState.session.cache.filepath, pose) ... + .include(gait_analysis.analysisRun.present(pose, result)) ... + .include(gait_analysis.stepPreview.present(result, selectedStep)) ... + .include(gait_analysis.resultFiles.present( ... + applicationState.session.workflow.outputFolder, result.ok)) ... + .include(gait_analysis.gaitPreview.present(model)); +end diff --git a/apps/gait/gait_analysis/+gait_analysis/createSession.m b/apps/gait/gait_analysis/+gait_analysis/createSession.m index 849780676..43a9424ae 100644 --- a/apps/gait/gait_analysis/+gait_analysis/createSession.m +++ b/apps/gait/gait_analysis/+gait_analysis/createSession.m @@ -1,24 +1,31 @@ -% Rebuild transient decoded pose, preview selection, export-folder -% convenience, and duplicate-run fingerprint from one validated project. -function session = createSession(project) - pose = gait_analysis.sourceFiles.emptyPoseData(); - filepath = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "pose"); - outputFolder = ""; - if strlength(filepath) > 0 - pose = gait_analysis.sourceFiles.readPoseFile(filepath); - outputFolder = string(labkit.ui.runtime.defaultOutputFolder( ... - filepath, "gait_analysis", "")); - end - fingerprint = ""; - if project.results.analysis.ok && pose.ok - task = gait_analysis.analysisRun.runTask( ... - filepath, pose, project.parameters); - fingerprint = task.fingerprint; - end - session = struct( ... - "selection", struct("currentStepIndex", 1), ... - "workflow", struct("outputFolder", outputFolder), ... - "cache", struct("filepath", filepath, "pose", pose, ... - "lastRunFingerprint", fingerprint)); +% App-owned implementation for gait_analysis.createSession within the gait_analysis product workflow. +function session = createSession(project, context) +%CREATESESSION Rebuild decoded pose and transient analysis state from project. +arguments + project (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end +paths = strings(0, 1); +if ~isempty(project.inputs.sources) + paths = context.resolveSourcePaths(project.inputs.sources); +end +pose = gait_analysis.sourceFiles.emptyPoseData(); +filepath = ""; +outputFolder = ""; +if ~isempty(paths) + filepath = paths(1); + pose = gait_analysis.sourceFiles.readPoseFile(filepath); + outputFolder = fullfile(fileparts(filepath), "gait_analysis"); +end +fingerprint = ""; +if project.results.analysis.ok && pose.ok + task = gait_analysis.analysisRun.runTask( ... + filepath, pose, project.parameters); + fingerprint = task.fingerprint; +end +selection = labkit.app.event.ListSelection(Indices=1:min(1, numel(paths))); +session = struct("selection", struct("files", selection, ... + "currentStepIndex", 1), "cache", struct("filepath", filepath, ... + "pose", pose, "lastRunFingerprint", fingerprint), ... + "workflow", struct("outputFolder", outputFolder)); end diff --git a/apps/gait/gait_analysis/+gait_analysis/definition.m b/apps/gait/gait_analysis/+gait_analysis/definition.m index 96995f944..bd9960a1d 100644 --- a/apps/gait/gait_analysis/+gait_analysis/definition.m +++ b/apps/gait/gait_analysis/+gait_analysis/definition.m @@ -1,22 +1,12 @@ -% App-owned runtime definition for labkit_GaitAnalysis_app. -% Expected caller: the public app entrypoint. Output is a declarative -% LabKit app definition; side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_GaitAnalysis_app", ... - "Id", "gait_analysis", ... - "Title", "Gait Analysis", ... - "DisplayName", "Gait Analysis", ... - "Family", "Gait", ... - "AppVersion", "2.0.8", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ... - "Project", gait_analysis.projectSpec(), ... - "CreateSession", @gait_analysis.createSession, ... - "Layout", @gait_analysis.userInterface.buildWorkbenchLayout, ... - "Actions", gait_analysis.definitionActions(), ... - "Present", @gait_analysis.userInterface.presentWorkbench, ... - "Renderers", struct("gaitPreview", ... - @gait_analysis.userInterface.drawGaitPreview), ... - "DebugSample", @gait_analysis.debug.writeSamplePack); +%DEFINE Declare Gait Analysis' explicit App SDK contract. +function app = definition() +app = labkit.app.Definition( ... + Entrypoint="labkit_GaitAnalysis_app", AppId="gait_analysis", ... + Title="Gait Analysis", DisplayName="Gait Analysis", Family="Gait", ... + AppVersion="2.1.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + ProjectSchema=gait_analysis.projectSpec(), CreateSession=@gait_analysis.createSession, ... + Workbench=gait_analysis.workbench.buildLayout(), ... + PresentWorkbench=@gait_analysis.workbench.present, ... + BuildDebugSample=@gait_analysis.debug.writeSamplePack); end diff --git a/apps/gait/gait_analysis/+gait_analysis/definitionActions.m b/apps/gait/gait_analysis/+gait_analysis/definitionActions.m deleted file mode 100644 index 2a220ec4f..000000000 --- a/apps/gait/gait_analysis/+gait_analysis/definitionActions.m +++ /dev/null @@ -1,211 +0,0 @@ -% App-owned Runtime V2 actions for Gait Analysis. Handlers own pose loading, -% deterministic analysis, preview selection, and result export without UI -% reads, startup callbacks, or direct control mutation. -function actions = definitionActions() - actions = struct( ... - "openPoseFile", @onOpenPoseFile, ... - "optionsChanged", @onOptionsChanged, ... - "runAnalysis", @onRunAnalysis, ... - "chooseOutputFolder", @onChooseOutputFolder, ... - "exportResults", @onExportResults, ... - "stepSelected", @onStepSelected, ... - "previousStep", @onPreviousStep, ... - "nextStep", @onNextStep); -end - -function state = onOpenPoseFile(state, event, services) - filepath = firstEventPath(event, services); - if strlength(filepath) == 0 - state = services.workflow.log(state, "Pose file selection cancelled."); - return; - end - try - pose = gait_analysis.sourceFiles.readPoseFile(filepath); - catch ME - services.diagnostics.report("Pose load failed", ME); - services.dialogs.alert(ME.message, "Could not load pose file"); - state = services.workflow.log(state, "Pose load failed: " + ME.message); - return; - end - state.project.inputs.sources = services.project.sourceRecord( ... - "pose", "poseCoordinates", filepath, true); - state.project.parameters = gait_analysis.analysisRun.optionsForPose( ... - pose, state.project.parameters); - state.project.results.analysis = gait_analysis.analysisRun.emptyResult(); - state.project.results.lastExport = []; - state.session.cache.filepath = filepath; - state.session.cache.pose = pose; - state.session.cache.lastRunFingerprint = ""; - state.session.selection.currentStepIndex = 1; - state.session.workflow.outputFolder = string( ... - services.dialogs.defaultOutputFolder(filepath, ... - "gait_analysis", state.session.workflow.outputFolder)); - state = services.workflow.log(state, "Loaded pose file: " + filepath); -end - -function state = onOptionsChanged(state, ~, ~) - state.project.parameters = sanitizeOptions(state.project.parameters); - state.project.results.analysis = gait_analysis.analysisRun.emptyResult(); - state.project.results.analysis.message = ... - "Analysis options changed; rerun analysis."; - state.project.results.lastExport = []; - state.session.cache.lastRunFingerprint = ""; -end - -function state = onRunAnalysis(state, ~, services) - pose = state.session.cache.pose; - if ~pose.ok - services.dialogs.alert( ... - "Open a current Video Marker MAT before running gait analysis.", ... - "No pose data"); - return; - end - options = sanitizeOptions(state.project.parameters); - task = gait_analysis.analysisRun.runTask( ... - state.session.cache.filepath, pose, options); - if state.project.results.analysis.ok && ... - state.session.cache.lastRunFingerprint == task.fingerprint - state = services.workflow.log(state, ... - "Gait analysis is already up to date; skipped duplicate run."); - return; - end - try - result = gait_analysis.analysisRun.computeGait(pose, options); - catch ME - services.diagnostics.report("Gait analysis failed", ME); - services.dialogs.alert(ME.message, "Gait analysis failed"); - state = services.workflow.log(state, ... - "Gait analysis failed: " + ME.message); - return; - end - state.project.parameters = options; - state.project.results.analysis = result; - state.project.results.lastExport = []; - state.session.cache.lastRunFingerprint = task.fingerprint; - state.session.selection.currentStepIndex = 1; - state = services.workflow.log(state, sprintf( ... - "Gait analysis complete: %d valid step(s).", ... - validStepCount(result.stepTable))); -end - -function state = onChooseOutputFolder(state, ~, services) - [folder, cancelled] = services.dialogs.outputFolder( ... - "Choose gait analysis output folder", ... - state.session.workflow.outputFolder); - if cancelled - state = services.workflow.log(state, ... - "Output folder selection cancelled."); - return; - end - state.session.workflow.outputFolder = string(folder); - state = services.workflow.log(state, "Output folder: " + string(folder)); -end - -function state = onExportResults(state, ~, services) - result = state.project.results.analysis; - if ~result.ok - services.dialogs.alert( ... - "Run gait analysis before exporting CSV files.", "No result"); - return; - end - folder = state.session.workflow.outputFolder; - if strlength(folder) == 0 - folder = string(services.dialogs.defaultOutputFolder( ... - state.session.cache.filepath, "gait_analysis", "")); - state.session.workflow.outputFolder = folder; - end - [~, stem] = fileparts(state.session.cache.filepath); - if strlength(string(stem)) == 0 - stem = "gait_analysis"; - end - try - outputs = gait_analysis.resultFiles.writeOutputs(folder, stem, result); - catch ME - services.diagnostics.report("Gait export failed", ME); - services.dialogs.alert(ME.message, "Could not export gait CSV files"); - return; - end - resultOutputs = [ ... - outputFor(services, "frames", outputs.frameCsv); ... - outputFor(services, "coordinates", outputs.coordinateCsv); ... - outputFor(services, "steps", outputs.stepCsv); ... - outputFor(services, "summary", outputs.summaryCsv)]; - spec = struct( ... - "Outputs", resultOutputs, "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, ... - "Summary", struct("validStepCount", ... - validStepCount(result.stepTable)), ... - "ManifestName", string(stem) + "_gait.labkit.json"); - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.lastExport = struct( ... - "outputs", outputs, "manifestPath", string(manifestPath)); - state = services.workflow.log(state, ... - "Exported gait CSV set and manifest: " + string(manifestPath)); -end - -function state = onStepSelected(state, event, ~) - if isempty(event.indices) - return; - end - state.session.selection.currentStepIndex = selectedStep( ... - state, event.indices(1)); -end - -function state = onPreviousStep(state, ~, ~) - state.session.selection.currentStepIndex = selectedStep( ... - state, state.session.selection.currentStepIndex - 1); -end - -function state = onNextStep(state, ~, ~) - state.session.selection.currentStepIndex = selectedStep( ... - state, state.session.selection.currentStepIndex + 1); -end - -function value = selectedStep(state, requested) - count = height(state.project.results.analysis.stepTable); - if count == 0 - value = 1; - else - value = min(max(1, round(double(requested))), count); - end -end - -function output = outputFor(services, id, filepath) - [~, name, extension] = fileparts(filepath); - output = services.results.output(id, "primary", ... - string(name) + string(extension), "text/csv"); -end - -function options = sanitizeOptions(options) - defaults = gait_analysis.analysisRun.defaultOptions(); - numeric = ["frameRate", "pixelsPerUnit", "smoothWindow", ... - "detectionProminence", "detectionMinHeightSigma", ... - "minLiftOffIntervalSeconds", "minSwingFrames", ... - "maxSwingFrames", "minStepLength", "maxHipTranslation"]; - for name = numeric - value = double(options.(name)); - if isempty(value) || ~isscalar(value) || ~isfinite(value) - options.(name) = defaults.(name); - end - end - options.originAtFirstFrameFirstPoint = ... - logical(options.originAtFirstFrameFirstPoint); -end - -function count = validStepCount(value) - count = 0; - if ~isempty(value) - count = sum(value.is_valid); - end -end - -function filepath = firstEventPath(event, services) - paths = services.events.paths(event, "files"); - if isempty(paths) - paths = services.events.paths(event, "addedFiles"); - end - filepath = ""; - if ~isempty(paths) - filepath = paths(1); - end -end diff --git a/apps/gait/gait_analysis/+gait_analysis/projectSpec.m b/apps/gait/gait_analysis/+gait_analysis/projectSpec.m index 4618f85dd..8bda3a40b 100644 --- a/apps/gait/gait_analysis/+gait_analysis/projectSpec.m +++ b/apps/gait/gait_analysis/+gait_analysis/projectSpec.m @@ -1,18 +1,15 @@ -% App-owned durable Gait Analysis contract. Runtime V2 calls the single +% App-owned durable Gait Analysis contract. The App runtime calls the single % migration entry for each missing version before validating current sources, % analysis parameters, and durable results. function spec = projectSpec() - spec = struct( ... - "Version", 3, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", @migrateProject); + spec = labkit.app.project.Schema(Version=3, ... + Create=@createProject, Validate=@validateProject, ... + Migrate=@migrateProject); end function project = createProject() project = struct(); - project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + project.inputs = struct("sources", struct([])); project.parameters = gait_analysis.analysisRun.defaultOptions(); project.annotations = struct(); project.results = struct( ... diff --git a/apps/gait/gait_analysis/labkit_GaitAnalysis_app.m b/apps/gait/gait_analysis/labkit_GaitAnalysis_app.m index f3a71ecb7..d835c0bb5 100644 --- a/apps/gait/gait_analysis/labkit_GaitAnalysis_app.m +++ b/apps/gait/gait_analysis/labkit_GaitAnalysis_app.m @@ -1,6 +1,5 @@ function varargout = labkit_GaitAnalysis_app(varargin) %LABKIT_GAITANALYSIS_APP Analyze gait from a current Video Marker MAT project. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @gait_analysis.definition, varargin{:}); + [varargout{1:nargout}] = gait_analysis.definition().launch(varargin{:}); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/centerXChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/centerXChanged.m new file mode 100644 index 000000000..cf31d6078 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/centerXChanged.m @@ -0,0 +1,6 @@ +% App-owned implementation for batch_crop.cropGeometry.centerXChanged within the batch_crop product workflow. +function applicationState = centerXChanged( ... + applicationState, value, callbackContext) +applicationState = batch_crop.cropGeometry.changeCoordinate( ... + applicationState, value, 1, callbackContext); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/centerYChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/centerYChanged.m new file mode 100644 index 000000000..174c2d30a --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/centerYChanged.m @@ -0,0 +1,6 @@ +% App-owned implementation for batch_crop.cropGeometry.centerYChanged within the batch_crop product workflow. +function applicationState = centerYChanged( ... + applicationState, value, callbackContext) +applicationState = batch_crop.cropGeometry.changeCoordinate( ... + applicationState, value, 2, callbackContext); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCenterFromPreview.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCenterFromPreview.m new file mode 100644 index 000000000..ce96f1111 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCenterFromPreview.m @@ -0,0 +1,23 @@ +% App-owned implementation for batch_crop.cropGeometry.changeCenterFromPreview within the batch_crop product workflow. +function applicationState = changeCenterFromPreview( ... + applicationState, value, callbackContext) +[applicationState, loaded] = batch_crop.sourceFiles.loadCurrent( ... + applicationState, callbackContext); +if ~loaded || ~isstruct(value) || ~isfield(value, "points") || ... + isempty(value.points) || size(value.points, 2) ~= 2 + return +end +index = batch_crop.sourceFiles.currentIndex(applicationState); +item = batch_crop.sourceFiles.currentItem(applicationState); +[geometry, ~] = batch_crop.cropGeometry.currentGeometry( ... + applicationState.session.cache.canvas, index, item, ... + batch_crop.cropGeometry.itemPaddingPercent(item, 0)); +center = batch_crop.cropGeometry.canvasToOriginal( ... + geometry, double(value.points(1, :))); +applicationState = batch_crop.cropGeometry.setCurrentCenter( ... + applicationState, center, true); +applicationState = batch_crop.cropGeometry.clearDerived(applicationState); +callbackContext.appendStatus(sprintf( ... + "Placed crop center for task %d: x=%.1f, y=%.1f.", index, ... + applicationState.project.inputs.items(index).centerXY)); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCoordinate.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCoordinate.m new file mode 100644 index 000000000..0b78c1b18 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCoordinate.m @@ -0,0 +1,18 @@ +% App-owned implementation for batch_crop.cropGeometry.changeCoordinate within the batch_crop product workflow. +function applicationState = changeCoordinate( ... + applicationState, value, dimension, callbackContext) +[applicationState, loaded] = batch_crop.sourceFiles.loadCurrent( ... + applicationState, callbackContext); +if ~loaded || ~(isnumeric(value) && isscalar(value) && isfinite(double(value))) + return +end +index = batch_crop.sourceFiles.currentIndex(applicationState); +center = applicationState.project.inputs.items(index).centerXY; +center(dimension) = double(value); +applicationState = batch_crop.cropGeometry.setCurrentCenter( ... + applicationState, center, true); +applicationState = batch_crop.cropGeometry.clearDerived(applicationState); +callbackContext.appendStatus(sprintf( ... + "Set crop center for task %d: x=%.1f, y=%.1f.", index, ... + applicationState.project.inputs.items(index).centerXY)); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/clearDerived.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/clearDerived.m new file mode 100644 index 000000000..e49815ad8 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/clearDerived.m @@ -0,0 +1,12 @@ +% App-owned implementation for batch_crop.cropGeometry.clearDerived within the batch_crop product workflow. +function applicationState = clearDerived(applicationState, clearCanvas) +if nargin < 2 + clearCanvas = false; +end +applicationState.project.results = ... + batch_crop.resultFiles.clearExportState(applicationState.project.results); +if clearCanvas + applicationState.session.cache.canvas = ... + batch_crop.cropGeometry.emptyCanvasCache(); +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropSizeChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropSizeChanged.m new file mode 100644 index 000000000..63995b613 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropSizeChanged.m @@ -0,0 +1,22 @@ +% App-owned implementation for batch_crop.cropGeometry.cropSizeChanged within the batch_crop product workflow. +function applicationState = cropSizeChanged(applicationState, ~, callbackContext) +parameters = applicationState.project.parameters; +parameters.cropWidth = positiveInteger(parameters.cropWidth, 1); +parameters.cropHeight = positiveInteger(parameters.cropHeight, 1); +applicationState.project.parameters = parameters; +applicationState.session.workflow.cropDefaultsInitialized = true; +[applicationState, loaded] = batch_crop.sourceFiles.loadCurrent( ... + applicationState, callbackContext); +if loaded + applicationState = batch_crop.cropGeometry.ensureCurrentCenter( ... + applicationState); +end +applicationState = batch_crop.cropGeometry.clearDerived(applicationState); +end + +function value = positiveInteger(candidate, fallback) +value = fallback; +if isnumeric(candidate) && isscalar(candidate) && isfinite(double(candidate)) + value = max(1, round(double(candidate))); +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/ensureCurrentCenter.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/ensureCurrentCenter.m new file mode 100644 index 000000000..8f0b19cbb --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/ensureCurrentCenter.m @@ -0,0 +1,13 @@ +% App-owned implementation for batch_crop.cropGeometry.ensureCurrentCenter within the batch_crop product workflow. +function applicationState = ensureCurrentCenter(applicationState) +if ~batch_crop.sourceFiles.hasCurrentImage(applicationState) + return +end +item = batch_crop.sourceFiles.currentItem(applicationState); +center = item.centerXY; +if isempty(center) || any(~isfinite(center)) + center = batch_crop.cropGeometry.sourceCenterXY(item.image); +end +applicationState = batch_crop.cropGeometry.setCurrentCenter( ... + applicationState, center, item.centerSet); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/initializeCropDefaults.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/initializeCropDefaults.m new file mode 100644 index 000000000..379ac2b2b --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/initializeCropDefaults.m @@ -0,0 +1,15 @@ +% App-owned implementation for batch_crop.cropGeometry.initializeCropDefaults within the batch_crop product workflow. +function applicationState = initializeCropDefaults(applicationState) +if applicationState.session.workflow.cropDefaultsInitialized || ... + ~batch_crop.sourceFiles.hasCurrentImage(applicationState) + return +end +imageData = batch_crop.sourceFiles.currentItem(applicationState).image; +% The established workflow starts with a crop spanning 70% of the source. +defaultCropFraction = 0.7; +applicationState.project.parameters.cropWidth = max(1, ... + round(size(imageData, 2) * defaultCropFraction)); +applicationState.project.parameters.cropHeight = max(1, ... + round(size(imageData, 1) * defaultCropFraction)); +applicationState.session.workflow.cropDefaultsInitialized = true; +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/layoutSection.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/layoutSection.m new file mode 100644 index 000000000..68b372790 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/layoutSection.m @@ -0,0 +1,37 @@ +% App-owned implementation for batch_crop.cropGeometry.layoutSection within the batch_crop product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare crop size, rotation, padding, and center controls. +fields = { ... + labkit.app.layout.slider("cropWidth", Label="Width (px)", ... + Value=1024, Limits=[1 100000], Step=1, ... + Bind="project.parameters.cropWidth", ... + OnValueChanged=@batch_crop.cropGeometry.cropSizeChanged), ... + labkit.app.layout.slider("cropHeight", Label="Height (px)", ... + Value=1024, Limits=[1 100000], Step=1, ... + Bind="project.parameters.cropHeight", ... + OnValueChanged=@batch_crop.cropGeometry.cropSizeChanged), ... + labkit.app.layout.slider("rotation", Label="Rotation (deg)", ... + Value=0, Limits=[-360 360], Step=0.1, ... + OnValueChanged=@batch_crop.cropGeometry.rotationChanged), ... + labkit.app.layout.slider("paddingPercent", Label="Padding (%)", ... + Value=0, Limits=[0 200], Step=1, ... + OnValueChanged=@batch_crop.cropGeometry.paddingChanged), ... + labkit.app.layout.slider("centerX", Label="Center X", ... + Value=1, Limits=[-100000 100000], Step=1, ... + OnValueChanged=@batch_crop.cropGeometry.centerXChanged), ... + labkit.app.layout.slider("centerY", Label="Center Y", ... + Value=1, Limits=[-100000 100000], Step=1, ... + OnValueChanged=@batch_crop.cropGeometry.centerYChanged)}; +centerActions = labkit.app.layout.group("centerActions", { ... + labkit.app.layout.button("useImageCenter", "Use XY center", ... + @batch_crop.cropGeometry.useImageCenter, ... + Tooltip="Center the crop rectangle on both axes of the current source image."), ... + labkit.app.layout.button("useImageXCenter", "Use X center", ... + @batch_crop.cropGeometry.useImageXCenter, ... + Tooltip="Center the crop horizontally while preserving its current vertical position."), ... + labkit.app.layout.button("useImageYCenter", "Use Y center", ... + @batch_crop.cropGeometry.useImageYCenter, ... + Tooltip="Center the crop vertically while preserving its current horizontal position.")}); +section = labkit.app.layout.section( ... + "cropGeometry", "Crop Geometry", [fields, {centerActions}]); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/paddingChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/paddingChanged.m new file mode 100644 index 000000000..f22dc28b6 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/paddingChanged.m @@ -0,0 +1,22 @@ +% App-owned implementation for batch_crop.cropGeometry.paddingChanged within the batch_crop product workflow. +function applicationState = paddingChanged( ... + applicationState, value, callbackContext) +[applicationState, loaded] = batch_crop.sourceFiles.loadCurrent( ... + applicationState, callbackContext); +if ~loaded + return +end +index = batch_crop.sourceFiles.currentIndex(applicationState); +fallback = applicationState.project.inputs.items(index).paddingPercent; +if isnumeric(value) && isscalar(value) && isfinite(double(value)) + fallback = double(value); +end +applicationState.project.inputs.items(index).paddingPercent = ... + min(max(fallback, 0), 200); +applicationState = batch_crop.cropGeometry.ensureCurrentCenter(applicationState); +applicationState = batch_crop.cropGeometry.clearDerived(applicationState, true); +applicationState.session.view.scaleBar = []; +callbackContext.appendStatus(sprintf( ... + "Updated padding for crop task %d: %.3g%%.", index, ... + applicationState.project.inputs.items(index).paddingPercent)); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/present.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/present.m new file mode 100644 index 000000000..7ebf686f6 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/present.m @@ -0,0 +1,49 @@ +% App-owned implementation for batch_crop.cropGeometry.present within the batch_crop product workflow. +function view = present(applicationState) +%PRESENT Describe crop geometry controls for the current task. +hasImage = batch_crop.sourceFiles.hasCurrentImage(applicationState); +parameters = applicationState.project.parameters; +physical = strcmpi(parameters.scaleMode, "Physical"); +rotation = 0; +padding = 0; +center = [1 1]; +centerXLimits = [-100000 100000]; +centerYLimits = [-100000 100000]; +cropUpper = 100000; +if hasImage + item = batch_crop.sourceFiles.currentItem(applicationState); + rotation = item.angleDeg; + padding = item.paddingPercent; + center = item.centerXY; + [geometry, ~] = batch_crop.cropGeometry.currentGeometry( ... + applicationState.session.cache.canvas, ... + batch_crop.sourceFiles.currentIndex(applicationState), item, padding); + scale = batch_crop.cropGeometry.geometryScale(geometry); + centerXLimits = [1 - double(geometry.padding.left) / scale, ... + double(geometry.sourceWidth) + ... + double(geometry.padding.right) / scale]; + centerYLimits = [1 - double(geometry.padding.top) / scale, ... + double(geometry.sourceHeight) + ... + double(geometry.padding.bottom) / scale]; + cropUpper = max(1, ceil(2 .* hypot( ... + double(size(item.image, 2)), double(size(item.image, 1))))); +end +view = labkit.app.view.Snapshot() ... + .enabled("cropWidth", hasImage && ~physical) ... + .enabled("cropHeight", hasImage && ~physical) ... + .limits("cropWidth", [1 cropUpper]) ... + .limits("cropHeight", [1 cropUpper]) ... + .enabled("rotation", hasImage) ... + .value("rotation", rotation) ... + .enabled("paddingPercent", hasImage) ... + .value("paddingPercent", padding) ... + .enabled("centerX", hasImage) ... + .value("centerX", center(1)) ... + .limits("centerX", centerXLimits) ... + .enabled("centerY", hasImage) ... + .value("centerY", center(2)) ... + .limits("centerY", centerYLimits) ... + .enabled("useImageCenter", hasImage) ... + .enabled("useImageXCenter", hasImage) ... + .enabled("useImageYCenter", hasImage); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotationChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotationChanged.m new file mode 100644 index 000000000..5479e52c2 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotationChanged.m @@ -0,0 +1,26 @@ +% App-owned implementation for batch_crop.cropGeometry.rotationChanged within the batch_crop product workflow. +function applicationState = rotationChanged( ... + applicationState, value, callbackContext) +[applicationState, loaded] = batch_crop.sourceFiles.loadCurrent( ... + applicationState, callbackContext); +if ~loaded + return +end +index = batch_crop.sourceFiles.currentIndex(applicationState); +fallback = applicationState.project.inputs.items(index).angleDeg; +applicationState.project.inputs.items(index).angleDeg = ... + finiteScalar(value, fallback); +applicationState = batch_crop.cropGeometry.ensureCurrentCenter(applicationState); +applicationState = batch_crop.cropGeometry.clearDerived(applicationState, true); +applicationState.session.view.scaleBar = []; +callbackContext.appendStatus(sprintf( ... + "Updated rotation for crop task %d: %.3g deg.", index, ... + applicationState.project.inputs.items(index).angleDeg)); +end + +function value = finiteScalar(candidate, fallback) +value = fallback; +if isnumeric(candidate) && isscalar(candidate) && isfinite(double(candidate)) + value = double(candidate); +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/scalePlan.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/scalePlan.m index 25b65c6c3..5674c7324 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/scalePlan.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/scalePlan.m @@ -55,7 +55,7 @@ % labkit_BatchImageCrop_app:InvalidScaleUnit - scaleUnit is unsupported. % % Example: -% cal = labkit.ui.interaction.scaleBarCalibration(20, 10, "um"); +% cal = labkit.app.interaction.scaleCalibration(20, 10, "um"); % items = struct("scaleCalibration", cal); % opts = struct("physicalWidth", 5, "physicalHeight", 3, ... % "scaleUnit", "um", "targetPixelsPerUnit", 4); @@ -63,7 +63,7 @@ % assert(plan.outputWidth == 20 && plan.outputHeight == 12) % % See also batch_crop.cropGeometry.cropScaledImage, -% labkit.ui.interaction.scaleBarCalibration +% labkit.app.interaction.scaleCalibration if nargin < 2 opts = struct(); diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/setCurrentCenter.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/setCurrentCenter.m new file mode 100644 index 000000000..0608766c8 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/setCurrentCenter.m @@ -0,0 +1,13 @@ +% App-owned implementation for batch_crop.cropGeometry.setCurrentCenter within the batch_crop product workflow. +function applicationState = setCurrentCenter( ... + applicationState, center, confirmed) +index = batch_crop.sourceFiles.currentIndex(applicationState); +item = batch_crop.sourceFiles.currentItem(applicationState); +[geometry, ~] = batch_crop.cropGeometry.currentGeometry( ... + applicationState.session.cache.canvas, index, item, ... + batch_crop.cropGeometry.itemPaddingPercent(item, 0)); +center = batch_crop.cropGeometry.clampCropCenterToCanvas( ... + geometry, center, batch_crop.cropGeometry.currentCropSize(applicationState)); +applicationState.project.inputs.items(index).centerXY = center; +applicationState.project.inputs.items(index).centerSet = logical(confirmed); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useImageCenter.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useImageCenter.m new file mode 100644 index 000000000..a7f8bca3f --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useImageCenter.m @@ -0,0 +1,5 @@ +% App-owned implementation for batch_crop.cropGeometry.useImageCenter within the batch_crop product workflow. +function applicationState = useImageCenter(applicationState, callbackContext) +applicationState = batch_crop.cropGeometry.useSourceCenter( ... + applicationState, "xy", callbackContext); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useImageXCenter.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useImageXCenter.m new file mode 100644 index 000000000..7760aceb4 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useImageXCenter.m @@ -0,0 +1,5 @@ +% App-owned implementation for batch_crop.cropGeometry.useImageXCenter within the batch_crop product workflow. +function applicationState = useImageXCenter(applicationState, callbackContext) +applicationState = batch_crop.cropGeometry.useSourceCenter( ... + applicationState, "x", callbackContext); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useImageYCenter.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useImageYCenter.m new file mode 100644 index 000000000..8bcbbf505 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useImageYCenter.m @@ -0,0 +1,5 @@ +% App-owned implementation for batch_crop.cropGeometry.useImageYCenter within the batch_crop product workflow. +function applicationState = useImageYCenter(applicationState, callbackContext) +applicationState = batch_crop.cropGeometry.useSourceCenter( ... + applicationState, "y", callbackContext); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useSourceCenter.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useSourceCenter.m new file mode 100644 index 000000000..1ab61760b --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useSourceCenter.m @@ -0,0 +1,26 @@ +% App-owned implementation for batch_crop.cropGeometry.useSourceCenter within the batch_crop product workflow. +function applicationState = useSourceCenter( ... + applicationState, mode, callbackContext) +[applicationState, loaded] = batch_crop.sourceFiles.loadCurrent( ... + applicationState, callbackContext); +if ~loaded + return +end +item = batch_crop.sourceFiles.currentItem(applicationState); +center = item.centerXY; +sourceCenter = batch_crop.cropGeometry.sourceCenterXY(item.image); +if any(~isfinite(center)) + center = sourceCenter; +end +if mode == "x" + center(1) = sourceCenter(1); +elseif mode == "y" + center(2) = sourceCenter(2); +else + center = sourceCenter; +end +applicationState = batch_crop.cropGeometry.setCurrentCenter( ... + applicationState, center, true); +applicationState = batch_crop.cropGeometry.clearDerived(applicationState); +callbackContext.appendStatus("Set crop " + upper(mode) + " center."); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/captureView.m b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/captureView.m new file mode 100644 index 000000000..d4db2941c --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/captureView.m @@ -0,0 +1,37 @@ +% App-owned preview view helper. Expected caller: batch-crop app redraw logic. +% Inputs are preview axes, current crop geometry, and preview placement. Output +% is a source-coordinate view state used to preserve zoom across redraws. +function state = captureView(previewAxes, geometry, placement) +%CAPTUREPREVIEWVIEW Capture current preview limits relative to source image. + + state = struct('valid', false); + if isempty(previewAxes) || ~isvalid(previewAxes) || ... + ~all(isfinite(previewAxes.XLim)) || ~all(isfinite(previewAxes.YLim)) + return; + end + + xLimitsCanvas = previewAxes.XLim - placement.offset(1); + yLimitsCanvas = previewAxes.YLim - placement.offset(2); + cornersCanvas = [ ... + xLimitsCanvas(1), yLimitsCanvas(1); ... + xLimitsCanvas(2), yLimitsCanvas(1); ... + xLimitsCanvas(2), yLimitsCanvas(2); ... + xLimitsCanvas(1), yLimitsCanvas(2)]; + originalCorners = zeros(4, 2); + for k = 1:4 + originalCorners(k, :) = batch_crop.cropGeometry.canvasToOriginal(geometry, ... + cornersCanvas(k, :)); + end + leftTop = originalCorners(1, :); + rightBottom = originalCorners(3, :); + originalXLim = sort([leftTop(1), rightBottom(1)]); + originalYLim = sort([leftTop(2), rightBottom(2)]); + state = struct( ... + 'valid', true, ... + 'centerOriginal', [mean(originalXLim), mean(originalYLim)], ... + 'originalCorners', originalCorners, ... + 'originalXLim', originalXLim, ... + 'originalYLim', originalYLim, ... + 'xSpan', diff(previewAxes.XLim), ... + 'ySpan', diff(previewAxes.YLim)); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/draw.m b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/draw.m new file mode 100644 index 000000000..e5555d99c --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/draw.m @@ -0,0 +1,67 @@ +% Expected caller: the Batch Crop App SDK plot declaration. Inputs are a +% target axes and prepared preview/crosshair/scale-bar model. Side effects +% are limited to the supplied axes. +function draw(axesById, model) + ax = axesById.main; + labkit.app.plot.clearAxes(ax); + if isempty(model.imageData) + title(ax, char(model.title)); + xlabel(ax, ''); + ylabel(ax, ''); + box(ax, 'on'); + return; + end + if ndims(model.imageData) == 2 + imagesc(ax, model.xData, model.yData, model.imageData); + colormap(ax, gray(256)); + else + image(ax, model.xData, model.yData, model.imageData); + end + axis(ax, 'image'); + ax.YDir = 'reverse'; + hold(ax, 'on'); + center = model.center; + plot(ax, [center(1) - 16, center(1) + 16], [center(2), center(2)], ... + 'Color', [0 0.85 1], 'LineWidth', 1.25, ... + 'HitTest', 'off', 'PickableParts', 'none'); + plot(ax, [center(1), center(1)], [center(2) - 16, center(2) + 16], ... + 'Color', [0 0.85 1], 'LineWidth', 1.25, ... + 'HitTest', 'off', 'PickableParts', 'none'); + drawCropRoi(ax, model.cropRectangle); + drawScaleBar(ax, model.scaleBar); + hold(ax, 'off'); + title(ax, char(model.title)); + xlabel(ax, ''); + ylabel(ax, ''); + box(ax, 'on'); +end + +function drawCropRoi(ax, position) + if numel(position) ~= 4 || any(~isfinite(position)) || ... + any(position(3:4) <= 0) + return; + end + color = [1 0.9 0.15]; + rectangle(ax, 'Position', position, 'EdgeColor', color, ... + 'LineStyle', '-', 'LineWidth', 2, ... + 'HitTest', 'off', 'PickableParts', 'none'); + text(ax, position(1), max(0.5, position(2) - 3), 'Crop ROI', ... + 'Color', color, 'FontWeight', 'bold', ... + 'BackgroundColor', [0 0 0], 'Margin', 2, ... + 'HitTest', 'off', 'PickableParts', 'none'); +end + +function drawScaleBar(ax, scaleBar) + if isempty(scaleBar) + return; + end + plot(ax, scaleBar.line(:, 1), scaleBar.line(:, 2), '-', ... + 'Color', scaleBar.color, 'LineWidth', 3, ... + 'HitTest', 'off', 'PickableParts', 'none', ... + 'DisplayName', 'scale bar'); + text(ax, scaleBar.labelPosition(1), scaleBar.labelPosition(2), ... + scaleBar.label, 'Color', scaleBar.color, 'FontWeight', 'bold', ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', char(scaleBar.verticalAlignment), ... + 'HitTest', 'off', 'PickableParts', 'none'); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/placement.m b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/placement.m new file mode 100644 index 000000000..6d2953f93 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/placement.m @@ -0,0 +1,25 @@ +% Expected caller: batch crop UI preview drawing and view-capture paths. +% Input is one crop-canvas geometry struct. Output is display placement data +% used to align padded canvas coordinates to source-image coordinates. +function placement = placement(geometry) +%PREVIEWPLACEMENT Compute preview x/y data and canvas offset. + + sourceCenter = batch_crop.cropGeometry.sourceCenterFromSize( ... + geometry.sourceWidth, geometry.sourceHeight); + canvasCenter = batch_crop.cropGeometry.originalToCanvas(geometry, sourceCenter); + displayCenter = originalToPreviewSource(geometry, sourceCenter); + offset = displayCenter - canvasCenter; + placement = struct( ... + 'offset', offset, ... + 'xData', [1, size(geometry.canvas, 2)] + offset(1), ... + 'yData', [1, size(geometry.canvas, 1)] + offset(2)); +end + +function point = originalToPreviewSource(geometry, point) + scale = 1; + if isfield(geometry, 'coordinateScale') && isfinite(double(geometry.coordinateScale)) && ... + double(geometry.coordinateScale) > 0 + scale = double(geometry.coordinateScale); + end + point = (point - 0.5) .* scale + 0.5; +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/present.m b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/present.m new file mode 100644 index 000000000..b0ef77ad2 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/present.m @@ -0,0 +1,73 @@ +% App-owned implementation for batch_crop.cropPreview.present within the batch_crop product workflow. +function view = present(applicationState) +%PRESENT Describe preview pixels and both managed interactions. +[model, geometry, item] = previewModel(applicationState); +hasImage = ~isempty(model.imageData); +editing = hasImage && ... + applicationState.session.workflow.scaleReferenceEditing; +imageSize = [1 1]; +centerValue = zeros(0, 2); +reference = zeros(0, 2); +if hasImage + imageSize = [size(geometry.canvas, 1), size(geometry.canvas, 2)]; + centerValue = batch_crop.cropGeometry.originalToCanvas( ... + geometry, item.centerXY); + reference = item.scaleCalibration.referenceLine; + for k = 1:size(reference, 1) + reference(k, :) = batch_crop.cropGeometry.originalToCanvas( ... + geometry, reference(k, :)); + end +end +view = labkit.app.view.Snapshot() ... + .renderPlot("preview", model) ... + .pointSlots("cropCenter", centerValue, ... + ImageSize=imageSize, Enabled=hasImage && ~editing) ... + .scaleReference("scaleReference", reference, ... + ImageSize=imageSize, Enabled=editing); +end + +function [model, geometry, item] = previewModel(state) +model = struct("imageData", [], "xData", [1 1], "yData", [1 1], ... + "center", [1 1], "cropRectangle", [], "scaleBar", [], ... + "title", "Padded rotation preview + fixed crop"); +geometry = struct(); +item = struct(); +if ~batch_crop.sourceFiles.hasCurrentImage(state) + return +end +index = batch_crop.sourceFiles.currentIndex(state); +item = batch_crop.sourceFiles.currentItem(state); +[geometry, ~] = batch_crop.cropGeometry.currentGeometry( ... + state.session.cache.canvas, index, item, item.paddingPercent); +placement = batch_crop.cropPreview.placement(geometry); +render = batch_crop.cropPreview.renderData(geometry, placement); +model.imageData = render.imageData; +model.xData = [1 size(geometry.canvas, 2)]; +model.yData = [1 size(geometry.canvas, 1)]; +model.center = batch_crop.cropGeometry.originalToCanvas( ... + geometry, item.centerXY); +model.cropRectangle = cropRectangle(geometry, item.centerXY, ... + batch_crop.cropGeometry.currentCropSize(state)); +model.scaleBar = scaleBarOnCanvas(geometry, state.session.view.scaleBar); +end + +function position = cropRectangle(geometry, center, cropSize) +scale = batch_crop.cropGeometry.geometryScale(geometry); +width = max(1, double(cropSize(1)) * scale); +height = max(1, double(cropSize(2)) * scale); +canvasCenter = batch_crop.cropGeometry.originalToCanvas(geometry, center); +position = [round(canvasCenter(1) - (width - 1) / 2) - 0.5, ... + round(canvasCenter(2) - (height - 1) / 2) - 0.5, width, height]; +end + +function value = scaleBarOnCanvas(geometry, value) +if isempty(value) + return +end +for k = 1:size(value.line, 1) + value.line(k, :) = batch_crop.cropGeometry.originalToCanvas( ... + geometry, value.line(k, :)); +end +value.labelPosition = batch_crop.cropGeometry.originalToCanvas( ... + geometry, value.labelPosition); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/renderData.m b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/renderData.m new file mode 100644 index 000000000..ad1430eb2 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/renderData.m @@ -0,0 +1,39 @@ +% App-owned preview rendering helper. Expected caller: batch-crop preview +% redraw logic. Inputs are the full crop geometry and placement metadata. +% Output preserves full canvas coordinate extents while optionally lowering +% preview CData resolution for responsive GUI rendering. +function render = renderData(geometry, placement, opts) +%PREVIEWRENDERDATA Prepare a lightweight preview image for axes rendering. + + if nargin < 3 + opts = struct(); + end + + maxPreviewPixels = double(optionValue(opts, 'MaxPreviewPixels', ... + defaultPreviewPixels())); + if ~isfinite(maxPreviewPixels) || maxPreviewPixels < 1 + maxPreviewPixels = defaultPreviewPixels(); + end + + [canvas, info] = labkit.image.previewBudget(geometry.canvas, ... + "MaxPixels", maxPreviewPixels); + + render = struct( ... + 'imageData', canvas, ... + 'xData', placement.xData, ... + 'yData', placement.yData, ... + 'scaleFactor', info.scaleFactor); +end + +function value = defaultPreviewPixels() + % Constant: 1.2 megapixels balances draggable preview responsiveness + % with sufficient crop-placement detail. + value = 1.2e6; +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isstruct(opts) && isfield(opts, name) + value = opts.(name); + end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/restoreView.m b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/restoreView.m new file mode 100644 index 000000000..e07bac415 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/restoreView.m @@ -0,0 +1,107 @@ +% App-owned preview view helper. Expected caller: batch-crop app redraw logic. +% Inputs are preview axes, a captured view state, crop geometry, and placement. +% Side effect is limited to restoring axes limits when the state is valid. +function restoreView(previewAxes, state, geometry, placement) +%RESTOREPREVIEWVIEW Restore source-coordinate preview limits after redraw. + + if isempty(state) || ~isstruct(state) || ~isfield(state, 'valid') || ~state.valid + return; + end + if ~hasOriginalLimits(state) && (~isfinite(state.xSpan) || ~isfinite(state.ySpan) || ... + state.xSpan <= 0 || state.ySpan <= 0) + return; + end + + fullXLimits = imageDataLimits(placement.xData, size(geometry.canvas, 2)); + fullYLimits = imageDataLimits(placement.yData, size(geometry.canvas, 1)); + if hasOriginalCorners(state) + cornersCanvas = zeros(size(state.originalCorners)); + for k = 1:size(state.originalCorners, 1) + cornersCanvas(k, :) = batch_crop.cropGeometry.originalToCanvas(geometry, ... + state.originalCorners(k, :)) + placement.offset; + end + previewAxes.XLim = clampLimits([min(cornersCanvas(:, 1)), ... + max(cornersCanvas(:, 1))], fullXLimits); + previewAxes.YLim = clampLimits([min(cornersCanvas(:, 2)), ... + max(cornersCanvas(:, 2))], fullYLimits); + return; + end + if hasOriginalLimits(state) + previewAxes.XLim = clampLimits(originalAxisLimits(geometry, placement, ... + state.originalXLim, 1), fullXLimits); + previewAxes.YLim = clampLimits(originalAxisLimits(geometry, placement, ... + state.originalYLim, 2), fullYLimits); + return; + end + + centerCanvas = batch_crop.cropGeometry.originalToCanvas(geometry, state.centerOriginal) + ... + placement.offset; + previewAxes.XLim = centeredLimits(centerCanvas(1), state.xSpan, fullXLimits); + previewAxes.YLim = centeredLimits(centerCanvas(2), state.ySpan, fullYLimits); +end + +function tf = hasOriginalCorners(state) + tf = isfield(state, 'originalCorners') && isnumeric(state.originalCorners) && ... + isequal(size(state.originalCorners), [4 2]) && ... + all(isfinite(state.originalCorners), "all"); +end + +function tf = hasOriginalLimits(state) + tf = isfield(state, 'originalXLim') && isfield(state, 'originalYLim') && ... + numel(state.originalXLim) == 2 && numel(state.originalYLim) == 2 && ... + all(isfinite(state.originalXLim)) && all(isfinite(state.originalYLim)) && ... + diff(state.originalXLim) > 0 && diff(state.originalYLim) > 0; +end + +function limits = originalAxisLimits(geometry, placement, originalLimits, axisIndex) + if axisIndex == 1 + firstPoint = [originalLimits(1), geometry.sourceHeight / 2]; + secondPoint = [originalLimits(2), geometry.sourceHeight / 2]; + else + firstPoint = [geometry.sourceWidth / 2, originalLimits(1)]; + secondPoint = [geometry.sourceWidth / 2, originalLimits(2)]; + end + firstCanvas = batch_crop.cropGeometry.originalToCanvas(geometry, firstPoint) + placement.offset; + secondCanvas = batch_crop.cropGeometry.originalToCanvas(geometry, secondPoint) + placement.offset; + limits = sort([firstCanvas(axisIndex), secondCanvas(axisIndex)]); +end + +function limits = clampLimits(limits, fullLimits) + span = diff(limits); + fullSpan = diff(fullLimits); + if span >= fullSpan + limits = fullLimits; + return; + end + if limits(1) < fullLimits(1) + limits = [fullLimits(1), fullLimits(1) + span]; + end + if limits(2) > fullLimits(2) + limits = [fullLimits(2) - span, fullLimits(2)]; + end +end + +function limits = centeredLimits(center, span, fullLimits) + fullSpan = diff(fullLimits); + if span >= fullSpan + limits = fullLimits; + return; + end + limits = center + [-0.5, 0.5] .* span; + if limits(1) < fullLimits(1) + limits = [fullLimits(1), fullLimits(1) + span]; + end + if limits(2) > fullLimits(2) + limits = [fullLimits(2) - span, fullLimits(2)]; + end +end + +function limits = imageDataLimits(data, count) + data = double(data(:)).'; + if numel(data) < 2 || count <= 1 + limits = data(1) + [-0.5, 0.5]; + return; + end + step = abs(diff(data(1:2))) / max(1, count - 1); + limits = sort(data(1:2)) + [-0.5, 0.5] .* step; +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+debug/writeSamplePack.m b/apps/image_measurement/batch_crop/+batch_crop/+debug/writeSamplePack.m index 9176c8ede..5006364cd 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+debug/writeSamplePack.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+debug/writeSamplePack.m @@ -2,39 +2,33 @@ % is a LabKit debug context. Output is a deterministic synthetic crop sample % pack. Side effects: writes anonymous debug images and records a session % manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Batch Crop debug image files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "batch_crop"); - imageFolder = fullfile(char(folders.sampleFolder), "images"); - ensureFolder(imageFolder); - - imageA = string(fullfile(imageFolder, "batch_crop_targets_a.png")); - imageB = string(fullfile(imageFolder, "batch_crop_targets_b.png")); - edgePath = string(fullfile(imageFolder, "batch_crop_valid_small_target.png")); - malformedPath = string(fullfile(imageFolder, "batch_crop_malformed_not_image.png")); + imageA = sampleContext.samplePath("batch_crop/source_a.png"); + imageB = sampleContext.samplePath("batch_crop/source_b.png"); + edgePath = sampleContext.samplePath("batch_crop/small_target.png"); + malformedPath = sampleContext.samplePath("batch_crop/malformed.png"); imwrite(targetImage(0), char(imageA)); imwrite(targetImage(1), char(imageB)); imwrite(targetImage(2), char(edgePath)); writeTextFile(malformedPath, "not an image payload" + newline); - representativeFiles = [imageA; imageB]; - pack = struct( ... - "sampleFolder", folders.sampleFolder, ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", representativeFiles, ... - "boundaryFiles", struct("validEdge", edgePath, "malformed", malformedPath)); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_BatchImageCrop_app", ... - "description", "Anonymous crop-target boundary image pack.", ... - "inputs", struct( ... - "representativeImages", representativeFiles, ... - "validEdgeImage", edgePath, ... - "malformedImage", malformedPath), ... - "outputFolder", folders.outputFolder); - pack.manifest = manifest; - recordManifest(debugLog, manifest); + project = batch_crop.projectSpec().Create(); + project.inputs.sources = [ ... + sampleContext.sourceRecord("image1", "cropSource", imageA, true), ... + sampleContext.sourceRecord("image2", "cropSource", imageB, true)]; + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-crop-targets", InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact("sourceA", "cropSource", imageA), ... + sampleContext.artifact("sourceB", "cropSource", imageB), ... + sampleContext.artifact("smallTarget", "boundaryInput", edgePath), ... + sampleContext.artifact("malformed", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function image = targetImage(variant) @@ -60,30 +54,6 @@ image = uint8(round(255 .* min(max(image, 0), 1))); end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 @@ -93,9 +63,3 @@ function writeTextFile(filepath, text) cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); end - -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/chooseFolder.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/chooseFolder.m new file mode 100644 index 000000000..85a5cd680 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/chooseFolder.m @@ -0,0 +1,12 @@ +% App-owned implementation for batch_crop.resultFiles.chooseFolder within the batch_crop product workflow. +function applicationState = chooseFolder(applicationState, callbackContext) +choice = callbackContext.chooseOutputFolder( ... + applicationState.project.parameters.outputFolder); +if choice.Cancelled + callbackContext.appendStatus("Export folder selection cancelled."); + return +end +applicationState.project.parameters.outputFolder = string(choice.Value); +applicationState.project.results = ... + batch_crop.resultFiles.clearExportState(applicationState.project.results); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/currentOptions.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/currentOptions.m new file mode 100644 index 000000000..061a8ea66 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/currentOptions.m @@ -0,0 +1,15 @@ +% App-owned implementation for batch_crop.resultFiles.currentOptions within the batch_crop product workflow. +function options = currentOptions(applicationState) +parameters = applicationState.project.parameters; +padding = 0; +index = batch_crop.sourceFiles.currentIndex(applicationState); +if batch_crop.sourceFiles.hasCurrentImage(applicationState) + padding = applicationState.project.inputs.items(index).paddingPercent; +end +options = batch_crop.resultFiles.exportOptions( ... + parameters.outputFolder, parameters.format, ... + batch_crop.cropGeometry.currentCropSize(applicationState), padding, ... + parameters.scaleMode, parameters.scaleUnit, ... + [parameters.physicalWidth, parameters.physicalHeight], ... + parameters.targetPixelsPerUnit, parameters.maxUpsamplePercent); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m deleted file mode 100644 index 11629c085..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/definitionActions.m +++ /dev/null @@ -1,160 +0,0 @@ -% Expected caller: batch_crop.definitionActions. Output owns export settings, -% output-folder choice, crop writes, and standard result-manifest state. -function actions = definitionActions() - actions = struct( ... - "exportSettingChanged", @onExportSettingChanged, ... - "chooseOutputFolder", @onChooseOutputFolder, ... - "exportCrops", @onExportCrops); -end - -function state = onExportSettingChanged(state, ~, ~) - state.project.results = batch_crop.resultFiles.clearExportState( ... - state.project.results); -end - -function state = onChooseOutputFolder(state, ~, services) - [folder, cancelled] = services.dialogs.outputFolder( ... - 'Select crop export folder', ... - state.project.parameters.outputFolder); - if cancelled - state = services.workflow.log(state, "Export folder selection cancelled."); - return; - end - state.project.parameters.outputFolder = string(folder); - state.project.results = batch_crop.resultFiles.clearExportState( ... - state.project.results); -end - -function state = onExportCrops(state, ~, services) - tasks = state.project.inputs.items; - if isempty(tasks) - services.dialogs.alert( ... - 'Load images before exporting crops.', 'No images loaded'); - return; - end - if ~all([tasks.centerSet]) - services.dialogs.alert( ... - batch_crop.userInterface.missingWorkflowItemsText(tasks, "center"), ... - 'Crop centers missing'); - return; - end - if strcmpi(state.project.parameters.scaleMode, "Physical") && ... - ~batch_crop.scaleCalibration.summarize(tasks).allCalibrated - services.dialogs.alert( ... - batch_crop.userInterface.missingWorkflowItemsText(tasks, "scale"), ... - 'Scale calibration missing'); - return; - end - try - items = batch_crop.sourceFiles.workingItems( ... - tasks, state.session.cache.images, state.project.inputs.sources); - items = batch_crop.sourceFiles.loadMissingImages(items); - catch ME - services.diagnostics.report('Could not load image', ME); - services.dialogs.alert(ME.message, 'Could not load image'); - return; - end - state.session.cache.images = {items.image}.'; - state.session.cache.canvas = batch_crop.cropGeometry.emptyCanvasCache(); - opts = currentExportOptions(state); - plan = batch_crop.resultFiles.exportPlan(items, opts); - results = state.project.results; - if ~isempty(results.lastExport) && ... - results.lastExportFingerprint == plan.fingerprint - state = services.workflow.log(state, ... - "Crop export is already up to date; skipped duplicate write."); - return; - end - try - payload = batch_crop.resultFiles.writeOutputs(items, opts); - spec = standardResultSpec(state, payload, services); - [manifestPath, ~] = services.results.writeManifest( ... - opts.outputFolder, spec); - catch ME - services.diagnostics.report('Export failed', ME); - services.dialogs.alert(ME.message, 'Export failed'); - return; - end - payload.resultManifestPath = string(manifestPath); - state.project.results.lastExport = payload; - state.project.results.lastExportFingerprint = plan.fingerprint; - state.project.results.resultManifestPath = string(manifestPath); - statuses = string({payload.results.status}); - savedCount = sum(statuses == "saved"); - failedCount = sum(statuses == "failed"); - state = services.workflow.log(state, sprintf( ... - 'Exported %d crop(s), %d failed. Manifest: %s', ... - savedCount, failedCount, char(payload.manifestPath))); - if failedCount > 0 - services.dialogs.alert(sprintf( ... - '%d image(s) failed. See the manifest for details.', failedCount), ... - 'Some crops failed'); - end -end - -function opts = currentExportOptions(state) - parameters = state.project.parameters; - padding = 0; - index = max(0, round(double(state.session.selection.currentIndex))); - if index >= 1 && index <= numel(state.project.inputs.items) && ... - index <= numel(state.session.cache.images) && ... - ~isempty(state.session.cache.images{index}) - padding = state.project.inputs.items(index).paddingPercent; - end - opts = batch_crop.resultFiles.exportOptions( ... - parameters.outputFolder, parameters.format, ... - batch_crop.cropGeometry.currentCropSize(state), padding, ... - parameters.scaleMode, parameters.scaleUnit, ... - [parameters.physicalWidth, parameters.physicalHeight], ... - parameters.targetPixelsPerUnit, parameters.maxUpsamplePercent); -end - -function spec = standardResultSpec(state, payload, services) - cropOutputs = services.results.emptyOutputs(); - for k = 1:numel(payload.results) - result = payload.results(k); - [~, name, extension] = fileparts(result.outputPath); - status = "success"; - if string(result.status) ~= "saved" - status = "failed"; - extension = formatExtension(state.project.parameters.format); - name = "crop" + string(k) + "_failed"; - end - cropOutputs(end + 1, 1) = services.results.output( ... - "crop" + string(k), "primary", ... - string(name) + string(extension), mediaType(extension), status, ... - string(result.message)); - end - [~, csvName, csvExtension] = fileparts(payload.manifestPath); - csvOutput = services.results.output("cropManifest", "manifest", ... - string(csvName) + string(csvExtension), "text/csv"); - spec = struct(); - spec.Outputs = [cropOutputs; csvOutput]; - spec.Inputs = state.project.inputs.sources; - spec.Parameters = state.project.parameters; - spec.Summary = struct("taskCount", numel(payload.results), ... - "savedCount", sum(string({payload.results.status}) == "saved")); - spec.ManifestName = "batch_crop_results.labkit.json"; -end - -function extension = formatExtension(formatValue) - switch upper(string(formatValue)) - case "PNG" - extension = ".png"; - case {"TIFF", "TIF"} - extension = ".tif"; - otherwise - extension = ".jpg"; - end -end - -function type = mediaType(extension) - switch lower(string(extension)) - case ".png" - type = "image/png"; - case {".tif", ".tiff"} - type = "image/tiff"; - otherwise - type = "image/jpeg"; - end -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/detailLines.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/detailLines.m new file mode 100644 index 000000000..c8cbb32e7 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/detailLines.m @@ -0,0 +1,37 @@ +% App-owned detail view helper. Expected caller: batch-crop app refreshSummary. +% Inputs are app state, current index, crop size, and padding percent. Output +% is a cell vector of text lines and has no side effects. +function lines = detailLines(state, currentIndex, cropWidth, cropHeight, paddingPercent) +%DETAILLINES Build detail text for the selected crop item. + + items = state.project.inputs.items; + if isempty(items) || currentIndex < 1 || currentIndex > numel(items) + lines = {'No images loaded.'}; + return; + end + + item = batch_crop.sourceFiles.workingItems( ... + items(currentIndex), state.session.cache.images(currentIndex), ... + state.session.cache.paths(currentIndex)); + stateText = ternary(item.centerSet, 'confirmed', 'needs confirmation'); + lines = { ... + sprintf('Image %d of %d: %s', currentIndex, numel(items), ... + labkit.image.displayName(item.path)), ... + sprintf('Crop center: x %.1f, y %.1f (%s)', ... + item.centerXY(1), item.centerXY(2), stateText), ... + sprintf('Output size: %d x %d px; rotation: %.3g deg; padding: %.3g%%', ... + cropWidth, cropHeight, item.angleDeg, paddingPercent)}; + lastExport = state.project.results.lastExport; + if ~isempty(lastExport) + lines{end+1} = sprintf( ... + 'Last manifest: %s', char(lastExport.manifestPath)); + end +end + +function value = ternary(condition, trueValue, falseValue) + if condition + value = trueValue; + else + value = falseValue; + end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/exportCrops.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/exportCrops.m new file mode 100644 index 000000000..51f2ddbec --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/exportCrops.m @@ -0,0 +1,125 @@ +% App-owned implementation for batch_crop.resultFiles.exportCrops within the batch_crop product workflow. +function applicationState = exportCrops(applicationState, callbackContext) +%EXPORTCROPS Write crop files, CSV detail manifest, and result provenance. +tasks = applicationState.project.inputs.items; +if isempty(tasks) + callbackContext.alert("Load images before exporting crops.", ... + "No images loaded"); + return +end +if ~all([tasks.centerSet]) + callbackContext.alert( ... + batch_crop.resultFiles.missingWorkflowItemsText(tasks, "center"), ... + "Crop centers missing"); + return +end +if strcmpi(applicationState.project.parameters.scaleMode, "Physical") && ... + ~batch_crop.scaleCalibration.summarize(tasks).allCalibrated + callbackContext.alert( ... + batch_crop.resultFiles.missingWorkflowItemsText(tasks, "scale"), ... + "Scale calibration missing"); + return +end +try + items = batch_crop.sourceFiles.workingItems(tasks, ... + applicationState.session.cache.images, ... + applicationState.session.cache.paths); + items = batch_crop.sourceFiles.loadMissingImages(items); +catch cause + callbackContext.reportError("Could not load image", cause); + callbackContext.alert(cause.message, "Could not load image"); + return +end +applicationState.session.cache.images = {items.image}.'; +applicationState.session.cache.canvas = ... + batch_crop.cropGeometry.emptyCanvasCache(); +options = batch_crop.resultFiles.currentOptions(applicationState); +plan = batch_crop.resultFiles.exportPlan(items, options); +results = applicationState.project.results; +if ~isempty(results.lastExport) && ... + results.lastExportFingerprint == plan.fingerprint + callbackContext.appendStatus( ... + "Crop export is already up to date; skipped duplicate write."); + return +end +try + if strlength(options.outputFolder) > 0 && ... + exist(options.outputFolder, "dir") ~= 7 + mkdir(options.outputFolder); + end + payload = batch_crop.resultFiles.writeOutputs(items, options); + package = resultPackage(applicationState, payload); + written = callbackContext.writeResultPackage( ... + options.outputFolder, package); +catch cause + callbackContext.reportError("Export failed", cause); + callbackContext.alert(cause.message, "Export failed"); + return +end +payload.resultManifestPath = string(written.Value); +applicationState.project.results.lastExport = payload; +applicationState.project.results.lastExportFingerprint = plan.fingerprint; +applicationState.project.results.resultManifestPath = string(written.Value); +statuses = string({payload.results.status}); +savedCount = sum(statuses == "saved"); +failedCount = sum(statuses == "failed"); +callbackContext.appendStatus(sprintf( ... + "Exported %d crop(s), %d failed. Manifest: %s", ... + savedCount, failedCount, payload.manifestPath)); +if failedCount > 0 + callbackContext.alert( ... + string(failedCount) + ... + " image(s) failed. See the manifest for details.", ... + "Some crops failed"); +end +end + +function package = resultPackage(applicationState, payload) +outputs = cell(1, numel(payload.results) + 1); +for k = 1:numel(payload.results) + result = payload.results(k); + [~, name, extension] = fileparts(result.outputPath); + status = "success"; + if string(result.status) ~= "saved" + status = "failed"; + extension = formatExtension(applicationState.project.parameters.format); + name = "crop" + string(k) + "_failed"; + end + outputs{k} = labkit.app.result.File( ... + "crop" + string(k), "primary", ... + string(name) + string(extension), ... + MediaType=mediaType(extension), Status=status, ... + Message=string(result.message)); +end +[~, name, extension] = fileparts(payload.manifestPath); +outputs{end} = labkit.app.result.File("cropManifest", "manifest", ... + string(name) + string(extension), MediaType="text/csv"); +package = labkit.app.result.Package(Outputs=outputs, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct("taskCount", numel(payload.results), ... + "savedCount", sum(string({payload.results.status}) == "saved")), ... + ManifestName="batch_crop_results.labkit.json"); +end + +function extension = formatExtension(formatValue) +switch upper(string(formatValue)) + case "PNG" + extension = ".png"; + case {"TIFF", "TIF"} + extension = ".tif"; + otherwise + extension = ".jpg"; +end +end + +function type = mediaType(extension) +switch lower(string(extension)) + case ".png" + type = "image/png"; + case {".tif", ".tiff"} + type = "image/tiff"; + otherwise + type = "image/jpeg"; +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/layoutSection.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/layoutSection.m new file mode 100644 index 000000000..f9dcbc980 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/layoutSection.m @@ -0,0 +1,19 @@ +% App-owned implementation for batch_crop.resultFiles.layoutSection within the batch_crop product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare export format, destination, and write action. +section = labkit.app.layout.section("exportSection", "Export", { ... + labkit.app.layout.field("format", Label="Format", Kind="choice", ... + Choices=["PNG", "TIFF", "JPEG"], ... + Bind="project.parameters.format", ... + OnValueChanged=@batch_crop.resultFiles.settingsChanged), ... + labkit.app.layout.field("outputFolder", Label="Output folder", ... + Kind="readonly"), ... + labkit.app.layout.group("exportActions", { ... + labkit.app.layout.button("chooseOutputFolder", ... + "Choose export folder", @batch_crop.resultFiles.chooseFolder, ... + Tooltip="Choose the destination for cropped images and their processing manifest."), ... + labkit.app.layout.button("exportCrops", ... + "Export cropped images", ... + @batch_crop.resultFiles.exportCrops, ... + Tooltip="Apply each task's crop, rotation, padding, scaling, and scale-bar settings and write the selected image format.")}, Layout="horizontal")}); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/missingWorkflowItemsText.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/missingWorkflowItemsText.m similarity index 100% rename from apps/image_measurement/batch_crop/+batch_crop/+userInterface/missingWorkflowItemsText.m rename to apps/image_measurement/batch_crop/+batch_crop/+resultFiles/missingWorkflowItemsText.m diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/present.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/present.m new file mode 100644 index 000000000..bdddfdcf5 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/present.m @@ -0,0 +1,7 @@ +% App-owned implementation for batch_crop.resultFiles.present within the batch_crop product workflow. +function view = present(applicationState) +hasImage = batch_crop.sourceFiles.hasCurrentImage(applicationState); +view = labkit.app.view.Snapshot() ... + .value("outputFolder", applicationState.project.parameters.outputFolder) ... + .enabled("exportCrops", hasImage); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/settingsChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/settingsChanged.m new file mode 100644 index 000000000..02e6e592b --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/settingsChanged.m @@ -0,0 +1,5 @@ +% App-owned implementation for batch_crop.resultFiles.settingsChanged within the batch_crop product workflow. +function applicationState = settingsChanged(applicationState, ~, ~) +applicationState.project.results = ... + batch_crop.resultFiles.clearExportState(applicationState.project.results); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/summaryTableData.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/summaryTableData.m new file mode 100644 index 000000000..b784fcf34 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/summaryTableData.m @@ -0,0 +1,41 @@ +% App-owned summary view helper. Expected caller: batch-crop app refreshSummary. +% Inputs are app state, current index, crop size, padding percent, and output +% format. Output is metric/value cell data and has no side effects. +function data = summaryTableData(state, currentIndex, cropWidth, cropHeight, paddingPercent, outputFormat) +%SUMMARYTABLEDATA Build the batch crop summary table cell data. + + items = state.project.inputs.items; + outputFolder = state.project.parameters.outputFolder; + if isempty(items) || currentIndex < 1 || currentIndex > numel(items) + data = { ... + 'Images loaded', '0'; ... + 'Crop size', sprintf('%d x %d px', cropWidth, cropHeight); ... + 'Aspect ratio', aspectRatioText(cropWidth, cropHeight); ... + 'Padding', sprintf('%.3g%% repaired reflect', paddingPercent); ... + 'Confirmed centers', '0'; ... + 'Output folder', char(outputFolder)}; + return; + end + + item = batch_crop.sourceFiles.workingItems( ... + items(currentIndex), state.session.cache.images(currentIndex), ... + state.session.cache.paths(currentIndex)); + data = { ... + 'Images loaded', sprintf('%d', numel(items)); ... + 'Current image', char(labkit.image.displayName(item.path)); ... + 'Crop size', sprintf('%d x %d px', cropWidth, cropHeight); ... + 'Aspect ratio', aspectRatioText(cropWidth, cropHeight); ... + 'Rotation', sprintf('%.3g deg', item.angleDeg); ... + 'Padding', sprintf('%.3g%% repaired reflect', paddingPercent); ... + 'Center', sprintf('x %.1f, y %.1f', item.centerXY(1), item.centerXY(2)); ... + 'Source image', sprintf('%d x %d px', size(item.image, 2), size(item.image, 1)); ... + 'Confirmed centers', sprintf('%d / %d', ... + batch_crop.cropTasks.countConfirmedCenters(items), numel(items)); ... + 'Output format', char(outputFormat); ... + 'Output folder', char(outputFolder)}; +end + +function text = aspectRatioText(width, height) + g = gcd(width, height); + text = sprintf('%d:%d', width / g, height / g); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/barSettingsChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/barSettingsChanged.m new file mode 100644 index 000000000..6e131188b --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/barSettingsChanged.m @@ -0,0 +1,9 @@ +% App-owned implementation for batch_crop.scaleCalibration.barSettingsChanged within the batch_crop product workflow. +function applicationState = barSettingsChanged(applicationState, ~, ~) +value = applicationState.project.parameters.scaleBarLength; +if ~(isnumeric(value) && isscalar(value) && ... + isfinite(double(value)) && value >= 0) + applicationState.project.parameters.scaleBarLength = 0; +end +applicationState.session.view.scaleBar = []; +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/changeCalibrationField.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/changeCalibrationField.m new file mode 100644 index 000000000..39a4ca24b --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/changeCalibrationField.m @@ -0,0 +1,45 @@ +% App-owned implementation for batch_crop.scaleCalibration.changeCalibrationField within the batch_crop product workflow. +function applicationState = changeCalibrationField( ... + applicationState, field, value) +if ~batch_crop.sourceFiles.hasCurrentImage(applicationState) + return +end +index = batch_crop.sourceFiles.currentIndex(applicationState); +calibration = applicationState.project.inputs.items(index).scaleCalibration; +switch field + case "pixels" + calibration.referencePixels = positiveOrNaN(value); + calibration.referenceLine = zeros(0, 2); + case "length" + calibration.referenceLength = nonnegative( ... + value, calibration.referenceLength); + case "unit" + calibration.unit = char(string(value)); +end +applicationState.project.inputs.items(index).scaleCalibration = ... + makeCalibration(calibration); +applicationState.session.view.scaleBar = []; +applicationState = batch_crop.cropGeometry.clearDerived(applicationState); +end + +function calibration = makeCalibration(value) +calibration = labkit.app.interaction.scaleCalibration( ... + value.referencePixels, value.referenceLength, value.unit, ... + struct("referenceLine", value.referenceLine, "defaultUnit", "um")); +end + +function value = positiveOrNaN(candidate) +value = NaN; +if isnumeric(candidate) && isscalar(candidate) && ... + isfinite(double(candidate)) && double(candidate) > 0 + value = double(candidate); +end +end + +function value = nonnegative(candidate, fallback) +value = fallback; +if isnumeric(candidate) && isscalar(candidate) && ... + isfinite(double(candidate)) && double(candidate) >= 0 + value = double(candidate); +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/changeReference.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/changeReference.m new file mode 100644 index 000000000..c165f6ce7 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/changeReference.m @@ -0,0 +1,28 @@ +% App-owned implementation for batch_crop.scaleCalibration.changeReference within the batch_crop product workflow. +function applicationState = changeReference( ... + applicationState, points, ~) +if ~batch_crop.sourceFiles.hasCurrentImage(applicationState) || ... + size(points, 2) ~= 2 + return +end +index = batch_crop.sourceFiles.currentIndex(applicationState); +item = batch_crop.sourceFiles.currentItem(applicationState); +[geometry, ~] = batch_crop.cropGeometry.currentGeometry( ... + applicationState.session.cache.canvas, index, item, ... + batch_crop.cropGeometry.itemPaddingPercent(item, 0)); +original = zeros(size(points)); +for k = 1:size(points, 1) + original(k, :) = batch_crop.cropGeometry.canvasToOriginal( ... + geometry, double(points(k, :))); +end +calibration = applicationState.project.inputs.items(index).scaleCalibration; +calibration.referenceLine = original; +calibration.referencePixels = NaN; +applicationState.project.inputs.items(index).scaleCalibration = ... + labkit.app.interaction.scaleCalibration( ... + calibration.referencePixels, calibration.referenceLength, ... + calibration.unit, struct("referenceLine", original, ... + "defaultUnit", "um")); +applicationState.session.view.scaleBar = []; +applicationState = batch_crop.cropGeometry.clearDerived(applicationState); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/layoutSections.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/layoutSections.m new file mode 100644 index 000000000..bca3e0ae3 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/layoutSections.m @@ -0,0 +1,81 @@ +% App-owned implementation for batch_crop.scaleCalibration.layoutSections within the batch_crop product workflow. +function sections = layoutSections() +%LAYOUTSECTIONS Declare global scale mode and current-image calibration. +% Constant: numeric limits and steps preserve the legacy calibration controls. +maximumCalibrationValue = 1e6; +settings = labkit.app.layout.section("scaleSettings", "Scale Mode", { ... + labkit.app.layout.field("scaleMode", Label="Mode", Kind="choice", ... + Choices=["Pixels", "Physical"], Bind="project.parameters.scaleMode", ... + OnValueChanged=@batch_crop.scaleCalibration.settingsChanged), ... + labkit.app.layout.field("scaleUnit", Label="Unit", Kind="choice", ... + Choices=["m", "cm", "mm", "um", "nm"], ... + Bind="project.parameters.scaleUnit", ... + OnValueChanged=@batch_crop.scaleCalibration.settingsChanged), ... + boundSlider("physicalWidth", "Width", 100, ... + [eps maximumCalibrationValue], 1, ... + "project.parameters.physicalWidth", ... + @batch_crop.scaleCalibration.settingsChanged), ... + boundSlider("physicalHeight", "Height", 100, ... + [eps maximumCalibrationValue], 1, ... + "project.parameters.physicalHeight", ... + @batch_crop.scaleCalibration.settingsChanged), ... + boundSlider("targetPixelsPerUnit", "Target px/unit (0=auto)", ... + 0, [0 maximumCalibrationValue], 1, ... + "project.parameters.targetPixelsPerUnit", ... + @batch_crop.scaleCalibration.settingsChanged), ... + boundSlider("maxUpsamplePercent", "Max upsample warning (%)", ... + 15, [0 10000], 1, "project.parameters.maxUpsamplePercent", ... + @batch_crop.scaleCalibration.settingsChanged), ... + labkit.app.layout.field("scaleStatus", Label="Status", Kind="readonly", ... + Value="Pixel mode: output size uses crop width/height in px.")}); + +currentImage = labkit.app.layout.section( ... + "scaleBarSection", "Current Image Scale", { ... + labkit.app.layout.button("measureScaleReference", ... + "Measure reference pixels", ... + @batch_crop.scaleCalibration.toggleReferenceEditing, ... + Tooltip="Measure a known distance in the current image to establish its pixels-per-unit calibration."), ... + numericSlider("scaleReferencePixels", "Reference pixels", ... + 0, [0 5000], 1, ... + @batch_crop.scaleCalibration.referencePixelsChanged), ... + numericSlider("scaleReferenceLength", "Reference length", ... + 1, [0 maximumCalibrationValue], 10, ... + @batch_crop.scaleCalibration.referenceLengthChanged), ... + labkit.app.layout.field("scaleCalibrationUnit", ... + Label="Scale unit", Kind="choice", ... + Choices=["m", "cm", "mm", "um", "nm"], ... + OnValueChanged=@batch_crop.scaleCalibration.unitChanged), ... + boundSlider("scaleBarLength", "Scale bar length", ... + 100, [0 maximumCalibrationValue], 10, ... + "project.parameters.scaleBarLength", ... + @batch_crop.scaleCalibration.barSettingsChanged), ... + labkit.app.layout.field("scaleBarPosition", ... + Label="Scale position", Kind="choice", ... + Choices=["Bottom center", "Bottom left", "Bottom right", ... + "Top center", "Top left", "Top right"], ... + Bind="project.parameters.scaleBarPosition", ... + OnValueChanged=@batch_crop.scaleCalibration.barSettingsChanged), ... + labkit.app.layout.field("scaleBarColor", ... + Label="Scale color", Kind="choice", ... + Choices=["Black", "White"], ... + Bind="project.parameters.scaleBarColor", ... + OnValueChanged=@batch_crop.scaleCalibration.barSettingsChanged), ... + labkit.app.layout.button("placeScaleBar", "Place scale bar", ... + @batch_crop.scaleCalibration.placeBar, ... + Tooltip="Place a calibrated scale bar on the current crop preview and exported crop."), ... + labkit.app.layout.field("scaleReferenceReadout", ... + Label="Reference px", Kind="readonly"), ... + labkit.app.layout.field("pixelsPerUnitReadout", ... + Label="Pixels/unit", Kind="readonly")}); +sections = {settings, currentImage}; +end + +function node = boundSlider(id, label, value, limits, step, bind, callback) +node = labkit.app.layout.slider(id, Label=label, Value=value, ... + Limits=limits, Step=step, Bind=bind, OnValueChanged=callback); +end + +function node = numericSlider(id, label, value, limits, step, callback) +node = labkit.app.layout.slider(id, Label=label, Value=value, ... + Limits=limits, Step=step, OnValueChanged=callback); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/placeBar.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/placeBar.m new file mode 100644 index 000000000..eb4d0e5f9 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/placeBar.m @@ -0,0 +1,27 @@ +% App-owned implementation for batch_crop.scaleCalibration.placeBar within the batch_crop product workflow. +function applicationState = placeBar(applicationState, callbackContext) +if ~batch_crop.sourceFiles.hasCurrentImage(applicationState) + callbackContext.alert("Open an image before placing a scale bar.", ... + "No image loaded"); + return +end +item = batch_crop.sourceFiles.currentItem(applicationState); +if ~batch_crop.scaleCalibration.isSet(item.scaleCalibration) + callbackContext.alert( ... + "Measure or enter reference pixels, then enter a positive reference length and unit.", ... + "Calibration required"); + return +end +try + parameters = applicationState.project.parameters; + applicationState.session.view.scaleBar = ... + labkit.app.interaction.scaleBarGeometry( ... + size(item.image), item.scaleCalibration, ... + parameters.scaleBarLength, parameters.scaleBarPosition, ... + parameters.scaleBarColor); + applicationState.session.workflow.scaleReferenceEditing = false; +catch cause + callbackContext.reportError("Could not place scale bar", cause); + callbackContext.alert(cause.message, "Could not place scale bar"); +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/present.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/present.m new file mode 100644 index 000000000..9b1b2d06b --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/present.m @@ -0,0 +1,60 @@ +% App-owned implementation for batch_crop.scaleCalibration.present within the batch_crop product workflow. +function view = present(applicationState) +%PRESENT Describe scale settings and current-image calibration. +hasImage = batch_crop.sourceFiles.hasCurrentImage(applicationState); +parameters = applicationState.project.parameters; +physical = strcmpi(parameters.scaleMode, "Physical"); +editing = applicationState.session.workflow.scaleReferenceEditing; +calibration = batch_crop.scaleCalibration.emptyCalibration( ... + parameters.scaleUnit); +index = batch_crop.sourceFiles.currentIndex(applicationState); +if hasImage + calibration = ... + applicationState.project.inputs.items(index).scaleCalibration; +end +referencePixels = calibration.referencePixels; +referenceReadout = "-"; +if isfinite(referencePixels) + referenceReadout = sprintf("%.6g", referencePixels); +else + referencePixels = 0; +end +pixelsReadout = "-"; +if calibration.pixelsPerUnit > 0 + pixelsReadout = sprintf("%.6g px/%s", ... + calibration.pixelsPerUnit, calibration.unit); +end +inputsEnabled = hasImage && physical && ~editing; +view = labkit.app.view.Snapshot() ... + .text("measureScaleReference", referenceActionText(editing)) ... + .enabled("scaleUnit", physical) ... + .enabled("physicalWidth", hasImage && physical) ... + .enabled("physicalHeight", hasImage && physical) ... + .enabled("targetPixelsPerUnit", hasImage && physical) ... + .enabled("maxUpsamplePercent", hasImage && physical) ... + .value("scaleStatus", batch_crop.scaleCalibration.statusText( ... + applicationState, index, parameters.scaleMode, ... + [parameters.physicalWidth parameters.physicalHeight], ... + parameters.scaleUnit)) ... + .enabled("measureScaleReference", hasImage && physical) ... + .enabled("scaleReferencePixels", inputsEnabled) ... + .value("scaleReferencePixels", referencePixels) ... + .enabled("scaleReferenceLength", hasImage && physical) ... + .value("scaleReferenceLength", calibration.referenceLength) ... + .enabled("scaleCalibrationUnit", hasImage && physical) ... + .value("scaleCalibrationUnit", string(calibration.unit)) ... + .enabled("scaleBarLength", hasImage && physical) ... + .enabled("scaleBarPosition", hasImage && physical) ... + .enabled("scaleBarColor", hasImage && physical) ... + .enabled("placeScaleBar", hasImage && physical && ... + calibration.isCalibrated && ~editing) ... + .value("scaleReferenceReadout", referenceReadout) ... + .value("pixelsPerUnitReadout", pixelsReadout); +end + +function text = referenceActionText(editing) +text = "Measure reference pixels"; +if editing + text = "Finish reference edit"; +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/referenceLengthChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/referenceLengthChanged.m new file mode 100644 index 000000000..9fa8bf06a --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/referenceLengthChanged.m @@ -0,0 +1,5 @@ +% App-owned implementation for batch_crop.scaleCalibration.referenceLengthChanged within the batch_crop product workflow. +function applicationState = referenceLengthChanged(applicationState, value, ~) +applicationState = batch_crop.scaleCalibration.changeCalibrationField( ... + applicationState, "length", value); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/referencePixelsChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/referencePixelsChanged.m new file mode 100644 index 000000000..a522d87ca --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/referencePixelsChanged.m @@ -0,0 +1,5 @@ +% App-owned implementation for batch_crop.scaleCalibration.referencePixelsChanged within the batch_crop product workflow. +function applicationState = referencePixelsChanged(applicationState, value, ~) +applicationState = batch_crop.scaleCalibration.changeCalibrationField( ... + applicationState, "pixels", value); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/settingsChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/settingsChanged.m new file mode 100644 index 000000000..924e20402 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/settingsChanged.m @@ -0,0 +1,36 @@ +% App-owned implementation for batch_crop.scaleCalibration.settingsChanged within the batch_crop product workflow. +function applicationState = settingsChanged(applicationState, ~, ~) +parameters = applicationState.project.parameters; +parameters.physicalWidth = positive(parameters.physicalWidth, eps); +parameters.physicalHeight = positive(parameters.physicalHeight, eps); +parameters.targetPixelsPerUnit = nonnegative( ... + parameters.targetPixelsPerUnit, 0); +parameters.maxUpsamplePercent = nonnegative( ... + parameters.maxUpsamplePercent, 0); +applicationState.project.parameters = parameters; +if ~strcmpi(parameters.scaleMode, "Physical") + applicationState.session.workflow.scaleReferenceEditing = false; +end +applicationState = batch_crop.cropGeometry.clearDerived(applicationState); +end + +function value = positive(candidate, fallback) +value = scalar(candidate, fallback); +if value <= 0 + value = fallback; +end +end + +function value = nonnegative(candidate, fallback) +value = scalar(candidate, fallback); +if value < 0 + value = fallback; +end +end + +function value = scalar(candidate, fallback) +value = fallback; +if isnumeric(candidate) && isscalar(candidate) && isfinite(double(candidate)) + value = double(candidate); +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/statusText.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/statusText.m new file mode 100644 index 000000000..71e64f6d3 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/statusText.m @@ -0,0 +1,33 @@ +% App-owned scale status view helper. Expected caller: batch-crop app summary +% refresh. Inputs are state, current index, mode, physical size, and unit. +% Output is display text only. +function text = statusText(state, currentIndex, mode, physicalSize, unitName) +%SCALESTATUSTEXT Build the Scale tab status line. + + if ~strcmpi(string(mode), "Physical") + text = 'Pixel mode: output size uses crop width/height in px.'; + return; + end + + items = state.project.inputs.items; + if isempty(items) + text = sprintf('Physical mode: set %.6g x %.6g %s and load images.', ... + physicalSize(1), physicalSize(2), char(string(unitName))); + return; + end + + scaleSummary = batch_crop.scaleCalibration.summarize(items); + item = items(currentIndex); + cal = item.scaleCalibration; + if batch_crop.scaleCalibration.isSet(cal) + cropPixelsPerUnit = batch_crop.cropGeometry.pixelsPerUnitForUnit(cal, unitName); + text = sprintf(['Physical mode: crop %.6g x %.6g %s; image %d scale %.6g px/%s ' ... + '(%.6g px/%s for crop); calibrated %d/%d.'], ... + physicalSize(1), physicalSize(2), char(string(unitName)), ... + currentIndex, cal.pixelsPerUnit, cal.unit, cropPixelsPerUnit, ... + char(string(unitName)), scaleSummary.calibratedCount, scaleSummary.total); + else + text = sprintf('Physical mode: image %d needs scale; calibrated %d/%d.', ... + currentIndex, scaleSummary.calibratedCount, scaleSummary.total); + end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/toggleReferenceEditing.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/toggleReferenceEditing.m new file mode 100644 index 000000000..8bc2857e6 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/toggleReferenceEditing.m @@ -0,0 +1,13 @@ +% App-owned implementation for batch_crop.scaleCalibration.toggleReferenceEditing within the batch_crop product workflow. +function applicationState = toggleReferenceEditing( ... + applicationState, callbackContext) +if ~batch_crop.sourceFiles.hasCurrentImage(applicationState) + callbackContext.alert( ... + "Open an image before measuring reference pixels.", ... + "No image loaded"); + return +end +applicationState.session.workflow.scaleReferenceEditing = ... + ~applicationState.session.workflow.scaleReferenceEditing; +applicationState.session.view.scaleBar = []; +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/unitChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/unitChanged.m new file mode 100644 index 000000000..e7efb9e6c --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/unitChanged.m @@ -0,0 +1,5 @@ +% App-owned implementation for batch_crop.scaleCalibration.unitChanged within the batch_crop product workflow. +function applicationState = unitChanged(applicationState, value, ~) +applicationState = batch_crop.scaleCalibration.changeCalibrationField( ... + applicationState, "unit", value); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/currentIndex.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/currentIndex.m new file mode 100644 index 000000000..99189c9b4 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/currentIndex.m @@ -0,0 +1,5 @@ +% App-owned implementation for batch_crop.sourceFiles.currentIndex within the batch_crop product workflow. +function index = currentIndex(applicationState) +index = max(0, round(double( ... + applicationState.session.selection.currentIndex))); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/currentItem.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/currentItem.m new file mode 100644 index 000000000..50bbb92b7 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/currentItem.m @@ -0,0 +1,8 @@ +% App-owned implementation for batch_crop.sourceFiles.currentItem within the batch_crop product workflow. +function item = currentItem(applicationState) +index = batch_crop.sourceFiles.currentIndex(applicationState); +item = batch_crop.sourceFiles.workingItems( ... + applicationState.project.inputs.items(index), ... + applicationState.session.cache.images(index), ... + applicationState.session.cache.paths(index)); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/duplicateCurrent.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/duplicateCurrent.m new file mode 100644 index 000000000..e3d0c5252 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/duplicateCurrent.m @@ -0,0 +1,44 @@ +% App-owned implementation for batch_crop.sourceFiles.duplicateCurrent within the batch_crop product workflow. +function applicationState = duplicateCurrent(applicationState, callbackContext) +[applicationState, loaded] = batch_crop.sourceFiles.loadCurrent( ... + applicationState, callbackContext); +if ~loaded + return +end +index = batch_crop.sourceFiles.currentIndex(applicationState); +duplicate = batch_crop.cropTasks.duplicateItem( ... + applicationState.project.inputs.items(index)); +sources = applicationState.project.inputs.sources; +path = applicationState.session.cache.paths(index); +sourceId = nextSourceId(sources); +source = labkit.app.project.sourceRecord( ... + sourceId, "cropSource", path, true); +duplicate.sourceId = sourceId; +items = applicationState.project.inputs.items; +applicationState.project.inputs.items = ... + [items(1:index); duplicate; items(index + 1:end)]; +applicationState.project.inputs.sources = ... + [sources(1:index); source; sources(index + 1:end)]; +images = applicationState.session.cache.images; +applicationState.session.cache.images = ... + [images(1:index); images(index); images(index + 1:end)]; +paths = applicationState.session.cache.paths; +applicationState.session.cache.paths = ... + [paths(1:index); paths(index); paths(index + 1:end)]; +applicationState.session.selection.currentIndex = index + 1; +applicationState = batch_crop.cropGeometry.ensureCurrentCenter( ... + applicationState); +applicationState = batch_crop.cropGeometry.clearDerived(applicationState, true); +callbackContext.appendStatus( ... + "Duplicated crop task " + string(index) + "."); +end + +function id = nextSourceId(sources) +ids = string({sources.id}); +number = 1; +id = "image-" + string(number); +while any(ids == id) + number = number + 1; + id = "image-" + string(number); +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/hasCurrentImage.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/hasCurrentImage.m new file mode 100644 index 000000000..329c8cf8f --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/hasCurrentImage.m @@ -0,0 +1,8 @@ +% App-owned implementation for batch_crop.sourceFiles.hasCurrentImage within the batch_crop product workflow. +function accepted = hasCurrentImage(applicationState) +index = batch_crop.sourceFiles.currentIndex(applicationState); +accepted = index >= 1 && ... + index <= numel(applicationState.project.inputs.items) && ... + index <= numel(applicationState.session.cache.images) && ... + ~isempty(applicationState.session.cache.images{index}); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/layoutSection.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/layoutSection.m new file mode 100644 index 000000000..205b89701 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/layoutSection.m @@ -0,0 +1,31 @@ +% App-owned implementation for batch_crop.sourceFiles.layoutSection within the batch_crop product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare source collection and crop-task navigation. +files = labkit.app.layout.fileList("images", ... + Label="Crop images", Filters=labkit.image.fileDialogFilter(), ... + SelectionMode="single", Bind="project.inputs.sources", ... + OnSelectionChanged=@batch_crop.sourceFiles.selectionChanged, ... + SourceRole="cropSource", SourceIdPrefix="image", Required=true, ... + AllowDuplicatePaths=true, ... + ChooseLabel="Add images or folder", FolderLabel="Add folder", ... + ChooseTooltip="Add source images as independent crop tasks; duplicate paths remain separate tasks.", ... + RecursiveFolderLabel="Add folder tree", ... + RemoveLabel="Remove selected", ClearLabel="Clear images", ... + EmptyText="No images loaded"); +duplicate = labkit.app.layout.group("imageCopyActions", { ... + labkit.app.layout.button("duplicateImage", "Duplicate image", ... + @batch_crop.sourceFiles.duplicateCurrent, ... + Tooltip="Create another crop task for the same source image so it can use different geometry or scale settings.")}); +navigation = labkit.app.layout.group("imageNavigation", { ... + labkit.app.layout.button("previousImage", "Previous image", ... + @batch_crop.sourceFiles.previous, ... + Tooltip="Show the preceding crop task and its task-specific geometry."), ... + labkit.app.layout.button("nextImage", "Next image", ... + @batch_crop.sourceFiles.next, ... + Tooltip="Show the next crop task and its task-specific geometry.")}); +section = labkit.app.layout.section("imagesSection", "Images", { ... + files, duplicate, navigation, ... + labkit.app.layout.field("imageSource", Label="Current image", ... + Kind="readonly"), ... + labkit.app.layout.field("imageStatus", Label="Status", Kind="readonly")}); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/loadCurrent.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/loadCurrent.m new file mode 100644 index 000000000..5015bbef3 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/loadCurrent.m @@ -0,0 +1,39 @@ +% App-owned implementation for batch_crop.sourceFiles.loadCurrent within the batch_crop product workflow. +function [applicationState, loaded] = loadCurrent( ... + applicationState, callbackContext) +%LOADCURRENT Lazily decode the selected crop task through resolved paths. +loaded = false; +index = batch_crop.sourceFiles.currentIndex(applicationState); +if index < 1 || index > numel(applicationState.project.inputs.items) + return +end +try + if index <= numel(applicationState.session.cache.images) && ... + ~isempty(applicationState.session.cache.images{index}) + loaded = true; + return + end + sourceId = string(applicationState.project.inputs.items(index).sourceId); + sources = applicationState.project.inputs.sources; + match = find(string({sources.id}) == sourceId, 1); + if isempty(match) + return + end + paths = callbackContext.resolveSourcePaths(sources(match)); + if isempty(paths) || strlength(paths(1)) == 0 + return + end + loadedItems = batch_crop.sourceFiles.readItems(paths(1)); + if isempty(loadedItems) + error("labkit_BatchImageCrop_app:ImageNotLoaded", ... + "No image was loaded for crop task %d.", index); + end + applicationState.session.cache.images{index} = loadedItems(1).image; + applicationState.session.cache.paths(index) = paths(1); + loaded = true; +catch cause + callbackContext.reportError("Could not load image", cause); + callbackContext.appendStatus( ... + "Could not load crop task " + string(index) + ": " + cause.message); +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/next.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/next.m new file mode 100644 index 000000000..bee4243fe --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/next.m @@ -0,0 +1,6 @@ +% App-owned implementation for batch_crop.sourceFiles.next within the batch_crop product workflow. +function applicationState = next(applicationState, callbackContext) +applicationState = batch_crop.sourceFiles.selectIndex(applicationState, ... + batch_crop.sourceFiles.currentIndex(applicationState) + 1, ... + callbackContext); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/present.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/present.m new file mode 100644 index 000000000..7cce81d3a --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/present.m @@ -0,0 +1,49 @@ +% App-owned implementation for batch_crop.sourceFiles.present within the batch_crop product workflow. +function view = present(applicationState) +%PRESENT Describe crop-task navigation and current source. +items = batch_crop.sourceFiles.workingItems( ... + applicationState.project.inputs.items, ... + applicationState.session.cache.images, ... + applicationState.session.cache.paths); +index = batch_crop.sourceFiles.currentIndex(applicationState); +hasImage = batch_crop.sourceFiles.hasCurrentImage(applicationState); +source = "No images loaded"; +if index >= 1 && index <= numel(items) + source = items(index).path; +end +physical = strcmpi(applicationState.project.parameters.scaleMode, "Physical"); +entries = batch_crop.sourceFiles.taskEntries(items, ... + applicationState.project.parameters.scaleMode); +statuses = strings(1, numel(entries)); +if ~isempty(entries) + statuses = reshape(string({entries.status}), 1, []); +end +selection = labkit.app.event.ListSelection(); +if index >= 1 && index <= numel(items) + selection = labkit.app.event.ListSelection( ... + Ids=string(applicationState.project.inputs.sources(index).id), ... + Indices=index); +end +view = labkit.app.view.Snapshot() ... + .fileItemStatuses("images", statuses) ... + .listSelection("images", selection) ... + .enabled("duplicateImage", hasImage) ... + .enabled("previousImage", hasImage && index > 1) ... + .enabled("nextImage", hasImage && index < numel(items)) ... + .value("imageSource", source) ... + .value("imageStatus", imageStatus(items, physical)); +end + +function value = imageStatus(items, physical) +if isempty(items) + value = "No images loaded"; +elseif physical + summary = batch_crop.scaleCalibration.summarize(items); + value = sprintf("Images: %d | centers: %d | scales: %d", ... + numel(items), batch_crop.cropTasks.countConfirmedCenters(items), ... + summary.calibratedCount); +else + value = sprintf("Images: %d | confirmed centers: %d", ... + numel(items), batch_crop.cropTasks.countConfirmedCenters(items)); +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/previous.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/previous.m new file mode 100644 index 000000000..f1caf7330 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/previous.m @@ -0,0 +1,6 @@ +% App-owned implementation for batch_crop.sourceFiles.previous within the batch_crop product workflow. +function applicationState = previous(applicationState, callbackContext) +applicationState = batch_crop.sourceFiles.selectIndex(applicationState, ... + batch_crop.sourceFiles.currentIndex(applicationState) - 1, ... + callbackContext); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/reconcileSelection.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/reconcileSelection.m deleted file mode 100644 index 546de5def..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/reconcileSelection.m +++ /dev/null @@ -1,52 +0,0 @@ -% App-owned task/source reconciliation. Expected caller: Batch Crop file -% selection. Inputs are existing durable tasks/sources, parallel cached images, -% selected paths, and the runtime source-record factory. Outputs preserve -% duplicate crop tasks for retained sources and append one fresh task per new -% source without performing image I/O. -function [tasks, sources, images] = reconcileSelection( ... - existingTasks, existingSources, existingImages, paths, sourceRecord) - tasks = repmat(batch_crop.cropTasks.emptyTask(), 0, 1); - sources = labkit.ui.runtime.emptySourceRecords(); - images = cell(0, 1); - paths = unique(string(paths), 'stable'); - for k = 1:numel(paths) - sourceIndex = find(labkit.ui.runtime.sourcePaths(existingSources) == ... - paths(k), ... - 1, 'first'); - if isempty(sourceIndex) - sourceId = nextSourceId(existingSources, sources); - source = sourceRecord(sourceId, "cropSource", paths(k), true); - matchingTasks = []; - else - source = existingSources(sourceIndex); - sourceId = string(source.id); - matchingTasks = find(string({existingTasks.sourceId}) == sourceId); - end - sources(end + 1) = source; - if isempty(matchingTasks) - task = batch_crop.cropTasks.emptyTask(); - task.sourceId = sourceId; - tasks(end + 1, 1) = task; - images{end + 1, 1} = []; - else - for taskIndex = matchingTasks(:).' - tasks(end + 1, 1) = existingTasks(taskIndex); - if taskIndex <= numel(existingImages) - images{end + 1, 1} = existingImages{taskIndex}; - else - images{end + 1, 1} = []; - end - end - end - end -end - -function sourceId = nextSourceId(existingSources, newSources) - ids = [string({existingSources.id}), string({newSources.id})]; - number = numel(ids) + 1; - sourceId = "image" + string(number); - while any(ids == sourceId) - number = number + 1; - sourceId = "image" + string(number); - end -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectIndex.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectIndex.m new file mode 100644 index 000000000..8d9771670 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectIndex.m @@ -0,0 +1,19 @@ +% App-owned implementation for batch_crop.sourceFiles.selectIndex within the batch_crop product workflow. +function applicationState = selectIndex( ... + applicationState, index, callbackContext) +items = applicationState.project.inputs.items; +if isempty(items) + applicationState.session.selection.currentIndex = 0; + return +end +applicationState.session.selection.currentIndex = ... + min(max(round(double(index)), 1), numel(items)); +applicationState.session.workflow.scaleReferenceEditing = false; +applicationState.session.view.scaleBar = []; +[applicationState, loaded] = batch_crop.sourceFiles.loadCurrent( ... + applicationState, callbackContext); +if loaded + applicationState = batch_crop.cropGeometry.ensureCurrentCenter( ... + applicationState); +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectionChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectionChanged.m new file mode 100644 index 000000000..7b76a4d71 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectionChanged.m @@ -0,0 +1,52 @@ +% App-owned implementation for batch_crop.sourceFiles.selectionChanged within the batch_crop product workflow. +function applicationState = selectionChanged( ... + applicationState, selection, callbackContext) +%SELECTIONCHANGED Reconcile durable crop tasks after source-list changes. +sources = applicationState.project.inputs.sources; +items = applicationState.project.inputs.items; +wasEmpty = isempty(items); +sourceIds = string({sources.id}); +retained = repmat(batch_crop.cropTasks.emptyTask(), 0, 1); +for k = 1:numel(sourceIds) + match = find(string({items.sourceId}) == sourceIds(k), 1); + if isempty(match) + task = batch_crop.cropTasks.emptyTask(); + task.sourceId = sourceIds(k); + else + task = items(match); + end + retained(end + 1, 1) = task; +end +applicationState.project.inputs.items = retained; +applicationState.project.results = ... + batch_crop.resultFiles.clearExportState(applicationState.project.results); +if strlength(applicationState.project.parameters.outputFolder) == 0 && ... + ~isempty(sources) + resolved = callbackContext.resolveSourcePaths(sources); + if ~isempty(resolved) && strlength(resolved(1)) > 0 + applicationState.project.parameters.outputFolder = ... + string(fullfile(fileparts(resolved(1)), "batch_crop")); + end +end +applicationState.session = batch_crop.createSession( ... + applicationState.project, callbackContext); +if wasEmpty + applicationState.session.workflow.cropDefaultsInitialized = false; +end +if ~isempty(selection.Indices) + selectedSource = sourceIds(selection.Indices(1)); + match = find(string({retained.sourceId}) == selectedSource, 1); + if ~isempty(match) + applicationState.session.selection.currentIndex = match; + end +end +[applicationState, loaded] = batch_crop.sourceFiles.loadCurrent( ... + applicationState, callbackContext); +if loaded + applicationState = batch_crop.cropGeometry.ensureCurrentCenter( ... + applicationState); + applicationState = batch_crop.cropGeometry.initializeCropDefaults( ... + applicationState); +end +callbackContext.appendStatus("Crop tasks: " + string(numel(retained)) + "."); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/taskEntries.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/taskEntries.m new file mode 100644 index 000000000..0acf190f3 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/taskEntries.m @@ -0,0 +1,26 @@ +% App-owned file-list view helper. Expected caller: batch-crop runner refresh +% logic and package tests. Inputs are crop items and the current scale mode. +% Output is filePanel entry structs with user-facing workflow status labels. +function entries = taskEntries(items, scaleMode) +%FILEPANELENTRIES Build filePanel entries with crop readiness status labels. + + entries = repmat(struct('path', "", 'status', ""), numel(items), 1); + physicalMode = strcmpi(string(scaleMode), "Physical"); + for k = 1:numel(items) + entries(k).path = string(items(k).path); + entries(k).status = itemStatus(items(k), physicalMode); + end +end + +function status = itemStatus(item, physicalMode) + if ~isfield(item, 'centerSet') || ~logical(item.centerSet) + status = "needs center"; + return; + end + if physicalMode && ... + ~batch_crop.scaleCalibration.isSet(item.scaleCalibration) + status = "needs scale"; + return; + end + status = "ready"; +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/workingItems.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/workingItems.m index 001d19041..8090b82d0 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/workingItems.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/workingItems.m @@ -1,9 +1,9 @@ % App-owned source/task adapter. Expected callers: Batch Crop presentation and % export paths. Inputs are durable crop tasks and the parallel session image % cache. Output is the established algorithm-facing item struct vector. -function items = workingItems(tasks, images, sources) +function items = workingItems(tasks, images, paths) if nargin < 3 - sources = labkit.ui.runtime.emptySourceRecords(); + paths = strings(numel(tasks), 1); end items = repmat(batch_crop.sourceFiles.emptyItem(), numel(tasks), 1); for k = 1:numel(tasks) @@ -14,20 +14,11 @@ items(k).(name) = tasks(k).(name); end end - items(k).path = sourcePath(tasks(k), sources); + if k <= numel(paths) + items(k).path = string(paths(k)); + end if k <= numel(images) items(k).image = images{k}; end end end - -function path = sourcePath(task, sources) - path = ""; - if ~isfield(task, 'sourceId') || isempty(sources) - return; - end - match = find(string({sources.id}) == string(task.sourceId), 1, 'first'); - if ~isempty(match) - path = labkit.ui.runtime.sourcePaths(sources(match)); - end -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index c4a2344d3..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,223 +0,0 @@ -% Expected caller: batch_crop.definition. Inputs are V2 callbacks and -% canonical state. Output is a data-only UI 5 workbench layout with durable -% global preferences bound to project parameters. -function layout = buildWorkbenchLayout(callbacks, state) - initialOutputFolder = state.project.parameters.outputFolder; - - layout = labkit.ui.layout.workbench("batchCropApp", ... - "Microscope Batch Image Crop", ... - "controlTabs", controlTabs(initialOutputFolder, callbacks), ... - "workspace", previewWorkspace()); -end - -function tabs = controlTabs(initialOutputFolder, callbacks) - tabs = { ... - filesAnalysisTab(initialOutputFolder, callbacks), ... - scaleTab(callbacks), ... - summaryResultsTab(), ... - logTab()}; -end - -function tab = filesAnalysisTab(initialOutputFolder, callbacks) - tab = labkit.ui.layout.tab("filesAnalysis", "Files + Analysis", { ... - imagesSection(callbacks), ... - cropGeometrySection(callbacks), ... - exportSection(initialOutputFolder, callbacks)}); -end - -function tab = scaleTab(callbacks) - tab = labkit.ui.layout.tab("scale", "Scale", { ... - scaleSettingsSection(), ... - scaleBarSection(callbacks)}); -end - -function section = scaleBarSection(callbacks) - section = labkit.ui.layout.section("scaleBarSection", "Current Image Scale", { ... - labkit.ui.layout.action("measureScaleReference", ... - "Measure reference pixels", callbacks.measureScaleReference, "enabled", false), ... - cropPanner("scaleReferencePixels", "Reference pixels:", 0, ... - [0 5000], 1, false, "scaleCalibrationFieldChanged", callbacks), ... - cropPanner("scaleReferenceLength", "Reference length:", 100, ... - [0 1e6], 10, false, "scaleCalibrationFieldChanged", callbacks), ... - labkit.ui.layout.field("scaleCalibrationUnit", "Scale unit:", ... - "kind", "dropdown", ... - "items", {'m', 'cm', 'mm', 'um', 'nm'}, ... - "value", "um", ... - "enabled", false, ... - "onChange", callbacks.scaleCalibrationFieldChanged), ... - boundPanner("scaleBarLength", "Scale bar length:", 100, ... - [0 1e6], 10, false, "project.parameters.scaleBarLength", ... - "scaleBarSettingChanged"), ... - boundDropdown("scaleBarPosition", "Scale position:", ... - {'Bottom center', 'Bottom left', 'Bottom right', ... - 'Top center', 'Top left', 'Top right'}, "Bottom right", ... - "project.parameters.scaleBarPosition", ... - "scaleBarSettingChanged", false), ... - boundDropdown("scaleBarColor", "Scale color:", ... - {'Black', 'White'}, "Black", ... - "project.parameters.scaleBarColor", ... - "scaleBarSettingChanged", false), ... - labkit.ui.layout.action("placeScaleBar", ... - "Place scale bar", callbacks.placeScaleBar, "enabled", false), ... - labkit.ui.layout.field("scaleReferenceReadout", "Reference px:", ... - "kind", "readonly", "value", "-"), ... - labkit.ui.layout.field("pixelsPerUnitReadout", "Pixels/unit:", ... - "kind", "readonly", "value", "-")}); -end - -function section = scaleSettingsSection() - section = labkit.ui.layout.section("scaleSettings", "Scale Mode", { ... - labkit.ui.layout.field("scaleMode", "Mode:", ... - "kind", "dropdown", ... - "items", {'Pixels', 'Physical'}, ... - "value", "Pixels", ... - "Bind", "project.parameters.scaleMode", ... - "Event", "scaleSettingChanged"), ... - labkit.ui.layout.field("scaleUnit", "Unit:", ... - "kind", "dropdown", ... - "items", {'m', 'cm', 'mm', 'um', 'nm'}, ... - "value", "um", ... - "Bind", "project.parameters.scaleUnit", ... - "Event", "scaleSettingChanged"), ... - boundPanner("physicalWidth", "Width:", 100, [eps 1e6], 1, ... - false, "project.parameters.physicalWidth", "scaleSettingChanged"), ... - boundPanner("physicalHeight", "Height:", 100, [eps 1e6], 1, ... - false, "project.parameters.physicalHeight", "scaleSettingChanged"), ... - boundPanner("targetPixelsPerUnit", "Target px/unit (0=auto):", ... - 0, [0 1e6], 1, false, ... - "project.parameters.targetPixelsPerUnit", "scaleSettingChanged"), ... - boundPanner("maxUpsamplePercent", "Max upsample warning (%):", ... - 15, [0 10000], 1, false, ... - "project.parameters.maxUpsamplePercent", "scaleSettingChanged"), ... - labkit.ui.layout.field("scaleStatus", "Status", ... - "kind", "readonly", ... - "value", "Pixel mode: output size uses crop width/height in px.")}); -end - -function tab = summaryResultsTab() - tab = labkit.ui.layout.tab("summaryResults", "Summary + Results", { ... - labkit.ui.layout.section("summarySection", "Summary", { ... - labkit.ui.layout.resultTable("resultTable", ... - "Batch Results", ... - "columns", {'Metric', 'Value'}, ... - "data", {'Images loaded', '0'}), ... - labkit.ui.layout.statusPanel("details", "Details", ... - "value", {'Load microscope images to begin.'})})}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log", ... - "value", {'Ready.'})})}); -end - -function section = imagesSection(callbacks) - section = labkit.ui.layout.section("imagesSection", "Images", { ... - labkit.ui.layout.filePanel("images", "Crop images", ... - "selectionMode", "single", ... - "chooseLabel", "Add images or folder", ... - "clearLabel", "Clear images", ... - "filters", labkit.image.fileDialogFilter(), ... - "dialogTitle", "Select microscope images", ... - "status", "No images loaded", ... - "emptyText", "No images loaded", ... - "onChoose", callbacks.imagesChosen, ... - "onRemove", callbacks.removeImages, ... - "onClear", callbacks.clearImages, ... - "onSelectionChange", callbacks.imageSelectionChanged), ... - labkit.ui.layout.group("imageCopyActions", "", { ... - labkit.ui.layout.action("duplicateImage", ... - "Duplicate image", callbacks.duplicateImage, "enabled", false)}), ... - labkit.ui.layout.group("imageNavigation", "", { ... - labkit.ui.layout.action("previousImage", ... - "Previous image", callbacks.previousImage, "enabled", false), ... - labkit.ui.layout.action("nextImage", ... - "Next image", callbacks.nextImage, "enabled", false)}), ... - labkit.ui.layout.field("imageSource", "Current image", ... - "kind", "readonly", ... - "value", "No images loaded"), ... - labkit.ui.layout.field("imageStatus", "Status", ... - "kind", "readonly", ... - "value", "Images: 0")}); -end - -function section = cropGeometrySection(callbacks) - section = labkit.ui.layout.section("cropGeometry", "Crop Geometry", { ... - boundPanner("cropWidth", "Width (px):", 1024, [1 100000], 1, ... - true, "project.parameters.cropWidth", "cropGeometryChanged"), ... - boundPanner("cropHeight", "Height (px):", 1024, [1 100000], 1, ... - true, "project.parameters.cropHeight", "cropGeometryChanged"), ... - cropPanner("rotation", "Rotation (deg):", 0, [-360 360], 0.1, ... - false, "rotationChanged", callbacks), ... - cropPanner("paddingPercent", "Padding (%):", 0, [0 200], 1, ... - false, "paddingChanged", callbacks), ... - cropPanner("centerX", "Center X:", 1, [1 100000], 1, ... - false, "centerChanged", callbacks), ... - cropPanner("centerY", "Center Y:", 1, [1 100000], 1, ... - false, "centerChanged", callbacks), ... - labkit.ui.layout.group("centerActions", "", { ... - labkit.ui.layout.action("useImageCenter", ... - "Use XY center", callbacks.useImageCenter, "enabled", false), ... - labkit.ui.layout.action("useImageXCenter", ... - "Use X center", callbacks.useImageXCenter, "enabled", false), ... - labkit.ui.layout.action("useImageYCenter", ... - "Use Y center", callbacks.useImageYCenter, "enabled", false)})}); -end - -function section = exportSection(initialOutputFolder, callbacks) - section = labkit.ui.layout.section("exportSection", "Export", { ... - labkit.ui.layout.field("format", "Format:", ... - "kind", "dropdown", ... - "items", {'PNG', 'TIFF', 'JPEG'}, ... - "value", "PNG", ... - "Bind", "project.parameters.format", ... - "Event", "exportSettingChanged"), ... - labkit.ui.layout.field("outputFolder", "Output folder", ... - "kind", "readonly", ... - "value", char(initialOutputFolder)), ... - labkit.ui.layout.group("exportActions", "", { ... - labkit.ui.layout.action("chooseOutputFolder", ... - "Choose export folder", callbacks.chooseOutputFolder), ... - labkit.ui.layout.action("exportCrops", ... - "Export cropped images", callbacks.exportCrops, "enabled", false)})}); -end - -function workspace = previewWorkspace() - workspace = labkit.ui.layout.workspace("previewWorkspace", ... - "Crop Preview", { ... - labkit.ui.layout.previewArea("preview", ... - "Padded rotation preview + fixed crop", ... - "layout", "single", ... - "axisIds", {'crop'}, ... - "axisTitles", {'Padded rotation preview + fixed crop'})}); -end - -function layoutNode = cropPanner(id, labelText, value, limits, step, enabled, ... - callbackName, callbacks) - args = {"value", value, ... - "limits", limits, ... - "step", step}; - if ~enabled - args = [args, {"enabled", false}]; - end - args = [args, {"onChange", callbacks.(char(callbackName))}]; - layoutNode = labkit.ui.layout.panner(id, labelText, args{:}); -end - -function layoutNode = boundPanner(id, labelText, value, limits, step, ... - enabled, path, eventId) - args = {"value", value, "limits", limits, "step", step, ... - "Bind", path, "Event", eventId}; - if ~enabled - args = [args, {"enabled", false}]; - end - layoutNode = labkit.ui.layout.panner(id, labelText, args{:}); -end - -function layoutNode = boundDropdown(id, labelText, items, value, path, ... - eventId, enabled) - layoutNode = labkit.ui.layout.field(id, labelText, ... - "kind", "dropdown", "items", items, "value", value, ... - "enabled", enabled, "Bind", path, "Event", eventId); -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/capturePreviewView.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/capturePreviewView.m deleted file mode 100644 index 665362792..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/capturePreviewView.m +++ /dev/null @@ -1,37 +0,0 @@ -% App-owned preview view helper. Expected caller: batch-crop app redraw logic. -% Inputs are preview axes, current crop geometry, and preview placement. Output -% is a source-coordinate view state used to preserve zoom across redraws. -function state = capturePreviewView(previewAxes, geometry, placement) -%CAPTUREPREVIEWVIEW Capture current preview limits relative to source image. - - state = struct('valid', false); - if isempty(previewAxes) || ~isvalid(previewAxes) || ... - ~all(isfinite(previewAxes.XLim)) || ~all(isfinite(previewAxes.YLim)) - return; - end - - xLimitsCanvas = previewAxes.XLim - placement.offset(1); - yLimitsCanvas = previewAxes.YLim - placement.offset(2); - cornersCanvas = [ ... - xLimitsCanvas(1), yLimitsCanvas(1); ... - xLimitsCanvas(2), yLimitsCanvas(1); ... - xLimitsCanvas(2), yLimitsCanvas(2); ... - xLimitsCanvas(1), yLimitsCanvas(2)]; - originalCorners = zeros(4, 2); - for k = 1:4 - originalCorners(k, :) = batch_crop.cropGeometry.canvasToOriginal(geometry, ... - cornersCanvas(k, :)); - end - leftTop = originalCorners(1, :); - rightBottom = originalCorners(3, :); - originalXLim = sort([leftTop(1), rightBottom(1)]); - originalYLim = sort([leftTop(2), rightBottom(2)]); - state = struct( ... - 'valid', true, ... - 'centerOriginal', [mean(originalXLim), mean(originalYLim)], ... - 'originalCorners', originalCorners, ... - 'originalXLim', originalXLim, ... - 'originalYLim', originalYLim, ... - 'xSpan', diff(previewAxes.XLim), ... - 'ySpan', diff(previewAxes.YLim)); -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/detailLines.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/detailLines.m deleted file mode 100644 index 014211896..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/detailLines.m +++ /dev/null @@ -1,37 +0,0 @@ -% App-owned detail view helper. Expected caller: batch-crop app refreshSummary. -% Inputs are app state, current index, crop size, and padding percent. Output -% is a cell vector of text lines and has no side effects. -function lines = detailLines(state, currentIndex, cropWidth, cropHeight, paddingPercent) -%DETAILLINES Build detail text for the selected crop item. - - items = state.project.inputs.items; - if isempty(items) || currentIndex < 1 || currentIndex > numel(items) - lines = {'No images loaded.'}; - return; - end - - item = batch_crop.sourceFiles.workingItems( ... - items(currentIndex), state.session.cache.images(currentIndex), ... - state.project.inputs.sources); - stateText = ternary(item.centerSet, 'confirmed', 'needs confirmation'); - lines = { ... - sprintf('Image %d of %d: %s', currentIndex, numel(items), ... - labkit.image.displayName(item.path)), ... - sprintf('Crop center: x %.1f, y %.1f (%s)', ... - item.centerXY(1), item.centerXY(2), stateText), ... - sprintf('Output size: %d x %d px; rotation: %.3g deg; padding: %.3g%%', ... - cropWidth, cropHeight, item.angleDeg, paddingPercent)}; - lastExport = state.project.results.lastExport; - if ~isempty(lastExport) - lines{end+1} = sprintf( ... - 'Last manifest: %s', char(lastExport.manifestPath)); - end -end - -function value = ternary(condition, trueValue, falseValue) - if condition - value = trueValue; - else - value = falseValue; - end -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/filePanelEntries.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/filePanelEntries.m deleted file mode 100644 index c98b7d2bd..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/filePanelEntries.m +++ /dev/null @@ -1,26 +0,0 @@ -% App-owned file-list view helper. Expected caller: batch-crop runner refresh -% logic and package tests. Inputs are crop items and the current scale mode. -% Output is filePanel entry structs with user-facing workflow status labels. -function entries = filePanelEntries(items, scaleMode) -%FILEPANELENTRIES Build filePanel entries with crop readiness status labels. - - entries = repmat(struct('path', "", 'status', ""), numel(items), 1); - physicalMode = strcmpi(string(scaleMode), "Physical"); - for k = 1:numel(items) - entries(k).path = string(items(k).path); - entries(k).status = itemStatus(items(k), physicalMode); - end -end - -function status = itemStatus(item, physicalMode) - if ~isfield(item, 'centerSet') || ~logical(item.centerSet) - status = "needs center"; - return; - end - if physicalMode && ... - ~batch_crop.scaleCalibration.isSet(item.scaleCalibration) - status = "needs scale"; - return; - end - status = "ready"; -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m deleted file mode 100644 index de5fcc048..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,286 +0,0 @@ -% Expected caller: the LabKit V2 runtime. Input is canonical Batch Crop -% state. Output is a deterministic control, summary, preview, and controlled -% interaction presentation without UI registry access. -function view = presentWorkbench(state) - items = batch_crop.sourceFiles.workingItems( ... - state.project.inputs.items, state.session.cache.images, ... - state.project.inputs.sources); - parameters = state.project.parameters; - index = currentIndex(state); - hasImage = hasCurrentImage(state); - physicalMode = strcmpi(parameters.scaleMode, "Physical"); - cropSize = batch_crop.cropGeometry.currentCropSize(state); - padding = currentPadding(state); - - view = struct(); - view.controls.images = imageListSpec(items, index, parameters.scaleMode); - view.controls.imageSource = valueSpec(currentPath(items, index)); - view.controls.imageStatus = valueSpec(imageStatus(items, physicalMode)); - view.controls.duplicateImage = enabledSpec(hasImage); - view.controls.previousImage = enabledSpec(hasImage && index > 1); - view.controls.nextImage = enabledSpec(hasImage && index < numel(items)); - view.controls.rotation = controlSpec(hasImage, currentAngle(items, index)); - view.controls.paddingPercent = controlSpec(hasImage, padding); - view.controls.centerX = coordinateSpec(hasImage, state, 1); - view.controls.centerY = coordinateSpec(hasImage, state, 2); - view.controls.useImageCenter = enabledSpec(hasImage); - view.controls.useImageXCenter = enabledSpec(hasImage); - view.controls.useImageYCenter = enabledSpec(hasImage); - view.controls.cropWidth = sizeSpec(hasImage && ~physicalMode, ... - parameters.cropWidth, cropLimit(items, index)); - view.controls.cropHeight = sizeSpec(hasImage && ~physicalMode, ... - parameters.cropHeight, cropLimit(items, index)); - view.controls.scaleUnit = enabledSpec(physicalMode); - view.controls.physicalWidth = enabledSpec(hasImage && physicalMode); - view.controls.physicalHeight = enabledSpec(hasImage && physicalMode); - view.controls.targetPixelsPerUnit = enabledSpec(hasImage && physicalMode); - view.controls.maxUpsamplePercent = enabledSpec(hasImage && physicalMode); - view.controls.outputFolder = valueSpec(parameters.outputFolder); - view.controls.exportCrops = enabledSpec(hasImage); - view.controls.scaleStatus = valueSpec(batch_crop.userInterface.scaleStatusText( ... - state, index, parameters.scaleMode, ... - [parameters.physicalWidth, parameters.physicalHeight], ... - parameters.scaleUnit)); - view.controls.resultTable = struct(); - view.controls.resultTable.Data = ... - batch_crop.userInterface.summaryTableData(state, index, ... - cropSize(1), cropSize(2), padding, parameters.format); - view.controls.details = valueSpec(batch_crop.userInterface.detailLines( ... - state, index, cropSize(1), cropSize(2), padding)); - view = scaleControlPresentation(view, state, hasImage, physicalMode); - - model = emptyPreviewModel(); - if hasImage - [geometry, render] = previewGeometry(state); - item = items(index); - model.imageData = render.imageData; - model.xData = [1 size(geometry.canvas, 2)]; - model.yData = [1 size(geometry.canvas, 1)]; - model.center = batch_crop.cropGeometry.originalToCanvas( ... - geometry, item.centerXY); - model.cropRectangle = cropRectanglePosition( ... - geometry, item.centerXY, cropSize); - model.scaleBar = scaleBarOnCanvas(geometry, state.session.view.scaleBar); - if state.session.workflow.scaleReferenceEditing - view.interactions.scaleReference = scaleReferenceSpec(geometry, item); - else - view.interactions.cropCenter = cropCenterSpec( ... - geometry, item.centerXY); - end - end - view.previews.preview = struct("Renderer", "cropPreview", "Model", model); -end - -function view = scaleControlPresentation(view, state, hasImage, physicalMode) - editing = state.session.workflow.scaleReferenceEditing; - cal = batch_crop.scaleCalibration.emptyCalibration( ... - state.project.parameters.scaleUnit); - if hasImage - cal = state.project.inputs.items(currentIndex(state)).scaleCalibration; - end - referencePixels = cal.referencePixels; - referenceReadout = "-"; - if isfinite(referencePixels) - referenceReadout = sprintf('%.6g', referencePixels); - else - referencePixels = 0; - end - pixelsReadout = "-"; - if cal.pixelsPerUnit > 0 - pixelsReadout = sprintf('%.6g px/%s', cal.pixelsPerUnit, cal.unit); - end - inputsEnabled = hasImage && physicalMode && ~editing; - view.controls.measureScaleReference = struct( ... - "Enabled", hasImage && physicalMode, ... - "Text", batch_crop.userInterface.ternary(editing, ... - "Finish reference edit", "Measure reference pixels")); - view.controls.scaleReferencePixels = controlSpec(inputsEnabled, referencePixels); - view.controls.scaleReferenceLength = controlSpec( ... - hasImage && physicalMode, cal.referenceLength); - view.controls.scaleCalibrationUnit = controlSpec( ... - hasImage && physicalMode, cal.unit); - view.controls.scaleBarLength = enabledSpec(hasImage && physicalMode); - view.controls.scaleBarPosition = enabledSpec(hasImage && physicalMode); - view.controls.scaleBarColor = enabledSpec(hasImage && physicalMode); - view.controls.placeScaleBar = enabledSpec(hasImage && physicalMode && ... - cal.isCalibrated && ~editing); - view.controls.scaleReferenceReadout = valueSpec(referenceReadout); - view.controls.pixelsPerUnitReadout = valueSpec(pixelsReadout); -end - -function spec = imageListSpec(items, index, mode) - files = batch_crop.userInterface.filePanelEntries(items, mode); - for k = 1:numel(files) - files(k).id = "item" + string(k); - end - selection = strings(0, 1); - if index >= 1 && index <= numel(items) - selection = "item" + string(index); - end - spec = struct("Files", files, "Selection", selection, ... - "Status", imageStatus(items, strcmpi(mode, "Physical"))); -end - -function value = imageStatus(items, physicalMode) - if isempty(items) - value = "No images loaded"; - return; - end - if physicalMode - summary = batch_crop.scaleCalibration.summarize(items); - value = sprintf('Images: %d | centers: %d | scales: %d', ... - numel(items), batch_crop.cropTasks.countConfirmedCenters(items), ... - summary.calibratedCount); - else - value = sprintf('Images: %d | confirmed centers: %d', ... - numel(items), batch_crop.cropTasks.countConfirmedCenters(items)); - end -end - -function position = cropRectanglePosition(geometry, center, cropSize) - scale = batch_crop.cropGeometry.geometryScale(geometry); - width = max(1, double(cropSize(1)) * scale); - height = max(1, double(cropSize(2)) * scale); - canvasCenter = batch_crop.cropGeometry.originalToCanvas(geometry, center); - position = [round(canvasCenter(1) - (width - 1) / 2) - 0.5, ... - round(canvasCenter(2) - (height - 1) / 2) - 0.5, width, height]; -end - -function spec = cropCenterSpec(geometry, center) - canvasCenter = batch_crop.cropGeometry.originalToCanvas(geometry, center); - value = struct("points", canvasCenter, "selectedIndex", 1, ... - "locked", false); - spec = struct("Kind", "pointSlots", "Targets", "preview", ... - "Value", value, "Event", "cropCenterEdited", ... - "ImageSize", size(geometry.canvas), ... - "ChangePolicy", "commit", ... - "Options", struct("color", [0.05 0.45 0.95], ... - "selectedColor", [1 0.9 0.15], ... - "placeSelectedOnBackground", true)); -end - -function spec = scaleReferenceSpec(geometry, item) - points = item.scaleCalibration.referenceLine; - if ~isempty(points) - for k = 1:size(points, 1) - points(k, :) = batch_crop.cropGeometry.originalToCanvas( ... - geometry, points(k, :)); - end - end - spec = struct("Kind", "scaleBarReference", "Targets", "preview", ... - "Value", points, "Event", "scaleReferenceEdited", ... - "ImageSize", size(geometry.canvas), ... - "ChangePolicy", "commit", ... - "Options", struct("color", [1 1 0])); -end - -function [geometry, render] = previewGeometry(state) - index = currentIndex(state); - item = batch_crop.sourceFiles.workingItems( ... - state.project.inputs.items(index), state.session.cache.images(index), ... - state.project.inputs.sources); - [geometry, ~] = batch_crop.cropGeometry.currentGeometry( ... - state.session.cache.canvas, index, item, item.paddingPercent); - placement = struct("offset", [0 0], ... - "xData", [1 size(geometry.canvas, 2)], ... - "yData", [1 size(geometry.canvas, 1)]); - render = batch_crop.userInterface.previewRenderData(geometry, placement); -end - -function value = scaleBarOnCanvas(geometry, scaleBar) - value = scaleBar; - if isempty(value) - return; - end - for k = 1:size(value.line, 1) - value.line(k, :) = batch_crop.cropGeometry.originalToCanvas( ... - geometry, value.line(k, :)); - end - value.labelPosition = batch_crop.cropGeometry.originalToCanvas( ... - geometry, value.labelPosition); -end - -function value = currentPadding(state) - value = 0; - if hasCurrentImage(state) - value = state.project.inputs.items(currentIndex(state)).paddingPercent; - end -end - -function spec = coordinateSpec(enabled, state, dimension) - value = 1; - limits = [1 100000]; - if enabled - item = state.project.inputs.items(currentIndex(state)); - value = item.centerXY(dimension); - [geometry, ~] = previewGeometry(state); - scale = batch_crop.cropGeometry.geometryScale(geometry); - if dimension == 1 - limits = [1 - double(geometry.padding.left) / scale, ... - double(geometry.sourceWidth) + double(geometry.padding.right) / scale]; - else - limits = [1 - double(geometry.padding.top) / scale, ... - double(geometry.sourceHeight) + double(geometry.padding.bottom) / scale]; - end - end - spec = struct("Enabled", enabled, "Value", value, "Limits", limits); -end - -function value = cropLimit(items, index) - value = 100000; - if index >= 1 && index <= numel(items) && ~isempty(items(index).image) - imageData = items(index).image; - value = max(1, ceil(2 .* hypot(double(size(imageData, 2)), ... - double(size(imageData, 1))))); - end -end - -function value = currentAngle(items, index) - value = 0; - if index >= 1 && index <= numel(items) - value = items(index).angleDeg; - end -end - -function value = currentPath(items, index) - value = "No images loaded"; - if index >= 1 && index <= numel(items) - value = items(index).path; - end -end - -function spec = sizeSpec(enabled, value, upper) - spec = struct("Enabled", enabled, "Value", value, ... - "Limits", [1 upper]); -end - -function spec = controlSpec(enabled, value) - spec = struct("Enabled", enabled, "Value", value); -end - -function spec = enabledSpec(enabled) - spec = struct("Enabled", logical(enabled)); -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function model = emptyPreviewModel() - model = struct("imageData", [], "xData", [1 1], "yData", [1 1], ... - "center", [1 1], "cropRectangle", [], "scaleBar", [], ... - "title", "Padded rotation preview + fixed crop"); -end - -function index = currentIndex(state) - index = state.session.selection.currentIndex; -end - -function tf = hasCurrentImage(state) - index = currentIndex(state); - tf = ~isempty(state.project.inputs.items) && index >= 1 && ... - index <= numel(state.project.inputs.items) && ... - index <= numel(state.session.cache.images) && ... - ~isempty(state.session.cache.images{index}); -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/previewPlacement.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/previewPlacement.m deleted file mode 100644 index 02d5d9d87..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/previewPlacement.m +++ /dev/null @@ -1,25 +0,0 @@ -% Expected caller: batch crop UI preview drawing and view-capture paths. -% Input is one crop-canvas geometry struct. Output is display placement data -% used to align padded canvas coordinates to source-image coordinates. -function placement = previewPlacement(geometry) -%PREVIEWPLACEMENT Compute preview x/y data and canvas offset. - - sourceCenter = batch_crop.cropGeometry.sourceCenterFromSize( ... - geometry.sourceWidth, geometry.sourceHeight); - canvasCenter = batch_crop.cropGeometry.originalToCanvas(geometry, sourceCenter); - displayCenter = originalToPreviewSource(geometry, sourceCenter); - offset = displayCenter - canvasCenter; - placement = struct( ... - 'offset', offset, ... - 'xData', [1, size(geometry.canvas, 2)] + offset(1), ... - 'yData', [1, size(geometry.canvas, 1)] + offset(2)); -end - -function point = originalToPreviewSource(geometry, point) - scale = 1; - if isfield(geometry, 'coordinateScale') && isfinite(double(geometry.coordinateScale)) && ... - double(geometry.coordinateScale) > 0 - scale = double(geometry.coordinateScale); - end - point = (point - 0.5) .* scale + 0.5; -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/previewRenderData.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/previewRenderData.m deleted file mode 100644 index c6cd53193..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/previewRenderData.m +++ /dev/null @@ -1,39 +0,0 @@ -% App-owned preview rendering helper. Expected caller: batch-crop preview -% redraw logic. Inputs are the full crop geometry and placement metadata. -% Output preserves full canvas coordinate extents while optionally lowering -% preview CData resolution for responsive GUI rendering. -function render = previewRenderData(geometry, placement, opts) -%PREVIEWRENDERDATA Prepare a lightweight preview image for axes rendering. - - if nargin < 3 - opts = struct(); - end - - maxPreviewPixels = double(optionValue(opts, 'MaxPreviewPixels', ... - defaultPreviewPixels())); - if ~isfinite(maxPreviewPixels) || maxPreviewPixels < 1 - maxPreviewPixels = defaultPreviewPixels(); - end - - [canvas, info] = labkit.image.previewBudget(geometry.canvas, ... - "MaxPixels", maxPreviewPixels); - - render = struct( ... - 'imageData', canvas, ... - 'xData', placement.xData, ... - 'yData', placement.yData, ... - 'scaleFactor', info.scaleFactor); -end - -function value = defaultPreviewPixels() - % Constant: 1.2 megapixels balances draggable preview responsiveness - % with sufficient crop-placement detail. - value = 1.2e6; -end - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isstruct(opts) && isfield(opts, name) - value = opts.(name); - end -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/renderCropPreview.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/renderCropPreview.m deleted file mode 100644 index 7e6ca83d0..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/renderCropPreview.m +++ /dev/null @@ -1,66 +0,0 @@ -% Expected caller: the registered Batch Crop V2 renderer. Inputs are a -% target axes and prepared preview/crosshair/scale-bar model. Side effects -% are limited to the supplied axes. -function renderCropPreview(ax, model) - labkit.ui.plot.clear(ax, "ResetScale", true); - if isempty(model.imageData) - title(ax, char(model.title)); - xlabel(ax, ''); - ylabel(ax, ''); - box(ax, 'on'); - return; - end - if ndims(model.imageData) == 2 - imagesc(ax, model.xData, model.yData, model.imageData); - colormap(ax, gray(256)); - else - image(ax, model.xData, model.yData, model.imageData); - end - axis(ax, 'image'); - ax.YDir = 'reverse'; - hold(ax, 'on'); - center = model.center; - plot(ax, [center(1) - 16, center(1) + 16], [center(2), center(2)], ... - 'Color', [0 0.85 1], 'LineWidth', 1.25, ... - 'HitTest', 'off', 'PickableParts', 'none'); - plot(ax, [center(1), center(1)], [center(2) - 16, center(2) + 16], ... - 'Color', [0 0.85 1], 'LineWidth', 1.25, ... - 'HitTest', 'off', 'PickableParts', 'none'); - drawCropRoi(ax, model.cropRectangle); - drawScaleBar(ax, model.scaleBar); - hold(ax, 'off'); - title(ax, char(model.title)); - xlabel(ax, ''); - ylabel(ax, ''); - box(ax, 'on'); -end - -function drawCropRoi(ax, position) - if numel(position) ~= 4 || any(~isfinite(position)) || ... - any(position(3:4) <= 0) - return; - end - color = [1 0.9 0.15]; - rectangle(ax, 'Position', position, 'EdgeColor', color, ... - 'LineStyle', '-', 'LineWidth', 2, ... - 'HitTest', 'off', 'PickableParts', 'none'); - text(ax, position(1), max(0.5, position(2) - 3), 'Crop ROI', ... - 'Color', color, 'FontWeight', 'bold', ... - 'BackgroundColor', [0 0 0], 'Margin', 2, ... - 'HitTest', 'off', 'PickableParts', 'none'); -end - -function drawScaleBar(ax, scaleBar) - if isempty(scaleBar) - return; - end - plot(ax, scaleBar.line(:, 1), scaleBar.line(:, 2), '-', ... - 'Color', scaleBar.color, 'LineWidth', 3, ... - 'HitTest', 'off', 'PickableParts', 'none', ... - 'DisplayName', 'scale bar'); - text(ax, scaleBar.labelPosition(1), scaleBar.labelPosition(2), ... - scaleBar.label, 'Color', scaleBar.color, 'FontWeight', 'bold', ... - 'HorizontalAlignment', 'center', ... - 'VerticalAlignment', char(scaleBar.verticalAlignment), ... - 'HitTest', 'off', 'PickableParts', 'none'); -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/restorePreviewView.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/restorePreviewView.m deleted file mode 100644 index 83f2bb472..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/restorePreviewView.m +++ /dev/null @@ -1,107 +0,0 @@ -% App-owned preview view helper. Expected caller: batch-crop app redraw logic. -% Inputs are preview axes, a captured view state, crop geometry, and placement. -% Side effect is limited to restoring axes limits when the state is valid. -function restorePreviewView(previewAxes, state, geometry, placement) -%RESTOREPREVIEWVIEW Restore source-coordinate preview limits after redraw. - - if isempty(state) || ~isstruct(state) || ~isfield(state, 'valid') || ~state.valid - return; - end - if ~hasOriginalLimits(state) && (~isfinite(state.xSpan) || ~isfinite(state.ySpan) || ... - state.xSpan <= 0 || state.ySpan <= 0) - return; - end - - fullXLimits = imageDataLimits(placement.xData, size(geometry.canvas, 2)); - fullYLimits = imageDataLimits(placement.yData, size(geometry.canvas, 1)); - if hasOriginalCorners(state) - cornersCanvas = zeros(size(state.originalCorners)); - for k = 1:size(state.originalCorners, 1) - cornersCanvas(k, :) = batch_crop.cropGeometry.originalToCanvas(geometry, ... - state.originalCorners(k, :)) + placement.offset; - end - previewAxes.XLim = clampLimits([min(cornersCanvas(:, 1)), ... - max(cornersCanvas(:, 1))], fullXLimits); - previewAxes.YLim = clampLimits([min(cornersCanvas(:, 2)), ... - max(cornersCanvas(:, 2))], fullYLimits); - return; - end - if hasOriginalLimits(state) - previewAxes.XLim = clampLimits(originalAxisLimits(geometry, placement, ... - state.originalXLim, 1), fullXLimits); - previewAxes.YLim = clampLimits(originalAxisLimits(geometry, placement, ... - state.originalYLim, 2), fullYLimits); - return; - end - - centerCanvas = batch_crop.cropGeometry.originalToCanvas(geometry, state.centerOriginal) + ... - placement.offset; - previewAxes.XLim = centeredLimits(centerCanvas(1), state.xSpan, fullXLimits); - previewAxes.YLim = centeredLimits(centerCanvas(2), state.ySpan, fullYLimits); -end - -function tf = hasOriginalCorners(state) - tf = isfield(state, 'originalCorners') && isnumeric(state.originalCorners) && ... - isequal(size(state.originalCorners), [4 2]) && ... - all(isfinite(state.originalCorners), "all"); -end - -function tf = hasOriginalLimits(state) - tf = isfield(state, 'originalXLim') && isfield(state, 'originalYLim') && ... - numel(state.originalXLim) == 2 && numel(state.originalYLim) == 2 && ... - all(isfinite(state.originalXLim)) && all(isfinite(state.originalYLim)) && ... - diff(state.originalXLim) > 0 && diff(state.originalYLim) > 0; -end - -function limits = originalAxisLimits(geometry, placement, originalLimits, axisIndex) - if axisIndex == 1 - firstPoint = [originalLimits(1), geometry.sourceHeight / 2]; - secondPoint = [originalLimits(2), geometry.sourceHeight / 2]; - else - firstPoint = [geometry.sourceWidth / 2, originalLimits(1)]; - secondPoint = [geometry.sourceWidth / 2, originalLimits(2)]; - end - firstCanvas = batch_crop.cropGeometry.originalToCanvas(geometry, firstPoint) + placement.offset; - secondCanvas = batch_crop.cropGeometry.originalToCanvas(geometry, secondPoint) + placement.offset; - limits = sort([firstCanvas(axisIndex), secondCanvas(axisIndex)]); -end - -function limits = clampLimits(limits, fullLimits) - span = diff(limits); - fullSpan = diff(fullLimits); - if span >= fullSpan - limits = fullLimits; - return; - end - if limits(1) < fullLimits(1) - limits = [fullLimits(1), fullLimits(1) + span]; - end - if limits(2) > fullLimits(2) - limits = [fullLimits(2) - span, fullLimits(2)]; - end -end - -function limits = centeredLimits(center, span, fullLimits) - fullSpan = diff(fullLimits); - if span >= fullSpan - limits = fullLimits; - return; - end - limits = center + [-0.5, 0.5] .* span; - if limits(1) < fullLimits(1) - limits = [fullLimits(1), fullLimits(1) + span]; - end - if limits(2) > fullLimits(2) - limits = [fullLimits(2) - span, fullLimits(2)]; - end -end - -function limits = imageDataLimits(data, count) - data = double(data(:)).'; - if numel(data) < 2 || count <= 1 - limits = data(1) + [-0.5, 0.5]; - return; - end - step = abs(diff(data(1:2))) / max(1, count - 1); - limits = sort(data(1:2)) + [-0.5, 0.5] .* step; -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/scaleStatusText.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/scaleStatusText.m deleted file mode 100644 index 001b29158..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/scaleStatusText.m +++ /dev/null @@ -1,33 +0,0 @@ -% App-owned scale status view helper. Expected caller: batch-crop app summary -% refresh. Inputs are state, current index, mode, physical size, and unit. -% Output is display text only. -function text = scaleStatusText(state, currentIndex, mode, physicalSize, unitName) -%SCALESTATUSTEXT Build the Scale tab status line. - - if ~strcmpi(string(mode), "Physical") - text = 'Pixel mode: output size uses crop width/height in px.'; - return; - end - - items = state.project.inputs.items; - if isempty(items) - text = sprintf('Physical mode: set %.6g x %.6g %s and load images.', ... - physicalSize(1), physicalSize(2), char(string(unitName))); - return; - end - - scaleSummary = batch_crop.scaleCalibration.summarize(items); - item = items(currentIndex); - cal = item.scaleCalibration; - if batch_crop.scaleCalibration.isSet(cal) - cropPixelsPerUnit = batch_crop.cropGeometry.pixelsPerUnitForUnit(cal, unitName); - text = sprintf(['Physical mode: crop %.6g x %.6g %s; image %d scale %.6g px/%s ' ... - '(%.6g px/%s for crop); calibrated %d/%d.'], ... - physicalSize(1), physicalSize(2), char(string(unitName)), ... - currentIndex, cal.pixelsPerUnit, cal.unit, cropPixelsPerUnit, ... - char(string(unitName)), scaleSummary.calibratedCount, scaleSummary.total); - else - text = sprintf('Physical mode: image %d needs scale; calibrated %d/%d.', ... - currentIndex, scaleSummary.calibratedCount, scaleSummary.total); - end -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/summaryTableData.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/summaryTableData.m deleted file mode 100644 index 1254ad81d..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/summaryTableData.m +++ /dev/null @@ -1,41 +0,0 @@ -% App-owned summary view helper. Expected caller: batch-crop app refreshSummary. -% Inputs are app state, current index, crop size, padding percent, and output -% format. Output is metric/value cell data and has no side effects. -function data = summaryTableData(state, currentIndex, cropWidth, cropHeight, paddingPercent, outputFormat) -%SUMMARYTABLEDATA Build the batch crop summary table cell data. - - items = state.project.inputs.items; - outputFolder = state.project.parameters.outputFolder; - if isempty(items) || currentIndex < 1 || currentIndex > numel(items) - data = { ... - 'Images loaded', '0'; ... - 'Crop size', sprintf('%d x %d px', cropWidth, cropHeight); ... - 'Aspect ratio', aspectRatioText(cropWidth, cropHeight); ... - 'Padding', sprintf('%.3g%% repaired reflect', paddingPercent); ... - 'Confirmed centers', '0'; ... - 'Output folder', char(outputFolder)}; - return; - end - - item = batch_crop.sourceFiles.workingItems( ... - items(currentIndex), state.session.cache.images(currentIndex), ... - state.project.inputs.sources); - data = { ... - 'Images loaded', sprintf('%d', numel(items)); ... - 'Current image', char(labkit.image.displayName(item.path)); ... - 'Crop size', sprintf('%d x %d px', cropWidth, cropHeight); ... - 'Aspect ratio', aspectRatioText(cropWidth, cropHeight); ... - 'Rotation', sprintf('%.3g deg', item.angleDeg); ... - 'Padding', sprintf('%.3g%% repaired reflect', paddingPercent); ... - 'Center', sprintf('x %.1f, y %.1f', item.centerXY(1), item.centerXY(2)); ... - 'Source image', sprintf('%d x %d px', size(item.image, 2), size(item.image, 1)); ... - 'Confirmed centers', sprintf('%d / %d', ... - batch_crop.cropTasks.countConfirmedCenters(items), numel(items)); ... - 'Output format', char(outputFormat); ... - 'Output folder', char(outputFolder)}; -end - -function text = aspectRatioText(width, height) - g = gcd(width, height); - text = sprintf('%d:%d', width / g, height / g); -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/ternary.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/ternary.m deleted file mode 100644 index 5ebdf675b..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/ternary.m +++ /dev/null @@ -1,12 +0,0 @@ -% Expected caller: batch_crop UI refresh helpers. Inputs are one scalar -% condition and two candidate values. Output is trueValue when condition is -% true, otherwise falseValue. No state is read or changed. -function value = ternary(condition, trueValue, falseValue) -%TERNARY Select one of two UI values from a logical condition. - - if condition - value = trueValue; - return; - end - value = falseValue; -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+workbench/buildLayout.m b/apps/image_measurement/batch_crop/+batch_crop/+workbench/buildLayout.m new file mode 100644 index 000000000..eedd302a4 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+workbench/buildLayout.m @@ -0,0 +1,36 @@ +% App-owned implementation for batch_crop.workbench.buildLayout within the batch_crop product workflow. +function layout = buildLayout() +%BUILDLAYOUT Assemble source selection, crop geometry, scale, and exports. +controls = { ... + labkit.app.layout.tab("filesAnalysis", "Files + Analysis", { ... + batch_crop.sourceFiles.layoutSection(), ... + batch_crop.cropGeometry.layoutSection(), ... + batch_crop.resultFiles.layoutSection()}), ... + labkit.app.layout.tab("scale", "Scale", ... + batch_crop.scaleCalibration.layoutSections()), ... + labkit.app.layout.tab("summaryResults", "Summary + Results", { ... + labkit.app.layout.section("summarySection", "Summary", { ... + labkit.app.layout.dataTable("resultTable", ... + Title="Batch Results", Columns=["Metric", "Value"]), ... + labkit.app.layout.statusPanel("details", Title="Details")})}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("appLog", Title="Log")})})}; +crop = labkit.app.interaction.pointSlots("cropCenter", ... + @batch_crop.cropGeometry.changeCenterFromPreview, Axis="main", ... + Style=struct("color", [0.05 0.45 0.95], ... + "selectedColor", [1 0.9 0.15], ... + "placeSelectedOnBackground", true), ... + ViewportPolicy="preserve"); +scale = labkit.app.interaction.scaleReference("scaleReference", ... + @batch_crop.scaleCalibration.changeReference, Axis="main", ... + Style=struct("color", [1 1 0]), ... + ViewportPolicy="preserve"); +workspace = labkit.app.layout.workspace( ... + labkit.app.layout.plotArea("preview", ... + @batch_crop.cropPreview.draw, ... + Title="Padded rotation preview + fixed crop", ... + Interactions={crop, scale}), ... + Title="Crop Preview"); +layout = labkit.app.layout.workbench(controls, Workspace=workspace); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+workbench/present.m b/apps/image_measurement/batch_crop/+batch_crop/+workbench/present.m new file mode 100644 index 000000000..c081ed71a --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+workbench/present.m @@ -0,0 +1,23 @@ +% App-owned implementation for batch_crop.workbench.present within the batch_crop product workflow. +function view = present(applicationState) +%PRESENT Compose the complete Batch Crop semantic snapshot. +index = batch_crop.sourceFiles.currentIndex(applicationState); +cropSize = batch_crop.cropGeometry.currentCropSize(applicationState); +padding = 0; +if batch_crop.sourceFiles.hasCurrentImage(applicationState) + padding = applicationState.project.inputs.items(index).paddingPercent; +end +view = batch_crop.sourceFiles.present(applicationState) ... + .include(batch_crop.cropGeometry.present(applicationState)) ... + .include(batch_crop.scaleCalibration.present(applicationState)) ... + .include(batch_crop.resultFiles.present(applicationState)) ... + .include(batch_crop.cropPreview.present(applicationState)) ... + .tableData("resultTable", ... + batch_crop.resultFiles.summaryTableData( ... + applicationState, index, cropSize(1), cropSize(2), ... + padding, applicationState.project.parameters.format), ... + Columns=["Metric", "Value"]) ... + .text("details", join(string( ... + batch_crop.resultFiles.detailLines(applicationState, index, ... + cropSize(1), cropSize(2), padding)), newline)); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/createSession.m b/apps/image_measurement/batch_crop/+batch_crop/createSession.m index 68031cdfd..85ac51934 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/createSession.m +++ b/apps/image_measurement/batch_crop/+batch_crop/createSession.m @@ -1,11 +1,10 @@ -% Rebuild transient selection and only the first selected image from one -% validated Batch Crop project after Runtime V2 resolves sources. -function session = createSession(project) +% Rebuild transient task paths and the first selected image. +function session = createSession(project, context) currentIndex = double(~isempty(project.inputs.items)); images = cell(numel(project.inputs.items), 1); + paths = taskPaths(project.inputs.items, project.inputs.sources, context); if currentIndex > 0 - sourceId = string(project.inputs.items(currentIndex).sourceId); - path = labkit.ui.runtime.sourcePaths(project.inputs.sources, sourceId); + path = paths(currentIndex); if strlength(path) > 0 loaded = batch_crop.sourceFiles.readItems(path); if ~isempty(loaded) @@ -21,5 +20,21 @@ "view", struct("scaleBar", []), ... "cache", struct( ... "images", {images}, ... + "paths", paths, ... "canvas", batch_crop.cropGeometry.emptyCanvasCache())); end + +function paths = taskPaths(tasks, sources, context) +paths = strings(numel(tasks), 1); +for k = 1:numel(tasks) + sourceId = string(tasks(k).sourceId); + match = find(string({sources.id}) == sourceId, 1); + if isempty(match) + continue + end + resolved = context.resolveSourcePaths(sources(match)); + if ~isempty(resolved) + paths(k) = resolved(1); + end +end +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/definition.m b/apps/image_measurement/batch_crop/+batch_crop/definition.m index 6daf3b657..c900d2270 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/definition.m +++ b/apps/image_measurement/batch_crop/+batch_crop/definition.m @@ -1,23 +1,13 @@ % App-owned runtime definition for labkit_BatchImageCrop_app. Expected % caller: the public app entrypoint. Output is a declarative LabKit app % definition; side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_BatchImageCrop_app", ... - "Id", "batch_crop", ... - "Title", "Microscope Batch Image Crop", ... - "DisplayName", "Batch Image Crop", ... - "Family", "Image Measurement", ... - "AppVersion", "1.7.7", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "image", ">=2.0 <3"), ... - "Project", batch_crop.projectSpec(), ... - "CreateSession", @batch_crop.createSession, ... - "Layout", @batch_crop.userInterface.buildWorkbenchLayout, ... - "Actions", batch_crop.definitionActions(), ... - "Present", @batch_crop.userInterface.presentWorkbench, ... - "Renderers", struct( ... - "cropPreview", @batch_crop.userInterface.renderCropPreview), ... - "DebugSample", @batch_crop.debug.writeSamplePack); +function app = definition() + app = labkit.app.Definition(Entrypoint="labkit_BatchImageCrop_app", ... + AppId="batch_crop", Title="Microscope Batch Image Crop", ... + DisplayName="Batch Image Crop", Family="Image Measurement", ... + AppVersion="1.8.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2", "image", ">=2.0 <3"), ... + ProjectSchema=batch_crop.projectSpec(), CreateSession=@batch_crop.createSession, ... + Workbench=batch_crop.workbench.buildLayout(), PresentWorkbench=@batch_crop.workbench.present, ... + BuildDebugSample=@batch_crop.debug.writeSamplePack); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/definitionActions.m b/apps/image_measurement/batch_crop/+batch_crop/definitionActions.m deleted file mode 100644 index aa0d5aaf9..000000000 --- a/apps/image_measurement/batch_crop/+batch_crop/definitionActions.m +++ /dev/null @@ -1,595 +0,0 @@ -% App-owned V2 action registry for Batch Image Crop. Handlers receive -% canonical state/events/services and own source queues, crop annotations, -% scale calibration, and exports without reading or mutating UI controls. -function actions = definitionActions() - actions = struct( ... - "imagesChosen", @onImagesChosen, ... - "removeImages", @onRemoveImages, ... - "clearImages", @onClearImages, ... - "duplicateImage", @onDuplicateImage, ... - "imageSelectionChanged", @onImageSelectionChanged, ... - "previousImage", @onPreviousImage, ... - "nextImage", @onNextImage, ... - "cropGeometryChanged", @onCropGeometryChanged, ... - "rotationChanged", @onRotationChanged, ... - "paddingChanged", @onPaddingChanged, ... - "centerChanged", @onCenterChanged, ... - "useImageCenter", @onUseImageCenterXY, ... - "useImageXCenter", @onUseImageCenterX, ... - "useImageYCenter", @onUseImageCenterY, ... - "scaleSettingChanged", @onScaleSettingChanged, ... - "scaleCalibrationFieldChanged", @onScaleCalibrationFieldChanged, ... - "measureScaleReference", @onMeasureScaleReference, ... - "scaleReferenceEdited", @onScaleReferenceEdited, ... - "scaleBarSettingChanged", @onScaleBarSettingChanged, ... - "placeScaleBar", @onPlaceScaleBar, ... - "cropCenterEdited", @onCropCenterEdited); - actions = mergeActions(actions, ... - batch_crop.resultFiles.definitionActions()); -end - -function state = onImagesChosen(state, event, services) - added = services.events.paths(event, "addedFiles"); - paths = services.events.paths(event, "files"); - if isempty(paths) - paths = added; - end - if isempty(paths) - state = services.workflow.log(state, "Image file selection cancelled."); - return; - end - [tasks, sources, images] = batch_crop.sourceFiles.reconcileSelection( ... - state.project.inputs.items, state.project.inputs.sources, ... - state.session.cache.images, paths, services.project.sourceRecord); - state.project.inputs.items = tasks; - state.project.inputs.sources = sources; - state.session.cache.images = images; - state.session.selection.currentIndex = selectedAddedIndex( ... - tasks, sources, added); - state.project.parameters.outputFolder = string( ... - services.dialogs.defaultOutputFolder(paths, "batch_crop", ... - state.project.parameters.outputFolder)); - state = clearExportAndCanvas(state); - [state, loaded] = ensureCurrentImageLoaded(state, services); - if loaded - state = ensureCurrentCenter(state); - state = initializeCropSizeDefaultsIfNeeded(state); - end - state = services.workflow.log(state, sprintf( ... - 'Selected %d image file(s); crop tasks: %d.', ... - numel(paths), numel(state.project.inputs.items))); -end - -function state = onClearImages(state, ~, services) - state.project.inputs.items = repmat( ... - batch_crop.cropTasks.emptyTask(), 0, 1); - state.project.inputs.sources = labkit.ui.runtime.emptySourceRecords(); - state.session.cache.images = cell(0, 1); - state.session.selection.currentIndex = 0; - state.session.workflow.cropDefaultsInitialized = false; - state.session.workflow.scaleReferenceEditing = false; - state.session.view.scaleBar = []; - state = clearExportAndCanvas(state); - state = services.workflow.log(state, "Cleared loaded images."); -end - -function state = onRemoveImages(state, event, services) - items = state.project.inputs.items; - indices = services.events.indices(event, "removedFiles", numel(items)); - if isempty(indices) - return; - end - items(indices) = []; - state.project.inputs.items = items; - state.session.cache.images(indices) = []; - state.project.inputs.sources = sourcesForTasks( ... - items, state.project.inputs.sources); - if isempty(items) - state.session.selection.currentIndex = 0; - else - state.session.selection.currentIndex = min(max( ... - state.session.selection.currentIndex, 1), numel(items)); - end - state.session.workflow.scaleReferenceEditing = false; - state.session.view.scaleBar = []; - state = clearExportAndCanvas(state); - [state, loaded] = ensureCurrentImageLoaded(state, services); - if loaded - state = ensureCurrentCenter(state); - end - state = services.workflow.log(state, sprintf( ... - 'Removed %d crop task(s); remaining: %d.', ... - numel(indices), numel(items))); -end - -function state = onDuplicateImage(state, ~, services) - [state, ok] = ensureCurrentReady(state, services); - if ~ok - return; - end - index = currentIndex(state); - duplicate = state.project.inputs.items(index); - duplicate.centerXY = [NaN, NaN]; - duplicate.centerSet = false; - insertAt = index + 1; - items = state.project.inputs.items; - state.project.inputs.items = [items(1:index); duplicate; items(insertAt:end)]; - images = state.session.cache.images; - state.session.cache.images = [images(1:index); images(index); images(insertAt:end)]; - state.session.selection.currentIndex = insertAt; - state.session.workflow.scaleReferenceEditing = false; - state.session.view.scaleBar = []; - state = clearExportAndCanvas(state); - state = ensureCurrentCenter(state); - state = services.workflow.log(state, sprintf( ... - 'Duplicated image %d as crop task %d. Pick a new crop center.', ... - index, insertAt)); -end - -function state = onImageSelectionChanged(state, event, services) - indices = services.events.indices(event, "selectedFiles", ... - numel(state.project.inputs.items)); - if isempty(indices) - return; - end - state.session.selection.currentIndex = indices(1); - state.session.workflow.scaleReferenceEditing = false; - state.session.view.scaleBar = []; - [state, loaded] = ensureCurrentImageLoaded(state, services); - if loaded - state = ensureCurrentCenter(state); - end -end - -function state = onPreviousImage(state, ~, services) - if isempty(state.project.inputs.items) - return; - end - state.session.selection.currentIndex = max(1, currentIndex(state) - 1); - state = selectCurrentImage(state, services); -end - -function state = onNextImage(state, ~, services) - if isempty(state.project.inputs.items) - return; - end - state.session.selection.currentIndex = min( ... - numel(state.project.inputs.items), currentIndex(state) + 1); - state = selectCurrentImage(state, services); -end - -function state = selectCurrentImage(state, services) - state.session.workflow.scaleReferenceEditing = false; - state.session.view.scaleBar = []; - [state, loaded] = ensureCurrentImageLoaded(state, services); - if loaded - state = ensureCurrentCenter(state); - end -end - -function state = onCropGeometryChanged(state, ~, services) - parameters = state.project.parameters; - parameters.cropWidth = positiveInteger(parameters.cropWidth, 1); - parameters.cropHeight = positiveInteger(parameters.cropHeight, 1); - state.project.parameters = parameters; - state.session.workflow.cropDefaultsInitialized = true; - [state, ok] = ensureCurrentReady(state, services); - if ok - state = ensureCurrentCenter(state); - end - state = clearExport(state); -end - -function state = onRotationChanged(state, event, services) - [state, ok] = ensureCurrentReady(state, services); - if ~ok - return; - end - index = currentIndex(state); - state.project.inputs.items(index).angleDeg = finiteScalar(event.value, ... - state.project.inputs.items(index).angleDeg); - state = ensureCurrentCenter(state); - state = clearExportAndCanvas(state); - state.session.view.scaleBar = []; - state = services.workflow.log(state, sprintf( ... - 'Updated rotation for image %d: %.3g deg.', index, ... - state.project.inputs.items(index).angleDeg)); -end - -function state = onPaddingChanged(state, event, services) - [state, ok] = ensureCurrentReady(state, services); - if ~ok - return; - end - index = currentIndex(state); - value = finiteScalar(event.value, ... - state.project.inputs.items(index).paddingPercent); - state.project.inputs.items(index).paddingPercent = min(max(value, 0), 200); - state = ensureCurrentCenter(state); - state = clearExportAndCanvas(state); - state.session.view.scaleBar = []; - state = services.workflow.log(state, sprintf( ... - 'Updated padding for image %d: %.3g%%.', index, ... - state.project.inputs.items(index).paddingPercent)); -end - -function state = onCenterChanged(state, event, services) - [state, ok] = ensureCurrentReady(state, services); - if ~ok - return; - end - index = currentIndex(state); - center = state.project.inputs.items(index).centerXY; - if event.target == "centerX" - center(1) = finiteScalar(event.value, center(1)); - elseif event.target == "centerY" - center(2) = finiteScalar(event.value, center(2)); - end - state = setCurrentCenter(state, center, true); - state = clearExport(state); - state = services.workflow.log(state, sprintf( ... - 'Set crop center for image %d: x=%.1f, y=%.1f.', index, ... - state.project.inputs.items(index).centerXY)); -end - -function state = onUseImageCenterXY(state, event, services) - state = onUseImageCenter(state, event, services, "xy"); -end - -function state = onUseImageCenterX(state, event, services) - state = onUseImageCenter(state, event, services, "x"); -end - -function state = onUseImageCenterY(state, event, services) - state = onUseImageCenter(state, event, services, "y"); -end - -function state = onUseImageCenter(state, ~, services, mode) - [state, ok] = ensureCurrentReady(state, services); - if ~ok - return; - end - index = currentIndex(state); - item = currentItem(state); - center = item.centerXY; - sourceCenter = batch_crop.cropGeometry.sourceCenterXY(item.image); - if any(~isfinite(center)) - center = sourceCenter; - end - if mode == "x" - center(1) = sourceCenter(1); - elseif mode == "y" - center(2) = sourceCenter(2); - else - center = sourceCenter; - end - state = setCurrentCenter(state, center, true); - state = clearExport(state); - state = services.workflow.log(state, sprintf( ... - 'Set image %d crop %s center.', index, char(upper(mode)))); -end - -function state = onScaleSettingChanged(state, ~, ~) - parameters = state.project.parameters; - parameters.physicalWidth = positiveScalar(parameters.physicalWidth, eps); - parameters.physicalHeight = positiveScalar(parameters.physicalHeight, eps); - parameters.targetPixelsPerUnit = nonnegativeScalar( ... - parameters.targetPixelsPerUnit, 0); - parameters.maxUpsamplePercent = nonnegativeScalar( ... - parameters.maxUpsamplePercent, 0); - state.project.parameters = parameters; - if ~strcmpi(parameters.scaleMode, "Physical") - state.session.workflow.scaleReferenceEditing = false; - end - state = clearExport(state); -end - -function state = onScaleCalibrationFieldChanged(state, event, ~) - if ~hasCurrentImage(state) - return; - end - index = currentIndex(state); - cal = state.project.inputs.items(index).scaleCalibration; - if event.target == "scaleReferencePixels" - cal.referencePixels = positiveOrNaN(event.value); - cal.referenceLine = zeros(0, 2); - elseif event.target == "scaleReferenceLength" - cal.referenceLength = nonnegativeScalar(event.value, cal.referenceLength); - elseif event.target == "scaleCalibrationUnit" - cal.unit = char(string(event.value)); - end - state.project.inputs.items(index).scaleCalibration = calibrationFrom(cal); - state.session.view.scaleBar = []; - state = clearExport(state); -end - -function state = onMeasureScaleReference(state, ~, services) - if ~hasCurrentImage(state) - showError(services, 'No image loaded', ... - 'Open an image before measuring reference pixels.'); - return; - end - state.session.workflow.scaleReferenceEditing = ... - ~state.session.workflow.scaleReferenceEditing; - state.session.view.scaleBar = []; -end - -function state = onScaleReferenceEdited(state, event, ~) - if ~hasCurrentImage(state) - return; - end - points = double(event.value); - if size(points, 2) ~= 2 - return; - end - [geometry, placement] = currentGeometryAndPlacement(state); - original = zeros(size(points)); - for k = 1:size(points, 1) - original(k, :) = batch_crop.cropGeometry.canvasToOriginal( ... - geometry, points(k, :) - placement.offset); - end - index = currentIndex(state); - cal = state.project.inputs.items(index).scaleCalibration; - cal.referenceLine = original; - cal.referencePixels = NaN; - state.project.inputs.items(index).scaleCalibration = calibrationFrom(cal); - state.session.view.scaleBar = []; - state = clearExport(state); -end - -function state = onScaleBarSettingChanged(state, ~, ~) - state.project.parameters.scaleBarLength = nonnegativeScalar( ... - state.project.parameters.scaleBarLength, 0); - state.session.view.scaleBar = []; -end - -function state = onPlaceScaleBar(state, ~, services) - if ~hasCurrentImage(state) - showError(services, 'No image loaded', ... - 'Open an image before placing a scale bar.'); - return; - end - item = currentItem(state); - if ~batch_crop.scaleCalibration.isSet(item.scaleCalibration) - showError(services, 'Calibration required', ... - ['Measure or enter reference pixels, then enter a positive ' ... - 'reference length and unit.']); - return; - end - try - state.session.view.scaleBar = labkit.ui.interaction.scaleBarGeometry( ... - size(item.image), item.scaleCalibration, ... - state.project.parameters.scaleBarLength, ... - state.project.parameters.scaleBarPosition, ... - state.project.parameters.scaleBarColor); - state.session.workflow.scaleReferenceEditing = false; - catch ME - showError(services, 'Could not place scale bar', ME.message); - end -end - -function state = onCropCenterEdited(state, event, services) - [state, ok] = ensureCurrentReady(state, services); - if ~ok || ~isstruct(event.value) || ... - ~isfield(event.value, 'points') || ... - size(event.value.points, 2) ~= 2 || isempty(event.value.points) - return; - end - [geometry, placement] = currentGeometryAndPlacement(state); - canvas = double(event.value.points(1, :)) - placement.offset; - center = batch_crop.cropGeometry.canvasToOriginal(geometry, canvas); - state = setCurrentCenter(state, center, true); - state = clearExport(state); - action = "Placed"; - if isfield(event.value, 'reason') && string(event.value.reason) == "move" - action = "Dragged"; - end - state = services.workflow.log(state, sprintf( ... - '%s crop center for image %d: x=%.1f, y=%.1f.', ... - char(action), currentIndex(state), state.project.inputs.items( ... - currentIndex(state)).centerXY)); -end - -function state = initializeCropSizeDefaultsIfNeeded(state) - if state.session.workflow.cropDefaultsInitialized || ~hasCurrentImage(state) - return; - end - imageData = currentItem(state).image; - % Constant: the legacy workflow starts with a crop spanning 70% of the - % source so users can reposition it without immediately hitting bounds. - defaultCropFraction = 0.7; - state.project.parameters.cropWidth = max(1, ... - round(size(imageData, 2) * defaultCropFraction)); - state.project.parameters.cropHeight = max(1, ... - round(size(imageData, 1) * defaultCropFraction)); - state.session.workflow.cropDefaultsInitialized = true; -end - -function [state, ok] = ensureCurrentReady(state, services) - [state, loaded] = ensureCurrentImageLoaded(state, services); - ok = loaded && hasCurrentImage(state); - if ok - state = ensureCurrentCenter(state); - state = initializeCropSizeDefaultsIfNeeded(state); - end -end - -function [state, loaded] = ensureCurrentImageLoaded(state, services) - loaded = false; - index = currentIndex(state); - if index < 1 || index > numel(state.project.inputs.items) - return; - end - try - if index <= numel(state.session.cache.images) && ... - ~isempty(state.session.cache.images{index}) - loaded = true; - return; - end - loadedItems = batch_crop.sourceFiles.readItems( ... - sourcePath(state.project.inputs.items(index), ... - state.project.inputs.sources)); - if isempty(loadedItems) - error('labkit_BatchImageCrop_app:ImageNotLoaded', ... - 'No image was loaded for item %d.', index); - end - state.session.cache.images{index} = loadedItems(1).image; - loaded = true; - catch ME - services.diagnostics.report('Could not load image', ME); - state = services.workflow.log(state, sprintf( ... - 'Could not load image %d: %s', index, ME.message)); - end -end - -function state = ensureCurrentCenter(state) - if ~hasCurrentImage(state) - return; - end - item = currentItem(state); - center = item.centerXY; - if isempty(center) || any(~isfinite(center)) - center = batch_crop.cropGeometry.sourceCenterXY(item.image); - end - state = setCurrentCenter(state, center, item.centerSet); -end - -function state = setCurrentCenter(state, center, confirmed) - index = currentIndex(state); - [geometry, ~] = currentGeometryAndPlacement(state); - center = batch_crop.cropGeometry.clampCropCenterToCanvas( ... - geometry, center, batch_crop.cropGeometry.currentCropSize(state)); - state.project.inputs.items(index).centerXY = center; - state.project.inputs.items(index).centerSet = logical(confirmed); -end - -function [geometry, placement] = currentGeometryAndPlacement(state) - item = currentItem(state); - [geometry, ~] = batch_crop.cropGeometry.currentGeometry( ... - state.session.cache.canvas, currentIndex(state), item, ... - batch_crop.cropGeometry.itemPaddingPercent(item, 0)); - placement = struct("offset", [0 0], ... - "xData", [1 size(geometry.canvas, 2)], ... - "yData", [1 size(geometry.canvas, 1)]); -end - -function state = clearExportAndCanvas(state) - state = clearExport(state); - state.session.cache.canvas = batch_crop.cropGeometry.emptyCanvasCache(); -end - -function state = clearExport(state) - state.project.results.lastExport = []; - state.project.results.lastExportFingerprint = ""; - state.project.results.resultManifestPath = ""; -end - -function item = currentItem(state) - index = currentIndex(state); - item = batch_crop.sourceFiles.workingItems( ... - state.project.inputs.items(index), state.session.cache.images(index), ... - state.project.inputs.sources); -end - -function index = currentIndex(state) - index = state.session.selection.currentIndex; -end - -function tf = hasCurrentImage(state) - index = currentIndex(state); - tf = ~isempty(state.project.inputs.items) && index >= 1 && ... - index <= numel(state.project.inputs.items) && ... - index <= numel(state.session.cache.images) && ... - ~isempty(state.session.cache.images{index}); -end - -function index = selectedAddedIndex(items, sources, added) - index = 1; - if isempty(items) - index = 0; - return; - end - if isempty(added) - return; - end - source = find(labkit.ui.runtime.sourcePaths(sources) == ... - added(1), 1, 'first'); - match = []; - if ~isempty(source) - match = find(string({items.sourceId}) == string(sources(source).id), ... - 1, 'first'); - end - if ~isempty(match) - index = match; - end -end - -function sources = sourcesForTasks(tasks, existingSources) - sources = labkit.ui.runtime.emptySourceRecords(); - taskIds = unique(string({tasks.sourceId}), 'stable'); - for k = 1:numel(taskIds) - match = find(string({existingSources.id}) == taskIds(k), 1, 'first'); - if ~isempty(match) - sources(end + 1) = existingSources(match); - end - end -end - -function path = sourcePath(task, sources) - path = ""; - match = find(string({sources.id}) == string(task.sourceId), 1, 'first'); - if ~isempty(match) - path = labkit.ui.runtime.sourcePaths(sources(match)); - end -end - -function cal = calibrationFrom(value) - cal = labkit.ui.interaction.scaleBarCalibration( ... - value.referencePixels, value.referenceLength, value.unit, ... - struct("referenceLine", value.referenceLine, ... - "defaultUnit", "um")); -end - -function value = finiteScalar(candidate, fallback) - value = fallback; - if isnumeric(candidate) && isscalar(candidate) && isfinite(double(candidate)) - value = double(candidate); - end -end - -function value = positiveInteger(candidate, fallback) - value = max(1, round(finiteScalar(candidate, fallback))); -end - -function value = positiveScalar(candidate, fallback) - value = finiteScalar(candidate, fallback); - if value <= 0 - value = fallback; - end -end - -function value = nonnegativeScalar(candidate, fallback) - value = finiteScalar(candidate, fallback); - if value < 0 - value = fallback; - end -end - -function value = positiveOrNaN(candidate) - value = NaN; - if isnumeric(candidate) && isscalar(candidate) && ... - isfinite(double(candidate)) && double(candidate) > 0 - value = double(candidate); - end -end - -function showError(services, titleText, message) - services.dialogs.alert(message, titleText); -end - -function target = mergeActions(target, source) - names = fieldnames(source); - for k = 1:numel(names) - target.(names{k}) = source.(names{k}); - end -end diff --git a/apps/image_measurement/batch_crop/+batch_crop/projectSpec.m b/apps/image_measurement/batch_crop/+batch_crop/projectSpec.m index c0035a7a5..3d8e43c72 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/projectSpec.m +++ b/apps/image_measurement/batch_crop/+batch_crop/projectSpec.m @@ -1,18 +1,16 @@ -% App-owned durable Batch Crop contract. Runtime V2 calls the one-step -% migration entry for version-1 payloads, then validates the version-2 task -% and source-record structure. +% App-owned durable Batch Crop contract. The App SDK calls the one-step +% migration entry for older payloads, then validates the version-3 +% one-task-per-source structure. function spec = projectSpec() - spec = struct( ... - "Version", 2, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", @migrateProject); + spec = labkit.app.project.Schema(Version=3, ... + Create=@createProject, Validate=@validateProject, ... + Migrate=@migrateProject); end function project = createProject() project = struct(); project.inputs = struct( ... - "sources", labkit.ui.runtime.emptySourceRecords(), ... + "sources", emptySources(), ... "items", repmat(batch_crop.cropTasks.emptyTask(), 0, 1)); project.parameters = struct( ... "cropWidth", 1024, ... @@ -36,16 +34,70 @@ project.extensions = struct(); end +function sources = emptySources() +reference = struct("schemaVersion", 1, "relativePath", "", ... + "originalPath", "", "fileName", ""); +prototype = struct("id", "", "required", true, "role", "", ... + "reference", {reference}); +sources = repmat(prototype, 0, 1); +end + function project = migrateProject(project, fromVersion) switch double(fromVersion) case 1 project = migrateVersionOne(project); + case 2 + project = migrateVersionTwo(project); otherwise error('batch_crop:UnsupportedProjectMigration', ... 'Batch Crop cannot migrate project version %d.', fromVersion); end end +function project = migrateVersionTwo(project) +if ~isfield(project, 'inputs') || ... + ~isfield(project.inputs, 'items') || ... + ~isfield(project.inputs, 'sources') || ... + isempty(project.inputs.items) + return +end +items = project.inputs.items; +sources = project.inputs.sources; +taskSources = emptySources(); +usedSourceIds = strings(0, 1); +for k = 1:numel(items) + match = find(string({sources.id}) == string(items(k).sourceId), 1); + if isempty(match) + continue + end + source = sources(match); + sourceId = string(source.id); + if any(usedSourceIds == sourceId) + sourceId = nextSourceId(sources, taskSources); + source.id = sourceId; + items(k).sourceId = sourceId; + end + if isempty(taskSources) + taskSources = source; + else + taskSources(end + 1, 1) = source; + end + usedSourceIds(end + 1, 1) = sourceId; +end +project.inputs.items = items; +project.inputs.sources = taskSources; +end + +function id = nextSourceId(existing, pending) +ids = [string({existing.id}), string({pending.id})]; +number = 1; +id = "image-" + string(number); +while any(ids == id) + number = number + 1; + id = "image-" + string(number); +end +end + function project = migrateVersionOne(project) if isfield(project, 'inputs') && isfield(project.inputs, 'items') && ... isstruct(project.inputs.items) && ... @@ -58,33 +110,26 @@ return; end items = project.inputs.items; - sources = project.inputs.sources; - sourcePaths = labkit.ui.runtime.sourcePaths(sources); - addedSources = cell(numel(items), 1); - addedPaths = strings(numel(items), 1); - addedCount = 0; + sources = struct([]); + sourcePaths = strings(0, 1); for k = 1:numel(items) path = string(items(k).path); - paths = [sourcePaths; addedPaths(1:addedCount)]; - match = find(paths == path, 1, 'first'); + match = find(sourcePaths == path, 1, 'first'); if isempty(match) - addedCount = addedCount + 1; - sourceId = "image" + string(numel(sources) + addedCount); - source = labkit.ui.runtime.sourceRecord( ... + sourceId = "image" + string(numel(sources) + 1); + source = labkit.app.project.sourceRecord( ... sourceId, "cropSource", path, true); - addedSources{addedCount} = source; - addedPaths(addedCount) = path; - elseif match <= numel(sources) - sourceId = string(sources(match).id); + if isempty(sources) + sources = source; + else + sources(end + 1, 1) = source; + end + sourcePaths(end + 1, 1) = path; else - added = addedSources{match - numel(sources)}; - sourceId = string(added.id); + sourceId = string(sources(match).id); end items(k).sourceId = sourceId; end - if addedCount > 0 - sources = [sources(:); vertcat(addedSources{1:addedCount})]; - end project.inputs.items = rmfield(items, 'path'); project.inputs.sources = sources; end @@ -103,13 +148,13 @@ assert(isempty(items) || all(isfield(items, cellstr(requiredItemFields))), ... 'batch_crop:InvalidProject', ... 'Batch crop project items do not match the crop-task contract.'); - if ~isempty(items) - sourceIds = string({sources.id}); - taskSourceIds = string({items.sourceId}); - assert(all(ismember(taskSourceIds, sourceIds)), ... - 'batch_crop:InvalidProject', ... - 'Every crop task must reference one canonical source record.'); - end + sourceIds = reshape(string({sources.id}), 1, []); + taskSourceIds = reshape(string({items.sourceId}), 1, []); + assert(numel(items) == numel(sources) && ... + isequal(taskSourceIds, sourceIds), ... + 'batch_crop:InvalidProject', ... + ['Every crop task must align with one distinct portable ' ... + 'source record.']); requiredParameters = ["cropWidth", "cropHeight", "scaleMode", ... "scaleUnit", "physicalWidth", "physicalHeight", ... "targetPixelsPerUnit", "maxUpsamplePercent", "format", ... diff --git a/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m b/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m index e08c656a8..70d07795e 100644 --- a/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m +++ b/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m @@ -1,6 +1,5 @@ function varargout = labkit_BatchImageCrop_app(varargin) %LABKIT_BATCHIMAGECROP_APP Batch crop microscope images at fixed pixel size. -% Thin entrypoint delegates requests, launch, and version title to runtime V2. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @batch_crop.definition, varargin{:}); +% Thin entrypoint delegates launch and version title to the App SDK. + [varargout{1:nargout}] = batch_crop.definition().launch(varargin{:}); end diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/densePointCountChanged.m b/apps/image_measurement/curvature/+curvature/+analysisRun/densePointCountChanged.m new file mode 100644 index 000000000..0a4321002 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/densePointCountChanged.m @@ -0,0 +1,13 @@ +% App-owned implementation for curvature.analysisRun.densePointCountChanged within the curvature product workflow. +function applicationState = densePointCountChanged( ... + applicationState, pointCount, callbackContext) +%DENSEPOINTCOUNTCHANGED Normalize fit sampling density and invalidate results. +pointCount = double(pointCount); +if ~isscalar(pointCount) || ~isfinite(pointCount) + pointCount = 300; +end +applicationState.project.parameters.densePointCount = ... + max(3, round(pointCount)); +applicationState = curvature.curveEdit.clearMeasurements(applicationState); +callbackContext.appendStatus("Curvature fit settings changed."); +end diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/densifyChanged.m b/apps/image_measurement/curvature/+curvature/+analysisRun/densifyChanged.m new file mode 100644 index 000000000..f81d0890b --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/densifyChanged.m @@ -0,0 +1,8 @@ +% App-owned implementation for curvature.analysisRun.densifyChanged within the curvature product workflow. +function applicationState = densifyChanged( ... + applicationState, enabled, callbackContext) +%DENSIFYCHANGED Normalize the fit sampling mode and invalidate results. +applicationState.project.parameters.densify = logical(enabled); +applicationState = curvature.curveEdit.clearMeasurements(applicationState); +callbackContext.appendStatus("Curvature fit settings changed."); +end diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/fit.m b/apps/image_measurement/curvature/+curvature/+analysisRun/fit.m new file mode 100644 index 000000000..17f5d0672 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/fit.m @@ -0,0 +1,47 @@ +% App-owned implementation for curvature.analysisRun.fit within the curvature product workflow. +function applicationState = fit(applicationState, callbackContext) +%FIT Execute one fingerprinted circle-fit task. +points = applicationState.project.annotations.curvePoints; +if size(points, 1) < 3 + callbackContext.alert( ... + "At least 3 curve points are required to fit curvature.", ... + "Not enough points"); + return +end +path = curvature.curvePreview.visiblePath( ... + points, applicationState.session.cache.image); +parameters = applicationState.project.parameters; +calibration = applicationState.project.annotations.calibration; +try + task = curvature.analysisRun.fitTask( ... + points, path, calibration, struct( ... + "doDensify", parameters.densify, ... + "denseN", parameters.densePointCount)); + if applicationState.project.results.fit.ok && ... + applicationState.session.cache.fitFingerprint == ... + task.fingerprint + callbackContext.appendStatus( ... + "Curvature fit already matches current curve and scale."); + return + end + fitResult = curvature.analysisRun.computeCurvatureFit( ... + task.points(:, 1), task.points(:, 2), task.calibration, ... + task.options.doDensify, task.options.denseN, ... + task.fitPath(:, 1), task.fitPath(:, 2)); +catch ME + callbackContext.reportError("Fit Curvature circle", ME); + callbackContext.alert(ME.message, "Circle fit failed"); + return +end +applicationState.project.results.fit = fitResult; +applicationState.project.results.length = ... + curvature.analysisRun.lengthResultFromFit(fitResult); +applicationState.project.results.lastCsvExport = []; +applicationState.project.results.lastOverlayExport = []; +applicationState.session.cache.fitFingerprint = task.fingerprint; +applicationState.session.cache.lengthFingerprint = ""; +callbackContext.appendStatus(sprintf( ... + "Fit complete: R = %.6g %s, curvature = %.6g %s.", ... + fitResult.R_show, fitResult.unitLen, ... + fitResult.kappa_show, fitResult.unitK)); +end diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/layoutSection.m b/apps/image_measurement/curvature/+curvature/+analysisRun/layoutSection.m new file mode 100644 index 000000000..df1221087 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/layoutSection.m @@ -0,0 +1,30 @@ +% App-owned implementation for curvature.analysisRun.layoutSection within the curvature product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare fit, length, and result export controls. +section = labkit.app.layout.section("fitExport", "Fit + Export", { ... + labkit.app.layout.field("densify", ... + Label="Densify before circle fit", Kind="logical", Value=true, ... + Bind="project.parameters.densify", ... + OnValueChanged=@curvature.analysisRun.densifyChanged), ... + labkit.app.layout.slider("densePointCount", ... + Label="Dense point count:", Value=300, ... + Limits=[3 100000], Step=25, ... + Bind="project.parameters.densePointCount", ... + OnValueChanged=@curvature.analysisRun.densePointCountChanged), ... + labkit.app.layout.field("showDensePoints", ... + Label="Show dense fit points", Kind="logical", Value=true, ... + Bind="project.parameters.showDensePoints", ... + OnValueChanged=@curvature.analysisRun.showDensePointsChanged), ... + labkit.app.layout.button("fitCurvature", ... + "Fit circle + curvature", @curvature.analysisRun.fit, ... + Tooltip="Fit a circle to the traced curve points and report curvature as the reciprocal fitted radius."), ... + labkit.app.layout.button("measureCurveLength", ... + "Measure curve length", @curvature.analysisRun.measureLength, ... + Tooltip="Sum distances along the traced curve, using the active pixel-to-physical scale when calibrated."), ... + labkit.app.layout.button("exportCsv", ... + "Export result CSV", @curvature.resultFiles.exportCsv, ... + Tooltip="Export fitted radius, curvature, traced length, calibration, and fit evidence as CSV."), ... + labkit.app.layout.button("exportOverlay", ... + "Export overlay PNG", @curvature.resultFiles.exportOverlay, ... + Tooltip="Export the source image with traced points, fitted circle, and optional calibrated scale bar.")}); +end diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/measureLength.m b/apps/image_measurement/curvature/+curvature/+analysisRun/measureLength.m new file mode 100644 index 000000000..301fafc14 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/measureLength.m @@ -0,0 +1,37 @@ +% App-owned implementation for curvature.analysisRun.measureLength within the curvature product workflow. +function applicationState = measureLength( ... + applicationState, callbackContext) +%MEASURELENGTH Execute one fingerprinted traced-length task. +points = applicationState.project.annotations.curvePoints; +if size(points, 1) < 2 + callbackContext.alert( ... + "At least 2 curve points are required to measure curve length.", ... + "Not enough points"); + return +end +path = curvature.curvePreview.visiblePath( ... + points, applicationState.session.cache.image); +try + task = curvature.analysisRun.lengthTask( ... + points, path, applicationState.project.annotations.calibration); + if applicationState.project.results.length.ok && ... + applicationState.session.cache.lengthFingerprint == ... + task.fingerprint + callbackContext.appendStatus( ... + "Curve length already matches current curve and scale."); + return + end + lengthResult = curvature.analysisRun.computeCurveLength( ... + task.lengthPath(:, 1), task.lengthPath(:, 2), task.calibration); +catch ME + callbackContext.reportError("Measure Curvature curve length", ME); + callbackContext.alert(ME.message, "Curve length failed"); + return +end +applicationState.project.results.length = lengthResult; +applicationState.project.results.lastCsvExport = []; +applicationState.session.cache.lengthFingerprint = task.fingerprint; +callbackContext.appendStatus(sprintf( ... + "Curve length measured: %.6g %s.", ... + lengthResult.length_show, lengthResult.unitLen)); +end diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/present.m b/apps/image_measurement/curvature/+curvature/+analysisRun/present.m new file mode 100644 index 000000000..5b04d8d7f --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/present.m @@ -0,0 +1,13 @@ +% App-owned implementation for curvature.analysisRun.present within the curvature product workflow. +function view = present(hasImage, points, fit, lengthResult, editMode) +%PRESENT Describe fit, length, and export action availability. +editing = editMode ~= "none"; +view = labkit.app.view.Snapshot() ... + .enabled("densify", ~editing) ... + .enabled("densePointCount", ~editing) ... + .enabled("showDensePoints", fit.ok && ~editing) ... + .enabled("fitCurvature", size(points, 1) >= 3 && ~editing) ... + .enabled("measureCurveLength", size(points, 1) >= 2 && ~editing) ... + .enabled("exportCsv", (fit.ok || lengthResult.ok) && ~editing) ... + .enabled("exportOverlay", hasImage && ~editing); +end diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/showDensePointsChanged.m b/apps/image_measurement/curvature/+curvature/+analysisRun/showDensePointsChanged.m new file mode 100644 index 000000000..6f6b6f87c --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/showDensePointsChanged.m @@ -0,0 +1,7 @@ +% App-owned implementation for curvature.analysisRun.showDensePointsChanged within the curvature product workflow. +function applicationState = showDensePointsChanged( ... + applicationState, visible, callbackContext) +%SHOWDENSEPOINTSCHANGED Update presentation-only dense-point visibility. +applicationState.project.parameters.showDensePoints = logical(visible); +callbackContext.appendStatus("Curvature overlay display changed."); +end diff --git a/apps/image_measurement/curvature/+curvature/+curveEdit/change.m b/apps/image_measurement/curvature/+curvature/+curveEdit/change.m new file mode 100644 index 000000000..63e57c5f4 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+curveEdit/change.m @@ -0,0 +1,21 @@ +% App-owned implementation for curvature.curveEdit.change within the curvature product workflow. +function applicationState = change( ... + applicationState, points, callbackContext) +%CHANGE Commit one managed curve-anchor edit. +points = normalizePoints(points); +applicationState.project.annotations.curvePoints = points; +applicationState = curvature.curveEdit.clearMeasurements(applicationState); +callbackContext.appendStatus( ... + "Curve edit updated: " + string(size(points, 1)) + " point(s)."); +end + +function points = normalizePoints(points) +if isempty(points) + points = zeros(0, 2); + return +end +points = double(points); +if size(points, 2) ~= 2 || any(~isfinite(points), "all") + points = zeros(0, 2); +end +end diff --git a/apps/image_measurement/curvature/+curvature/+curveEdit/clear.m b/apps/image_measurement/curvature/+curvature/+curveEdit/clear.m new file mode 100644 index 000000000..87dd52c72 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+curveEdit/clear.m @@ -0,0 +1,7 @@ +% App-owned implementation for curvature.curveEdit.clear within the curvature product workflow. +function applicationState = clear(applicationState, callbackContext) +%CLEAR Remove every curve anchor and invalidate measurements. +applicationState.project.annotations.curvePoints = zeros(0, 2); +applicationState = curvature.curveEdit.clearMeasurements(applicationState); +callbackContext.appendStatus("Cleared curve points."); +end diff --git a/apps/image_measurement/curvature/+curvature/+curveEdit/clearMeasurements.m b/apps/image_measurement/curvature/+curvature/+curveEdit/clearMeasurements.m new file mode 100644 index 000000000..edf4a7b37 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+curveEdit/clearMeasurements.m @@ -0,0 +1,12 @@ +% App-owned implementation for curvature.curveEdit.clearMeasurements within the curvature product workflow. +function applicationState = clearMeasurements(applicationState) +%CLEARMEASUREMENTS Invalidate curve-derived results and export evidence. +applicationState.project.results.fit = ... + curvature.analysisRun.emptyFitResult(); +applicationState.project.results.length = ... + curvature.analysisRun.emptyLengthResult(); +applicationState.project.results.lastCsvExport = []; +applicationState.project.results.lastOverlayExport = []; +applicationState.session.cache.fitFingerprint = ""; +applicationState.session.cache.lengthFingerprint = ""; +end diff --git a/apps/image_measurement/curvature/+curvature/+curveEdit/layoutSection.m b/apps/image_measurement/curvature/+curvature/+curveEdit/layoutSection.m new file mode 100644 index 000000000..fddbbe3b6 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+curveEdit/layoutSection.m @@ -0,0 +1,16 @@ +% App-owned implementation for curvature.curveEdit.layoutSection within the curvature product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare curve-edit mode and point actions. +actions = labkit.app.layout.group("curveEditActions", { ... + labkit.app.layout.button("undoCurvePoint", "Undo last point", ... + @curvature.curveEdit.undo, ... + Tooltip="Remove the most recently traced point from the curve used for fitting and length measurement."), ... + labkit.app.layout.button("clearCurve", "Clear curve", ... + @curvature.curveEdit.clear, ... + Tooltip="Remove all traced curve points and invalidate the current curvature and length results.")}, Layout="horizontal"); +section = labkit.app.layout.section("curveEditing", "Curve Editing", { ... + labkit.app.layout.button("startCurveEdit", "Start curve edit", ... + @curvature.curveEdit.toggle, ... + Tooltip="Enter or leave point-placement mode for tracing the image curve to be measured."), ... + actions}); +end diff --git a/apps/image_measurement/curvature/+curvature/+curveEdit/present.m b/apps/image_measurement/curvature/+curvature/+curveEdit/present.m new file mode 100644 index 000000000..8c436176e --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+curveEdit/present.m @@ -0,0 +1,18 @@ +% App-owned implementation for curvature.curveEdit.present within the curvature product workflow. +function view = present(hasImage, points, editMode) +%PRESENT Describe curve-edit availability and dynamic action text. +curveEditing = editMode == "curve"; +referenceEditing = editMode == "reference"; +view = labkit.app.view.Snapshot() ... + .text("startCurveEdit", actionText(curveEditing)) ... + .enabled("startCurveEdit", hasImage && ~referenceEditing) ... + .enabled("undoCurvePoint", ~isempty(points) && ~referenceEditing) ... + .enabled("clearCurve", ~isempty(points) && ~referenceEditing); +end + +function value = actionText(active) +value = "Start curve edit"; +if active + value = "Finish curve edit"; +end +end diff --git a/apps/image_measurement/curvature/+curvature/+curveEdit/toggle.m b/apps/image_measurement/curvature/+curvature/+curveEdit/toggle.m new file mode 100644 index 000000000..3f2956a7a --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+curveEdit/toggle.m @@ -0,0 +1,21 @@ +% App-owned implementation for curvature.curveEdit.toggle within the curvature product workflow. +function applicationState = toggle(applicationState, callbackContext) +%TOGGLE Enter or leave managed curve-anchor editing. +if isempty(applicationState.session.cache.image) + callbackContext.alert( ... + "Open an image before editing curve points.", "No image loaded"); + return +end +if string(applicationState.session.workflow.editMode) == "curve" + applicationState.session.workflow.editMode = "none"; + callbackContext.appendStatus("Finished curve edit."); +else + applicationState.session.workflow.editMode = "curve"; + applicationState.session.view.scaleBar = []; + applicationState = ... + curvature.curveEdit.clearMeasurements(applicationState); + callbackContext.appendStatus( ... + "Started curve edit. Double-click blank image space to add or " + ... + "insert points; drag points to move; double-click a point to delete."); +end +end diff --git a/apps/image_measurement/curvature/+curvature/+curveEdit/undo.m b/apps/image_measurement/curvature/+curvature/+curveEdit/undo.m new file mode 100644 index 000000000..cad640f89 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+curveEdit/undo.m @@ -0,0 +1,12 @@ +% App-owned implementation for curvature.curveEdit.undo within the curvature product workflow. +function applicationState = undo(applicationState, callbackContext) +%UNDO Remove the newest curve anchor and invalidate measurements. +points = applicationState.project.annotations.curvePoints; +if isempty(points) + return +end +points(end, :) = []; +applicationState.project.annotations.curvePoints = points; +applicationState = curvature.curveEdit.clearMeasurements(applicationState); +callbackContext.appendStatus("Undid last curve point."); +end diff --git a/apps/image_measurement/curvature/+curvature/+userInterface/emptyDash.m b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/emptyDash.m similarity index 100% rename from apps/image_measurement/curvature/+curvature/+userInterface/emptyDash.m rename to apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/emptyDash.m diff --git a/apps/image_measurement/curvature/+curvature/+userInterface/fitResultTableData.m b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/fitResultTableData.m similarity index 100% rename from apps/image_measurement/curvature/+curvature/+userInterface/fitResultTableData.m rename to apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/fitResultTableData.m diff --git a/apps/image_measurement/curvature/+curvature/+userInterface/initialResultTable.m b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/initialResultTable.m similarity index 100% rename from apps/image_measurement/curvature/+curvature/+userInterface/initialResultTable.m rename to apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/initialResultTable.m diff --git a/apps/image_measurement/curvature/+curvature/+userInterface/lengthResultTableData.m b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/lengthResultTableData.m similarity index 100% rename from apps/image_measurement/curvature/+curvature/+userInterface/lengthResultTableData.m rename to apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/lengthResultTableData.m diff --git a/apps/image_measurement/curvature/+curvature/+userInterface/plotAnchorResiduals.m b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/plotAnchorResiduals.m similarity index 96% rename from apps/image_measurement/curvature/+curvature/+userInterface/plotAnchorResiduals.m rename to apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/plotAnchorResiduals.m index 736047024..50a834486 100644 --- a/apps/image_measurement/curvature/+curvature/+userInterface/plotAnchorResiduals.m +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/plotAnchorResiduals.m @@ -23,5 +23,6 @@ function plotAnchorResiduals(ax, points, fit) 'Color', [1 0.9 0], ... 'LineWidth', 1.2, ... 'HitTest', 'off', ... + 'PickableParts', 'none', ... 'DisplayName', 'anchor residuals'); end diff --git a/apps/image_measurement/curvature/+curvature/+userInterface/plotDenseFitPoints.m b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/plotDenseFitPoints.m similarity index 93% rename from apps/image_measurement/curvature/+curvature/+userInterface/plotDenseFitPoints.m rename to apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/plotDenseFitPoints.m index 62f197cbc..c35694b26 100644 --- a/apps/image_measurement/curvature/+curvature/+userInterface/plotDenseFitPoints.m +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/plotDenseFitPoints.m @@ -11,5 +11,6 @@ function plotDenseFitPoints(ax, fit) 'Color', [0.95 0.2 0.95], ... 'MarkerSize', 7, ... 'HitTest', 'off', ... + 'PickableParts', 'none', ... 'DisplayName', 'dense fit points'); end diff --git a/apps/image_measurement/curvature/+curvature/+userInterface/plotStaticCurveAnchors.m b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/plotStaticCurveAnchors.m similarity index 80% rename from apps/image_measurement/curvature/+curvature/+userInterface/plotStaticCurveAnchors.m rename to apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/plotStaticCurveAnchors.m index 5686ea683..691259681 100644 --- a/apps/image_measurement/curvature/+curvature/+userInterface/plotStaticCurveAnchors.m +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/plotStaticCurveAnchors.m @@ -14,13 +14,14 @@ function plotStaticCurveAnchors(ax, points, curve, fit, showDense) 'Color', [0 0.45 0.95], ... 'LineWidth', 1.5, ... 'HitTest', 'off', ... + 'PickableParts', 'none', ... 'DisplayName', 'curve'); end if fit.ok if showDense - curvature.userInterface.plotDenseFitPoints(ax, fit); + curvature.curvePreview.presentationData.plotDenseFitPoints(ax, fit); end - curvature.userInterface.plotAnchorResiduals(ax, points, fit); + curvature.curvePreview.presentationData.plotAnchorResiduals(ax, points, fit); end plot(ax, points(:, 1), points(:, 2), 'o', ... 'LineStyle', 'none', ... @@ -29,5 +30,6 @@ function plotStaticCurveAnchors(ax, points, curve, fit, showDense) 'LineWidth', 1.2, ... 'MarkerSize', 7, ... 'HitTest', 'off', ... + 'PickableParts', 'none', ... 'DisplayName', 'anchors'); end diff --git a/apps/image_measurement/curvature/+curvature/+userInterface/summaryViewData.m b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/summaryViewData.m similarity index 82% rename from apps/image_measurement/curvature/+curvature/+userInterface/summaryViewData.m rename to apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/summaryViewData.m index b1bc5a516..b587297f3 100644 --- a/apps/image_measurement/curvature/+curvature/+userInterface/summaryViewData.m +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/summaryViewData.m @@ -8,9 +8,9 @@ summary = struct(); summary.pointCountText = sprintf('Points: %d', numel(xPix)); if fit.ok - summary.tableData = curvature.userInterface.fitResultTableData(fit, lengthResult); + summary.tableData = curvature.curvePreview.presentationData.fitResultTableData(fit, lengthResult); summary.details = { ... - sprintf('Image: %s', curvature.userInterface.emptyDash(imagePath)), ... + sprintf('Image: %s', curvature.curvePreview.presentationData.emptyDash(imagePath)), ... sprintf('Center: xc = %.6f px, yc = %.6f px', fit.xc_px, fit.yc_px), ... sprintf('Radius: %.6f %s', fit.R_show, fit.unitLen), ... sprintf('Curvature: %.6f %s', fit.kappa_show, fit.unitK), ... @@ -20,15 +20,15 @@ fit.referencePx, fit.referenceLength, fit.scaleUnit, ... fit.scaleUnit, fit.px_per_unit)}; elseif lengthResult.ok - summary.tableData = curvature.userInterface.lengthResultTableData(lengthResult); + summary.tableData = curvature.curvePreview.presentationData.lengthResultTableData(lengthResult); summary.details = { ... - sprintf('Image: %s', curvature.userInterface.emptyDash(imagePath)), ... + sprintf('Image: %s', curvature.curvePreview.presentationData.emptyDash(imagePath)), ... sprintf('Curve length: %.6f %s', lengthResult.length_show, lengthResult.unitLen), ... sprintf('Curve length: %.6f px', lengthResult.length_px), ... sprintf('Points used: %d; px/%s = %.6g', ... lengthResult.pointCount, lengthResult.scaleUnit, lengthResult.px_per_unit)}; else - summary.tableData = curvature.userInterface.initialResultTable(); + summary.tableData = curvature.curvePreview.presentationData.initialResultTable(); if curveEditActive summary.details = {'Curve edit active. Double-click blank image space to add/insert points, drag points to move them, double-click a point to delete it. Use the scroll wheel over the image to zoom.'}; elseif referenceEditActive diff --git a/apps/image_measurement/curvature/+curvature/+curvePreview/draw.m b/apps/image_measurement/curvature/+curvature/+curvePreview/draw.m new file mode 100644 index 000000000..1447bebc1 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/draw.m @@ -0,0 +1,91 @@ +% App-owned implementation for curvature.curvePreview.draw within the curvature product workflow. +function draw(axesById, model) +%DRAW Render the image, curve, circle fit, residuals, and scale bar. +ax = axesById.image; +view = captureImageView(ax, model.imageData); +labkit.app.plot.clearAxes(ax); +if isempty(model.imageData) + title(ax, "Image + Circle Fit"); + box(ax, "on"); + return +end +if ndims(model.imageData) == 2 + imagesc(ax, model.imageData, ... + HitTest="off", PickableParts="none"); + colormap(ax, gray(256)); +else + image(ax, model.imageData, ... + HitTest="off", PickableParts="none"); +end +axis(ax, "image"); +ax.YDir = "reverse"; +hold(ax, "on"); +curvature.curvePreview.presentationData.plotStaticCurveAnchors( ... + ax, model.points, model.curve, model.fit, model.showDensePoints); +drawFit(ax, model.fit); +drawScaleBar(ax, model.scaleBar); +hold(ax, "off"); +title(ax, fitTitle(model.fit)); +box(ax, "on"); +restoreImageView(ax, view); +end + +function drawFit(ax, fit) +if ~fit.ok + return +end +angle = linspace(-pi, pi, 600); +plot(ax, fit.xc_px + fit.R_px .* cos(angle), ... + fit.yc_px + fit.R_px .* sin(angle), ... + "r-", LineWidth=2, HitTest="off", PickableParts="none"); +plot(ax, fit.xc_px, fit.yc_px, ... + "ro", MarkerFaceColor="r", ... + HitTest="off", PickableParts="none"); +end + +function drawScaleBar(ax, scaleBar) +if isempty(scaleBar) + return +end +plot(ax, scaleBar.line(:, 1), scaleBar.line(:, 2), "-", ... + Color=scaleBar.color, LineWidth=3, ... + HitTest="off", PickableParts="none"); +text(ax, scaleBar.labelPosition(1), scaleBar.labelPosition(2), ... + scaleBar.label, Color=scaleBar.color, FontWeight="bold", ... + HorizontalAlignment="center", ... + VerticalAlignment=char(scaleBar.verticalAlignment), ... + HitTest="off", PickableParts="none"); +end + +function value = fitTitle(fit) +value = "Image + Circle Fit"; +if fit.ok + value = sprintf( ... + "R = %.4g %s, curvature = %.4g %s, RMSE = %.3g %s", ... + fit.R_show, fit.unitLen, fit.kappa_show, fit.unitK, ... + fit.rmse_show, fit.unitLen); +end +end + +function view = captureImageView(ax, imageData) +view = struct("preserve", false, "xLimits", [], "yLimits", []); +images = findobj(ax, "Type", "image"); +if isempty(images) || isempty(imageData) + return +end +previousSize = size(images(1).CData); +nextSize = size(imageData); +if numel(previousSize) < 2 || numel(nextSize) < 2 || ... + ~isequal(previousSize(1:2), nextSize(1:2)) + return +end +view = struct("preserve", true, ... + "xLimits", double(ax.XLim), "yLimits", double(ax.YLim)); +end + +function restoreImageView(ax, view) +if view.preserve + ax.XLim = view.xLimits; + ax.YLim = view.yLimits; +end +end diff --git a/apps/image_measurement/curvature/+curvature/+curvePreview/model.m b/apps/image_measurement/curvature/+curvature/+curvePreview/model.m new file mode 100644 index 000000000..6739bf993 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/model.m @@ -0,0 +1,11 @@ +% App-owned implementation for curvature.curvePreview.model within the curvature product workflow. +function value = model(imageData, points, fit, showDensePoints, scaleBar) +%MODEL Build the shared live/export Curvature overlay model. +value = struct( ... + "imageData", imageData, ... + "points", points, ... + "curve", curvature.curvePreview.visiblePath(points, imageData), ... + "fit", fit, ... + "showDensePoints", logical(showDensePoints), ... + "scaleBar", scaleBar); +end diff --git a/apps/image_measurement/curvature/+curvature/+curvePreview/present.m b/apps/image_measurement/curvature/+curvature/+curvePreview/present.m new file mode 100644 index 000000000..5ccedf0e0 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/present.m @@ -0,0 +1,19 @@ +% App-owned implementation for curvature.curvePreview.present within the curvature product workflow. +function view = present( ... + imageData, points, fit, showDensePoints, scaleBar, ... + editMode, calibration) +%PRESENT Prepare the preview renderer and mutually exclusive interactions. +model = curvature.curvePreview.model( ... + imageData, points, fit, showDensePoints, scaleBar); +imageSize = []; +if ~isempty(imageData) + imageSize = size(imageData); +end +view = labkit.app.view.Snapshot() ... + .renderPlot("preview", model) ... + .anchorPath("curve", points, ImageSize=imageSize, ... + Enabled=~isempty(imageData) && editMode == "curve") ... + .scaleReference("scaleReference", calibration.referenceLine, ... + ImageSize=imageSize, ... + Enabled=~isempty(imageData) && editMode == "reference"); +end diff --git a/apps/image_measurement/curvature/+curvature/+curvePreview/visiblePath.m b/apps/image_measurement/curvature/+curvature/+curvePreview/visiblePath.m new file mode 100644 index 000000000..42161b29f --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/visiblePath.m @@ -0,0 +1,7 @@ +% App-owned implementation for curvature.curvePreview.visiblePath within the curvature product workflow. +function path = visiblePath(points, imageData) +path = points; +if size(points,1)>=2 && ~isempty(imageData) + path = labkit.app.interaction.interpolateAnchorPath(points,size(imageData),Style="Curve",Closed=false); +end +end diff --git a/apps/image_measurement/curvature/+curvature/+debug/writeSamplePack.m b/apps/image_measurement/curvature/+curvature/+debug/writeSamplePack.m index 26d4cfb23..7a1a1e2ab 100644 --- a/apps/image_measurement/curvature/+curvature/+debug/writeSamplePack.m +++ b/apps/image_measurement/curvature/+curvature/+debug/writeSamplePack.m @@ -1,37 +1,30 @@ -% Expected caller: curvature.definitionActions during debug launch and unit tests. Input +% Expected caller: curvature.definition during debug launch and unit tests. Input % is a LabKit debug context. Output is a deterministic synthetic curvature % image sample pack. Side effects: writes anonymous debug images and records % a session manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Curvature debug image files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "curvature"); - imageFolder = fullfile(char(folders.sampleFolder), "images"); - ensureFolder(imageFolder); - - arcPath = string(fullfile(imageFolder, "curvature_arc_feature.png")); - edgePath = string(fullfile(imageFolder, "curvature_valid_low_contrast_arc.png")); - malformedPath = string(fullfile(imageFolder, "curvature_malformed_not_image.png")); + arcPath = sampleContext.samplePath("curvature/arc.png"); + edgePath = sampleContext.samplePath("curvature/low_contrast.png"); + malformedPath = sampleContext.samplePath("curvature/malformed.png"); imwrite(arcImage(false), char(arcPath)); imwrite(arcImage(true), char(edgePath)); writeTextFile(malformedPath, "not an image payload" + newline); - pack = struct( ... - "sampleFolder", folders.sampleFolder, ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", arcPath, ... - "boundaryFiles", struct("validEdge", edgePath, "malformed", malformedPath)); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_CurvatureMeasurement_app", ... - "description", "Anonymous arc-feature boundary image pack.", ... - "inputs", struct( ... - "representativeImage", arcPath, ... - "validEdgeImage", edgePath, ... - "malformedImage", malformedPath), ... - "outputFolder", folders.outputFolder); - pack.manifest = manifest; - recordManifest(debugLog, manifest); + project = curvature.projectSpec().Create(); + project.inputs.sources = sampleContext.sourceRecord( ... + "image1", "image", arcPath, true); + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-arc", InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact("arc", "image", arcPath), ... + sampleContext.artifact("lowContrast", "boundaryInput", edgePath), ... + sampleContext.artifact("malformed", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function image = arcImage(lowContrast) @@ -56,30 +49,6 @@ image = uint8(round(255 .* min(max(image, 0), 1))); end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 @@ -89,9 +58,3 @@ function writeTextFile(filepath, text) cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); end - -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/apps/image_measurement/curvature/+curvature/+resultFiles/buildResultTable.m b/apps/image_measurement/curvature/+curvature/+resultFiles/buildResultTable.m index ad75fb950..d0e9d6939 100644 --- a/apps/image_measurement/curvature/+curvature/+resultFiles/buildResultTable.m +++ b/apps/image_measurement/curvature/+curvature/+resultFiles/buildResultTable.m @@ -5,7 +5,7 @@ %BUILDRESULTTABLE Build export table for labkit_CurvatureMeasurement_app. % % Expected callers: -% Runtime V2 export actions and result-table tests. +% App SDK runtime export actions and result-table tests. % % Inputs/outputs: % Fit struct, image path, and optional length-result struct. Returns the diff --git a/apps/image_measurement/curvature/+curvature/+resultFiles/exportCsv.m b/apps/image_measurement/curvature/+curvature/+resultFiles/exportCsv.m new file mode 100644 index 000000000..4b58d0d55 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+resultFiles/exportCsv.m @@ -0,0 +1,68 @@ +% App-owned implementation for curvature.resultFiles.exportCsv within the curvature product workflow. +function applicationState = exportCsv( ... + applicationState, callbackContext) +%EXPORTCSV Write curvature measurements and a standard result manifest. +fit = applicationState.project.results.fit; +lengthResult = applicationState.project.results.length; +if ~fit.ok && ~lengthResult.ok + callbackContext.alert( ... + "Fit curvature or measure curve length before exporting.", ... + "No measurement result"); + return +end +choice = callbackContext.chooseOutputFile( ... + ["*.csv", "CSV files (*.csv)"], ... + defaultOutputPath(applicationState, "curvature_result.csv")); +if choice.Cancelled + callbackContext.appendStatus("Result CSV export cancelled."); + return +end +filepath = string(choice.Value); +try + resultTable = curvature.resultFiles.buildResultTable( ... + fit, applicationState.session.cache.imagePath, lengthResult); + writetable(resultTable, filepath); + written = writeManifest(applicationState, callbackContext, ... + filepath, "curvatureResults", "text/csv", ... + "curvature_result.labkit.json"); +catch ME + callbackContext.reportError("Export Curvature result CSV", ME); + callbackContext.alert(ME.message, "Could not export result CSV"); + return +end +applicationState.project.results.lastCsvExport = struct( ... + "csvPath", filepath, "manifestPath", string(written.Value)); +callbackContext.appendStatus("Exported result CSV: " + filepath); +end + +function written = writeManifest( ... + applicationState, callbackContext, filepath, ... + id, mediaType, manifestName) +[folder, name, extension] = fileparts(filepath); +output = labkit.app.result.File( ... + id, "primary", string(name) + string(extension), ... + MediaType=mediaType); +package = labkit.app.result.Package( ... + Outputs={output}, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=resultSummary(applicationState), ... + ManifestName=manifestName); +written = callbackContext.writeResultPackage(folder, package); +end + +function summary = resultSummary(applicationState) +summary = struct( ... + "fitOk", logical(applicationState.project.results.fit.ok), ... + "lengthOk", logical(applicationState.project.results.length.ok), ... + "pointCount", ... + size(applicationState.project.annotations.curvePoints, 1)); +end + +function filepath = defaultOutputPath(applicationState, filename) +folder = string(fileparts(applicationState.session.cache.imagePath)); +if strlength(folder) == 0 || ~isfolder(folder) + folder = string(pwd); +end +filepath = string(fullfile(folder, filename)); +end diff --git a/apps/image_measurement/curvature/+curvature/+resultFiles/exportOverlay.m b/apps/image_measurement/curvature/+curvature/+resultFiles/exportOverlay.m new file mode 100644 index 000000000..ff3e05a7d --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+resultFiles/exportOverlay.m @@ -0,0 +1,65 @@ +% App-owned implementation for curvature.resultFiles.exportOverlay within the curvature product workflow. +function applicationState = exportOverlay( ... + applicationState, callbackContext) +%EXPORTOVERLAY Write the shared preview model and a standard manifest. +if isempty(applicationState.session.cache.image) + callbackContext.alert( ... + "Open an image before exporting an overlay.", "No image loaded"); + return +end +choice = callbackContext.chooseOutputFile( ... + ["*.png", "PNG image (*.png)"], ... + defaultOutputPath(applicationState, "curvature_overlay.png")); +if choice.Cancelled + callbackContext.appendStatus("Overlay PNG export cancelled."); + return +end +filepath = string(choice.Value); +project = applicationState.project; +try + model = curvature.curvePreview.model( ... + applicationState.session.cache.image, ... + project.annotations.curvePoints, project.results.fit, ... + project.parameters.showDensePoints, ... + applicationState.session.view.scaleBar); + curvature.resultFiles.writeOverlayPng(model, filepath); + written = writeManifest(applicationState, callbackContext, ... + filepath, "curvatureOverlay", "image/png", ... + "curvature_overlay.labkit.json"); +catch ME + callbackContext.reportError("Export Curvature overlay PNG", ME); + callbackContext.alert(ME.message, "Could not export overlay PNG"); + return +end +applicationState.project.results.lastOverlayExport = struct( ... + "pngPath", filepath, "manifestPath", string(written.Value)); +callbackContext.appendStatus("Exported overlay PNG: " + filepath); +end + +function written = writeManifest( ... + applicationState, callbackContext, filepath, ... + id, mediaType, manifestName) +[folder, name, extension] = fileparts(filepath); +output = labkit.app.result.File( ... + id, "primary", string(name) + string(extension), ... + MediaType=mediaType); +package = labkit.app.result.Package( ... + Outputs={output}, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct( ... + "fitOk", logical(applicationState.project.results.fit.ok), ... + "lengthOk", logical(applicationState.project.results.length.ok), ... + "pointCount", ... + size(applicationState.project.annotations.curvePoints, 1)), ... + ManifestName=manifestName); +written = callbackContext.writeResultPackage(folder, package); +end + +function filepath = defaultOutputPath(applicationState, filename) +folder = string(fileparts(applicationState.session.cache.imagePath)); +if strlength(folder) == 0 || ~isfolder(folder) + folder = string(pwd); +end +filepath = string(fullfile(folder, filename)); +end diff --git a/apps/image_measurement/curvature/+curvature/+resultFiles/writeOverlayPng.m b/apps/image_measurement/curvature/+curvature/+resultFiles/writeOverlayPng.m index 12daf13b3..5735f2713 100644 --- a/apps/image_measurement/curvature/+curvature/+resultFiles/writeOverlayPng.m +++ b/apps/image_measurement/curvature/+curvature/+resultFiles/writeOverlayPng.m @@ -1,10 +1,9 @@ -% Expected caller: curvature export action. Inputs are a prepared preview -% model and output path. Side effect is one PNG rendered from the same model as -% the live preview, without reading runtime UI handles. +% App-owned implementation for curvature.resultFiles.writeOverlayPng within the curvature product workflow. function writeOverlayPng(model, outputPath) - figureHandle = figure("Visible", "off", "Color", "white"); - cleanup = onCleanup(@() close(figureHandle)); - ax = axes(figureHandle); - curvature.userInterface.renderCurvaturePreview(ax, model); - exportgraphics(ax, outputPath, "Resolution", 300); +%WRITEOVERLAYPNG Render the live Curvature overlay model to one PNG. +figureHandle = figure("Visible", "off", "Color", "white"); +cleanup = onCleanup(@() close(figureHandle)); +ax = axes(figureHandle); +curvature.curvePreview.draw(struct("image", ax), model); +exportgraphics(ax, outputPath, "Resolution", 300); end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/barSettingChanged.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/barSettingChanged.m new file mode 100644 index 000000000..d5302ab78 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/barSettingChanged.m @@ -0,0 +1,13 @@ +% App-owned implementation for curvature.scaleCalibration.barSettingChanged within the curvature product workflow. +function applicationState = barSettingChanged( ... + applicationState, changedValue, callbackContext) +%BARSETTINGCHANGED Normalize display-bar settings and invalidate its geometry. +barLength = double(applicationState.project.parameters.scaleBarLength); +if ~isscalar(barLength) || ~isfinite(barLength) || barLength < 0 + barLength = 0; +end +applicationState.project.parameters.scaleBarLength = barLength; +applicationState.session.view.scaleBar = []; +callbackContext.appendStatus( ... + "Scale bar setting changed to " + string(changedValue) + "."); +end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeLength.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeLength.m new file mode 100644 index 000000000..f87a8f57b --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeLength.m @@ -0,0 +1,19 @@ +% App-owned implementation for curvature.scaleCalibration.changeLength within the curvature product workflow. +function applicationState = changeLength( ... + applicationState, referenceLength, callbackContext) +%CHANGELENGTH Update the known physical reference length. +calibration = applicationState.project.annotations.calibration; +referenceLength = double(referenceLength); +if ~isscalar(referenceLength) || ~isfinite(referenceLength) || ... + referenceLength < 0 + referenceLength = 0; +end +applicationState.project.annotations.calibration = ... + labkit.app.interaction.scaleCalibration( ... + calibration.referencePixels, referenceLength, calibration.unit, ... + struct("referenceLine", calibration.referenceLine)); +applicationState.session.view.scaleBar = []; +applicationState = curvature.curveEdit.clearMeasurements(applicationState); +callbackContext.appendStatus( ... + "Scale reference length set to " + string(referenceLength) + "."); +end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/changePixels.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changePixels.m new file mode 100644 index 000000000..fb7d47492 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changePixels.m @@ -0,0 +1,21 @@ +% App-owned implementation for curvature.scaleCalibration.changePixels within the curvature product workflow. +function applicationState = changePixels( ... + applicationState, referencePixels, callbackContext) +%CHANGEPIXELS Replace the measured line with a typed pixel distance. +calibration = applicationState.project.annotations.calibration; +referencePixels = finiteNonnegative(referencePixels, 0); +applicationState.project.annotations.calibration = ... + labkit.app.interaction.scaleCalibration( ... + referencePixels, calibration.referenceLength, calibration.unit); +applicationState.session.view.scaleBar = []; +applicationState = curvature.curveEdit.clearMeasurements(applicationState); +callbackContext.appendStatus( ... + "Reference pixels set to " + string(referencePixels) + "."); +end + +function value = finiteNonnegative(value, fallback) +value = double(value); +if ~isscalar(value) || ~isfinite(value) || value < 0 + value = fallback; +end +end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeReference.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeReference.m new file mode 100644 index 000000000..729b03de7 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeReference.m @@ -0,0 +1,28 @@ +% App-owned implementation for curvature.scaleCalibration.changeReference within the curvature product workflow. +function applicationState = changeReference( ... + applicationState, endpoints, callbackContext) +%CHANGEREFERENCE Commit the managed two-point reference measurement. +calibration = applicationState.project.annotations.calibration; +endpoints = normalizeEndpoints(endpoints); +if size(endpoints, 1) > 2 + endpoints = endpoints(end - 1:end, :); +end +applicationState.project.annotations.calibration = ... + labkit.app.interaction.scaleCalibration( ... + NaN, calibration.referenceLength, calibration.unit, ... + struct("referenceLine", endpoints)); +applicationState.session.view.scaleBar = []; +applicationState = curvature.curveEdit.clearMeasurements(applicationState); +callbackContext.appendStatus("Scale reference updated."); +end + +function endpoints = normalizeEndpoints(endpoints) +if isempty(endpoints) + endpoints = zeros(0, 2); + return +end +endpoints = double(endpoints); +if size(endpoints, 2) ~= 2 || any(~isfinite(endpoints), "all") + endpoints = zeros(0, 2); +end +end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeUnit.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeUnit.m new file mode 100644 index 000000000..b38357bd6 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeUnit.m @@ -0,0 +1,16 @@ +% App-owned implementation for curvature.scaleCalibration.changeUnit within the curvature product workflow. +function applicationState = changeUnit( ... + applicationState, unitName, callbackContext) +%CHANGEUNIT Update the physical calibration unit. +calibration = applicationState.project.annotations.calibration; +applicationState.project.annotations.calibration = ... + labkit.app.interaction.scaleCalibration( ... + calibration.referencePixels, calibration.referenceLength, ... + string(unitName), ... + struct("referenceLine", calibration.referenceLine)); +applicationState.session.view.scaleBar = []; +applicationState = curvature.curveEdit.clearMeasurements(applicationState); +callbackContext.appendStatus( ... + "Scale unit set to " + ... + string(applicationState.project.annotations.calibration.unit) + "."); +end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/layoutSection.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/layoutSection.m new file mode 100644 index 000000000..f44e808ca --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/layoutSection.m @@ -0,0 +1,46 @@ +% App-owned implementation for curvature.scaleCalibration.layoutSection within the curvature product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare reference calibration and display scale-bar controls. +% Constant: numeric limits and steps preserve the legacy calibration controls. +maximumCalibrationValue = 1e6; +section = labkit.app.layout.section("scaleBarSection", "Scale Bar", { ... + labkit.app.layout.button("measureScaleReference", ... + "Measure reference pixels", ... + @curvature.scaleCalibration.toggleReference, ... + Tooltip="Draw a line over a known distance to calibrate pixels per physical unit."), ... + labkit.app.layout.slider("scaleReferencePixels", ... + Label="Reference pixels:", Value=0, Limits=[0 5000], Step=1, ... + OnValueChanged=@curvature.scaleCalibration.changePixels), ... + labkit.app.layout.slider("scaleReferenceLength", ... + Label="Reference length:", Value=100, ... + Limits=[0 maximumCalibrationValue], Step=10, ... + OnValueChanged=@curvature.scaleCalibration.changeLength), ... + labkit.app.layout.field("scaleCalibrationUnit", ... + Label="Scale unit:", Kind="choice", ... + Choices=["m", "cm", "mm", "um", "nm"], Value="um", ... + OnValueChanged=@curvature.scaleCalibration.changeUnit), ... + labkit.app.layout.slider("scaleBarLength", ... + Label="Scale bar length:", Value=100, ... + Limits=[0 maximumCalibrationValue], Step=10, ... + Bind="project.parameters.scaleBarLength", ... + OnValueChanged=@curvature.scaleCalibration.barSettingChanged), ... + labkit.app.layout.field("scaleBarPosition", ... + Label="Scale position:", Kind="choice", ... + Choices=["Bottom center", "Bottom left", "Bottom right", ... + "Top center", "Top left", "Top right"], ... + Value="Bottom right", ... + Bind="project.parameters.scaleBarPosition", ... + OnValueChanged=@curvature.scaleCalibration.barSettingChanged), ... + labkit.app.layout.field("scaleBarColor", ... + Label="Scale color:", Kind="choice", ... + Choices=["Black", "White"], Value="Black", ... + Bind="project.parameters.scaleBarColor", ... + OnValueChanged=@curvature.scaleCalibration.barSettingChanged), ... + labkit.app.layout.button("placeScaleBar", "Place scale bar", ... + @curvature.scaleCalibration.placeBar, ... + Tooltip="Place a display scale bar whose pixel length is derived from the current calibration."), ... + labkit.app.layout.field("scaleReferenceReadout", ... + Label="Reference px:", Kind="readonly", Value="-"), ... + labkit.app.layout.field("pixelsPerUnitReadout", ... + Label="Pixels/unit:", Kind="readonly", Value="-")}); +end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/placeBar.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/placeBar.m new file mode 100644 index 000000000..afef7f7d4 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/placeBar.m @@ -0,0 +1,29 @@ +% App-owned implementation for curvature.scaleCalibration.placeBar within the curvature product workflow. +function applicationState = placeBar( ... + applicationState, callbackContext) +%PLACEBAR Compute the configured display scale-bar overlay. +calibration = applicationState.project.annotations.calibration; +if isempty(applicationState.session.cache.image) || ... + ~calibration.isCalibrated + callbackContext.alert( ... + "Measure or enter reference pixels, then enter a positive " + ... + "reference length and unit.", "Calibration required"); + return +end +parameters = applicationState.project.parameters; +try + applicationState.session.view.scaleBar = ... + labkit.app.interaction.scaleBarGeometry( ... + size(applicationState.session.cache.image), calibration, ... + parameters.scaleBarLength, ... + parameters.scaleBarPosition, parameters.scaleBarColor); +catch ME + callbackContext.reportError("Place Curvature scale bar", ME); + callbackContext.alert(ME.message, "Could not place scale bar"); + return +end +applicationState.session.workflow.editMode = "none"; +callbackContext.appendStatus(sprintf( ... + "Placed scale bar: %.6g %s.", ... + parameters.scaleBarLength, calibration.unit)); +end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/present.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/present.m new file mode 100644 index 000000000..b4708f835 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/present.m @@ -0,0 +1,46 @@ +% App-owned implementation for curvature.scaleCalibration.present within the curvature product workflow. +function view = present(hasImage, calibration, parameters, editMode) +%PRESENT Describe calibration values, edit mode, and bar availability. +referenceEditing = editMode == "reference"; +curveEditing = editMode == "curve"; +editing = referenceEditing || curveEditing; +referencePixels = calibration.referencePixels; +referenceText = "-"; +if isfinite(referencePixels) + referenceText = sprintf("%.6g", referencePixels); +else + referencePixels = 0; +end +pixelsPerUnitText = "-"; +if calibration.pixelsPerUnit > 0 + pixelsPerUnitText = sprintf("%.6g px/%s", ... + calibration.pixelsPerUnit, calibration.unit); +end +view = labkit.app.view.Snapshot() ... + .text("measureScaleReference", ... + referenceActionText(referenceEditing)) ... + .enabled("measureScaleReference", hasImage && ~curveEditing) ... + .value("scaleReferencePixels", referencePixels) ... + .enabled("scaleReferencePixels", hasImage && ~editing) ... + .value("scaleReferenceLength", calibration.referenceLength) ... + .enabled("scaleReferenceLength", hasImage && ~curveEditing) ... + .value("scaleCalibrationUnit", string(calibration.unit)) ... + .enabled("scaleCalibrationUnit", hasImage && ~curveEditing) ... + .value("scaleBarLength", parameters.scaleBarLength) ... + .enabled("scaleBarLength", hasImage && ~curveEditing) ... + .value("scaleBarPosition", string(parameters.scaleBarPosition)) ... + .enabled("scaleBarPosition", hasImage && ~curveEditing) ... + .value("scaleBarColor", string(parameters.scaleBarColor)) ... + .enabled("scaleBarColor", hasImage && ~curveEditing) ... + .enabled("placeScaleBar", ... + hasImage && calibration.isCalibrated && ~editing) ... + .value("scaleReferenceReadout", referenceText) ... + .value("pixelsPerUnitReadout", pixelsPerUnitText); +end + +function value = referenceActionText(active) +value = "Measure reference pixels"; +if active + value = "Finish reference edit"; +end +end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/toggleReference.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/toggleReference.m new file mode 100644 index 000000000..27621da00 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/toggleReference.m @@ -0,0 +1,23 @@ +% App-owned implementation for curvature.scaleCalibration.toggleReference within the curvature product workflow. +function applicationState = toggleReference( ... + applicationState, callbackContext) +%TOGGLEREFERENCE Enter or leave managed two-point reference editing. +if isempty(applicationState.session.cache.image) + callbackContext.alert( ... + "Open an image before measuring reference pixels.", ... + "No image loaded"); + return +end +if string(applicationState.session.workflow.editMode) == "reference" + applicationState.session.workflow.editMode = "none"; + callbackContext.appendStatus("Finished reference-pixel edit."); +else + applicationState.session.workflow.editMode = "reference"; + applicationState.session.view.scaleBar = []; + applicationState = ... + curvature.curveEdit.clearMeasurements(applicationState); + callbackContext.appendStatus( ... + "Started reference-pixel edit. Double-click two endpoints and " + ... + "drag them to refine the reference line."); +end +end diff --git a/apps/image_measurement/curvature/+curvature/+sourceFiles/layoutSection.m b/apps/image_measurement/curvature/+curvature/+sourceFiles/layoutSection.m new file mode 100644 index 000000000..1fde53e7f --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+sourceFiles/layoutSection.m @@ -0,0 +1,16 @@ +% App-owned implementation for curvature.sourceFiles.layoutSection within the curvature product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare the single portable image source. +image = labkit.app.layout.fileList("imageFile", ... + Label="Image file", Filters=labkit.image.fileDialogFilter(), ... + MaxFiles=1, SelectionMode="single", ... + Bind="project.inputs.sources", ... + OnSelectionChanged=@curvature.sourceFiles.selectionChanged, ... + SourceRole="image", SourceIdPrefix="image", Required=true, ... + ChooseLabel="Choose image", EmptyText="No image loaded", ... + ChooseTooltip="Choose the image containing the curve to trace, calibrate, fit, and measure."); +section = labkit.app.layout.section("imageSection", "Image", { ... + image, ... + labkit.app.layout.field("pointCount", ... + Label="Curve points", Kind="readonly", Value="Points: 0")}); +end diff --git a/apps/image_measurement/curvature/+curvature/+sourceFiles/present.m b/apps/image_measurement/curvature/+curvature/+sourceFiles/present.m new file mode 100644 index 000000000..65ecc048f --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+sourceFiles/present.m @@ -0,0 +1,11 @@ +% App-owned implementation for curvature.sourceFiles.present within the curvature product workflow. +function view = present(imagePath, hasImage, pointCount) +%PRESENT Describe the image-source status and curve point count. +status = "No image loaded"; +if hasImage && strlength(string(imagePath)) > 0 + status = "Image loaded"; +end +view = labkit.app.view.Snapshot() ... + .text("imageFile", status) ... + .value("pointCount", "Points: " + string(pointCount)); +end diff --git a/apps/image_measurement/curvature/+curvature/+sourceFiles/selectionChanged.m b/apps/image_measurement/curvature/+curvature/+sourceFiles/selectionChanged.m new file mode 100644 index 000000000..74bcb89c3 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+sourceFiles/selectionChanged.m @@ -0,0 +1,21 @@ +% App-owned implementation for curvature.sourceFiles.selectionChanged within the curvature product workflow. +function applicationState = selectionChanged( ... + applicationState, selection, callbackContext) +%SELECTIONCHANGED Reset image-owned annotations after source replacement. +calibration = applicationState.project.annotations.calibration; +applicationState.project.annotations.curvePoints = zeros(0, 2); +applicationState.project.annotations.calibration = ... + labkit.app.interaction.scaleCalibration( ... + [], calibration.referenceLength, calibration.unit); +applicationState.session.workflow.editMode = "none"; +applicationState.session.view.scaleBar = []; +applicationState = curvature.curveEdit.clearMeasurements(applicationState); +if isempty(selection.Indices) || isempty(applicationState.session.cache.image) + applicationState.session.workflow.statusMessage = ... + "Open an image to trace a curve."; + callbackContext.appendStatus("Image cleared."); +else + applicationState.session.workflow.statusMessage = "Image loaded."; + callbackContext.appendStatus("Loaded image."); +end +end diff --git a/apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m b/apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 041952871..000000000 --- a/apps/image_measurement/curvature/+curvature/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,161 +0,0 @@ -% Expected caller: curvature.definition. Inputs are app-owned callback -% handles and initial app state. Output is a data-only UI 5 workbench layout -% for the Curvature Measurement app. -function layout = buildWorkbenchLayout(callbacks, ~) - layout = labkit.ui.layout.workbench("curvatureApp", ... - "Image Curvature Measurement", ... - "controlTabs", controlTabs(callbacks), ... - "workspace", curvatureWorkspace(), ... - "usageTitle", "Workflow Notes", ... - "usage", workflowNotesLines()); -end - -function tabs = controlTabs(callbacks) - tabs = {filesAnalysisTab(callbacks), summaryResultsTab(), logTab()}; -end - -function tab = filesAnalysisTab(callbacks) - tab = labkit.ui.layout.tab("filesAnalysis", "Files + Analysis", { ... - imageSection(callbacks), ... - curveEditingSection(callbacks), ... - scaleBarSection(callbacks), ... - fitExportSection(callbacks)}); -end - -function section = scaleBarSection(callbacks) - section = labkit.ui.layout.section("scaleBarSection", "Scale Bar", { ... - labkit.ui.layout.action("measureScaleReference", ... - "Measure reference pixels", callbacks.measureScaleReference, "enabled", false), ... - panner("scaleReferencePixels", "Reference pixels:", 0, ... - [0 5000], 1, false, "scaleCalibrationChanged", callbacks), ... - panner("scaleReferenceLength", "Reference length:", 100, ... - [0 1e6], 10, false, "scaleCalibrationChanged", callbacks), ... - labkit.ui.layout.field("scaleCalibrationUnit", "Scale unit:", ... - "kind", "dropdown", "items", {'m', 'cm', 'mm', 'um', 'nm'}, ... - "value", "um", "enabled", false, ... - "onChange", callbacks.scaleCalibrationChanged), ... - boundPanner("scaleBarLength", "Scale bar length:", 100, ... - [0 1e6], 10, "project.parameters.scaleBarLength", ... - "scaleBarSettingChanged"), ... - boundDropdown("scaleBarPosition", "Scale position:", ... - {'Bottom center', 'Bottom left', 'Bottom right', ... - 'Top center', 'Top left', 'Top right'}, "Bottom right", ... - "project.parameters.scaleBarPosition", "scaleBarSettingChanged"), ... - boundDropdown("scaleBarColor", "Scale color:", ... - {'Black', 'White'}, "Black", ... - "project.parameters.scaleBarColor", "scaleBarSettingChanged"), ... - labkit.ui.layout.action("placeScaleBar", "Place scale bar", ... - callbacks.placeScaleBar, "enabled", false), ... - labkit.ui.layout.field("scaleReferenceReadout", "Reference px:", ... - "kind", "readonly", "value", "-"), ... - labkit.ui.layout.field("pixelsPerUnitReadout", "Pixels/unit:", ... - "kind", "readonly", "value", "-")}); -end - -function tab = summaryResultsTab() - tab = labkit.ui.layout.tab("summaryResults", "Summary + Results", { ... - labkit.ui.layout.section("resultsSection", "Results", { ... - labkit.ui.layout.resultTable("resultTable", ... - "Curvature Results", ... - "columns", {'Metric', 'Value'}, ... - "data", curvature.userInterface.initialResultTable())}), ... - labkit.ui.layout.section("detailsSection", "Details", { ... - labkit.ui.layout.statusPanel("detailsText", "Details", ... - "value", {'No curvature result yet.'})})}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log", ... - "value", {'Ready.'})})}); -end - -function section = imageSection(callbacks) - section = labkit.ui.layout.section("imageSection", "Image", { ... - labkit.ui.layout.filePanel("imageFile", "Image file", ... - "mode", "single", ... - "filters", labkit.image.fileDialogFilter(), ... - "chooseLabel", "Choose image", ... - "status", "No image loaded", ... - "emptyText", "No image loaded", ... - "onChoose", callbacks.openImage), ... - labkit.ui.layout.field("pointCount", "Curve points", ... - "kind", "readonly", ... - "value", "Points: 0")}); -end - -function section = curveEditingSection(callbacks) - section = labkit.ui.layout.section("curveEditing", "Curve Editing", { ... - labkit.ui.layout.action("startCurveEdit", "Start curve edit", ... - callbacks.toggleCurveEdit), ... - labkit.ui.layout.group("curveEditActions", "", { ... - labkit.ui.layout.action("undoCurvePoint", "Undo last point", ... - callbacks.undoCurvePoint, "enabled", false), ... - labkit.ui.layout.action("clearCurve", "Clear curve", ... - callbacks.clearCurve, "enabled", false)})}); -end - -function section = fitExportSection(callbacks) - section = labkit.ui.layout.section("fitExport", "Fit + Export", { ... - labkit.ui.layout.field("densify", ... - "Densify before circle fit", ... - "kind", "checkbox", "value", true, ... - "Bind", "project.parameters.densify", ... - "Event", "fitSettingChanged"), ... - labkit.ui.layout.panner("densePointCount", ... - "Dense point count:", ... - "value", 300, ... - "limits", [3 100000], "step", 25, ... - "Bind", "project.parameters.densePointCount", ... - "Event", "fitSettingChanged"), ... - labkit.ui.layout.field("showDensePoints", ... - "Show dense fit points", ... - "kind", "checkbox", "value", true, ... - "Bind", "project.parameters.showDensePoints", ... - "Event", "viewSettingChanged"), ... - labkit.ui.layout.action("fitCurvature", "Fit circle + curvature", ... - callbacks.fitCurvature), ... - labkit.ui.layout.action("measureCurveLength", "Measure curve length", ... - callbacks.measureCurveLength), ... - labkit.ui.layout.action("exportCsv", "Export result CSV", ... - callbacks.exportCsv), ... - labkit.ui.layout.action("exportOverlay", "Export overlay PNG", ... - callbacks.exportOverlay)}); -end - -function control = panner(id, label, value, limits, step, enabled, eventName, callbacks) - control = labkit.ui.layout.panner(id, label, ... - "value", value, "limits", limits, "step", step, ... - "enabled", enabled, ... - "onChange", callbacks.(char(eventName))); -end - -function control = boundPanner(id, label, value, limits, step, bind, eventName) - control = labkit.ui.layout.panner(id, label, ... - "value", value, "limits", limits, "step", step, ... - "enabled", false, "Bind", bind, "Event", eventName); -end - -function control = boundDropdown(id, label, items, value, bind, eventName) - control = labkit.ui.layout.field(id, label, "kind", "dropdown", ... - "items", items, "value", value, "enabled", false, ... - "Bind", bind, "Event", eventName); -end - -function lines = workflowNotesLines() - lines = { ... - '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.'}; -end - -function workspace = curvatureWorkspace() - workspace = labkit.ui.layout.workspace("curvaturePreview", ... - "Image Preview", { ... - labkit.ui.layout.previewArea("imageAxes", "Image Preview", ... - "layout", "single", ... - "axisIds", {'image'}, ... - "axisTitles", {'Image + Circle Fit'})}); -end diff --git a/apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m b/apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m deleted file mode 100644 index e6d482d8b..000000000 --- a/apps/image_measurement/curvature/+curvature/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,141 +0,0 @@ -% Expected caller: Runtime V2. Input is canonical curvature state. Output is -% one deterministic control, preview, and managed-interaction presentation -% with no UI handles, file IO, or scientific side effects. -function view = presentWorkbench(state) - project = state.project; - session = state.session; - points = project.annotations.curvePoints; - fit = project.results.fit; - lengthResult = project.results.length; - hasImage = ~isempty(session.cache.image); - curveEditing = session.workflow.editMode == "curve"; - referenceEditing = session.workflow.editMode == "reference"; - editing = curveEditing || referenceEditing; - calibration = project.annotations.calibration; - - view = struct(); - view.controls.imageFile = fileSpec(session.cache.imagePath, hasImage); - summary = curvature.userInterface.summaryViewData( ... - session.cache.imagePath, points(:, 1), fit, lengthResult, ... - curveEditing, referenceEditing); - view.controls.pointCount = valueSpec(summary.pointCountText); - view.controls.resultTable = tableSpec(summary.tableData); - view.controls.detailsText = valueSpec(summary.details); - view.controls.startCurveEdit = struct( ... - "Enabled", hasImage && ~referenceEditing, ... - "Text", ternary(curveEditing, "Finish curve edit", "Start curve edit")); - view.controls.undoCurvePoint = enabledSpec(~isempty(points) && ~referenceEditing); - view.controls.clearCurve = enabledSpec(~isempty(points) && ~referenceEditing); - view.controls.densify = enabledSpec(~editing); - view.controls.densePointCount = enabledSpec(~editing); - view.controls.showDensePoints = enabledSpec(fit.ok && ~editing); - view.controls.fitCurvature = enabledSpec(size(points, 1) >= 3 && ~editing); - view.controls.measureCurveLength = enabledSpec(size(points, 1) >= 2 && ~editing); - view.controls.exportCsv = enabledSpec((fit.ok || lengthResult.ok) && ~editing); - view.controls.exportOverlay = enabledSpec(hasImage && ~editing); - view = scalePresentation(view, state, hasImage, referenceEditing, curveEditing); - - curve = visibleCurve(points, session.cache.image); - model = struct( ... - "imageData", session.cache.image, ... - "points", points, ... - "curve", curve, ... - "fit", fit, ... - "showDensePoints", project.parameters.showDensePoints, ... - "scaleBar", session.view.scaleBar); - view.previews.imageAxes.Axes.image = struct( ... - "Renderer", "curvaturePreview", "Model", model); - if hasImage && curveEditing - view.interactions.curve = struct( ... - "Kind", "anchors", ... - "Targets", "imageAxes", ... - "Value", points, ... - "Event", "curvePointsEdited", ... - "ImageSize", size(session.cache.image), ... - "ChangePolicy", "commit", ... - "Options", struct("closed", false, "style", "Curve", ... - "color", [0 0.45 0.95])); - elseif hasImage && referenceEditing - view.interactions.scaleReference = struct( ... - "Kind", "scaleBarReference", ... - "Targets", "imageAxes", ... - "Value", calibration.referenceLine, ... - "Event", "scaleReferenceEdited", ... - "ImageSize", size(session.cache.image), ... - "ChangePolicy", "commit", ... - "Options", struct("color", [1 1 0])); - end -end - -function view = scalePresentation(view, state, hasImage, editing, curveEditing) - calibration = state.project.annotations.calibration; - referencePixels = calibration.referencePixels; - referenceReadout = "-"; - if isfinite(referencePixels) - referenceReadout = sprintf('%.6g', referencePixels); - else - referencePixels = 0; - end - pixelsReadout = "-"; - if calibration.pixelsPerUnit > 0 - pixelsReadout = sprintf('%.6g px/%s', ... - calibration.pixelsPerUnit, calibration.unit); - end - view.controls.measureScaleReference = struct( ... - "Enabled", hasImage && ~curveEditing, ... - "Text", ternary(editing, ... - "Finish reference edit", "Measure reference pixels")); - view.controls.scaleReferencePixels = controlSpec( ... - hasImage && ~editing && ~curveEditing, referencePixels); - view.controls.scaleReferenceLength = controlSpec( ... - hasImage && ~curveEditing, calibration.referenceLength); - view.controls.scaleCalibrationUnit = controlSpec( ... - hasImage && ~curveEditing, calibration.unit); - view.controls.scaleBarLength = enabledSpec(hasImage && ~curveEditing); - view.controls.scaleBarPosition = enabledSpec(hasImage && ~curveEditing); - view.controls.scaleBarColor = enabledSpec(hasImage && ~curveEditing); - view.controls.placeScaleBar = enabledSpec( ... - hasImage && calibration.isCalibrated && ~editing && ~curveEditing); - view.controls.scaleReferenceReadout = valueSpec(referenceReadout); - view.controls.pixelsPerUnitReadout = valueSpec(pixelsReadout); -end - -function curve = visibleCurve(points, imageData) - curve = zeros(0, 2); - if isempty(imageData) || size(points, 1) < 2 - return; - end - curve = labkit.ui.interaction.anchorPath(points, size(imageData), ... - "Style", "Curve", "Closed", false); -end - -function spec = fileSpec(pathValue, loaded) - spec = struct("Files", string(pathValue), ... - "Status", ternary(loaded, "Image loaded", "No image loaded")); -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = tableSpec(value) - spec = struct(); - spec.Data = value; -end - -function spec = enabledSpec(value) - spec = struct("Enabled", logical(value)); -end - -function spec = controlSpec(enabled, value) - spec = struct("Enabled", logical(enabled), "Value", value); -end - -function value = ternary(condition, trueValue, falseValue) - if condition - value = trueValue; - else - value = falseValue; - end -end diff --git a/apps/image_measurement/curvature/+curvature/+userInterface/renderCurvaturePreview.m b/apps/image_measurement/curvature/+curvature/+userInterface/renderCurvaturePreview.m deleted file mode 100644 index 62adbeccb..000000000 --- a/apps/image_measurement/curvature/+curvature/+userInterface/renderCurvaturePreview.m +++ /dev/null @@ -1,87 +0,0 @@ -% Expected caller: the registered curvature V2 renderer. Inputs are one -% semantic image axes and a prepared model. Side effects are limited to -% non-pickable preview graphics on the supplied axes. -function renderCurvaturePreview(ax, model) - view = captureImageView(ax, model.imageData); - labkit.ui.plot.clear(ax, "ResetScale", true); - if isempty(model.imageData) - title(ax, 'Image + Circle Fit'); - box(ax, 'on'); - return; - end - if ndims(model.imageData) == 2 - imagesc(ax, model.imageData); - colormap(ax, gray(256)); - else - image(ax, model.imageData); - end - axis(ax, 'image'); - ax.YDir = 'reverse'; - hold(ax, 'on'); - curvature.userInterface.plotStaticCurveAnchors( ... - ax, model.points, model.curve, model.fit, model.showDensePoints); - drawFit(ax, model.fit); - drawScaleBar(ax, model.scaleBar); - hold(ax, 'off'); - title(ax, fitTitle(model.fit)); - box(ax, 'on'); - restoreImageView(ax, view); -end - -function drawFit(ax, fit) - if ~fit.ok - return; - end - t = linspace(-pi, pi, 600); - plot(ax, fit.xc_px + fit.R_px .* cos(t), ... - fit.yc_px + fit.R_px .* sin(t), 'r-', 'LineWidth', 2, ... - 'HitTest', 'off', 'PickableParts', 'none'); - plot(ax, fit.xc_px, fit.yc_px, 'ro', 'MarkerFaceColor', 'r', ... - 'HitTest', 'off', 'PickableParts', 'none'); -end - -function drawScaleBar(ax, scaleBar) - if isempty(scaleBar) - return; - end - plot(ax, scaleBar.line(:, 1), scaleBar.line(:, 2), '-', ... - 'Color', scaleBar.color, 'LineWidth', 3, ... - 'HitTest', 'off', 'PickableParts', 'none'); - text(ax, scaleBar.labelPosition(1), scaleBar.labelPosition(2), ... - scaleBar.label, 'Color', scaleBar.color, 'FontWeight', 'bold', ... - 'HorizontalAlignment', 'center', ... - 'VerticalAlignment', char(scaleBar.verticalAlignment), ... - 'HitTest', 'off', 'PickableParts', 'none'); -end - -function value = fitTitle(fit) - value = 'Image + Circle Fit'; - if fit.ok - value = sprintf('R = %.4g %s, curvature = %.4g %s, RMSE = %.3g %s', ... - fit.R_show, fit.unitLen, fit.kappa_show, fit.unitK, ... - fit.rmse_show, fit.unitLen); - end -end - -function view = captureImageView(ax, imageData) - view = struct("preserve", false, "xLimits", [], "yLimits", []); - images = findobj(ax, 'Type', 'image'); - if isempty(images) || isempty(imageData) - return; - end - previousSize = size(images(1).CData); - nextSize = size(imageData); - if numel(previousSize) < 2 || numel(nextSize) < 2 || ... - ~isequal(previousSize(1:2), nextSize(1:2)) - return; - end - view = struct("preserve", true, ... - "xLimits", double(ax.XLim), "yLimits", double(ax.YLim)); -end - -function restoreImageView(ax, view) - if view.preserve - ax.XLim = view.xLimits; - ax.YLim = view.yLimits; - end -end diff --git a/apps/image_measurement/curvature/+curvature/+workbench/buildLayout.m b/apps/image_measurement/curvature/+curvature/+workbench/buildLayout.m new file mode 100644 index 000000000..4e471da32 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+workbench/buildLayout.m @@ -0,0 +1,48 @@ +% App-owned implementation for curvature.workbench.buildLayout within the curvature product workflow. +function layout = buildLayout() +%BUILDLAYOUT Assemble the Curvature Measurement product workflow. +controls = { ... + labkit.app.layout.tab("filesAnalysis", "Files + Analysis", { ... + curvature.sourceFiles.layoutSection(), ... + curvature.curveEdit.layoutSection(), ... + curvature.scaleCalibration.layoutSection(), ... + curvature.analysisRun.layoutSection()}), ... + labkit.app.layout.tab("summaryResults", "Summary + Results", { ... + labkit.app.layout.section("resultsSection", "Results", { ... + labkit.app.layout.dataTable("resultTable", ... + Title="Curvature Results", ... + Columns=["Metric", "Value"])}), ... + labkit.app.layout.section("detailsSection", "Details", { ... + labkit.app.layout.statusPanel("detailsText", ... + Title="Details")})}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("appLog", Title="Log")})})}; +curve = labkit.app.interaction.anchorPath("curve", ... + @curvature.curveEdit.change, Axis="image", ... + Style=struct("closed", false, "color", [0 0.45 0.95]), ... + Instruction="Double-click blank space to add or insert points; " + ... + "drag to move; double-click a point to delete.", ... + ViewportPolicy="preserve"); +reference = labkit.app.interaction.scaleReference("scaleReference", ... + @curvature.scaleCalibration.changeReference, Axis="image", ... + Style=struct("color", [1 1 0]), ... + Instruction="Double-click two reference endpoints and drag to refine.", ... + ViewportPolicy="preserve"); +workspace = labkit.app.layout.workspace( ... + labkit.app.layout.plotArea("preview", ... + @curvature.curvePreview.draw, ... + Title="Image Preview", AxisIds="image", ... + AxisTitles="Image + Circle Fit", ... + Interactions={curve, reference}), ... + Title="Image Preview"); +usage = [ ... + "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."]; +layout = labkit.app.layout.workbench(controls, Workspace=workspace, ... + UsageTitle="Workflow Notes", Usage=usage); +end diff --git a/apps/image_measurement/curvature/+curvature/+workbench/present.m b/apps/image_measurement/curvature/+curvature/+workbench/present.m new file mode 100644 index 000000000..f66dae6de --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+workbench/present.m @@ -0,0 +1,32 @@ +% App-owned implementation for curvature.workbench.present within the curvature product workflow. +function view = present(applicationState) +%PRESENT Compose Curvature Measurement's complete semantic snapshot. +project = applicationState.project; +session = applicationState.session; +points = project.annotations.curvePoints; +fit = project.results.fit; +lengthResult = project.results.length; +imageData = session.cache.image; +hasImage = ~isempty(imageData); +editMode = string(session.workflow.editMode); + +summary = curvature.curvePreview.presentationData.summaryViewData( ... + session.cache.imagePath, points(:, 1), fit, lengthResult, ... + editMode == "curve", editMode == "reference"); +view = curvature.sourceFiles.present( ... + session.cache.imagePath, hasImage, size(points, 1)) ... + .include(curvature.curveEdit.present( ... + hasImage, points, editMode)) ... + .include(curvature.scaleCalibration.present( ... + hasImage, project.annotations.calibration, ... + project.parameters, editMode)) ... + .include(curvature.analysisRun.present( ... + hasImage, points, fit, lengthResult, editMode)) ... + .include(curvature.curvePreview.present( ... + imageData, points, fit, project.parameters.showDensePoints, ... + session.view.scaleBar, editMode, ... + project.annotations.calibration)) ... + .tableData("resultTable", summary.tableData, ... + Columns=["Metric", "Value"]) ... + .text("detailsText", join(string(summary.details), newline)); +end diff --git a/apps/image_measurement/curvature/+curvature/createSession.m b/apps/image_measurement/curvature/+curvature/createSession.m index 0225a8195..fb6f254f9 100644 --- a/apps/image_measurement/curvature/+curvature/createSession.m +++ b/apps/image_measurement/curvature/+curvature/createSession.m @@ -1,8 +1,12 @@ % Rebuild the decoded image and transient interaction state from one validated -% Curvature project. Runtime V2 calls this after source relinking. -function session = createSession(project) - imagePath = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "image"); +% Curvature project. App SDK runtime calls this after source relinking. +function session = createSession(project, context) + paths = strings(0, 1); + if ~isempty(project.inputs.sources) + paths = context.resolveSourcePaths(project.inputs.sources); + end + imagePath = ""; + if ~isempty(paths), imagePath = paths(1); end imageData = []; if strlength(imagePath) > 0 && isfile(imagePath) imageData = imread(imagePath); diff --git a/apps/image_measurement/curvature/+curvature/definition.m b/apps/image_measurement/curvature/+curvature/definition.m index f901fdc05..0e449e1f1 100644 --- a/apps/image_measurement/curvature/+curvature/definition.m +++ b/apps/image_measurement/curvature/+curvature/definition.m @@ -1,23 +1,14 @@ % App-owned runtime definition for labkit_CurvatureMeasurement_app. % Expected caller: the public app entrypoint. Output is a declarative % LabKit app definition; side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_CurvatureMeasurement_app", ... - "Id", "curvature", ... - "Title", "Image Curvature Measurement", ... - "DisplayName", "Curvature Measurement", ... - "Family", "Image Measurement", ... - "AppVersion", "1.4.6", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "image", ">=2.0 <3"), ... - "Project", curvature.projectSpec(), ... - "CreateSession", @curvature.createSession, ... - "Layout", @curvature.userInterface.buildWorkbenchLayout, ... - "Actions", curvature.definitionActions(), ... - "Present", @curvature.userInterface.presentWorkbench, ... - "Renderers", struct("curvaturePreview", ... - @curvature.userInterface.renderCurvaturePreview), ... - "DebugSample", @curvature.debug.writeSamplePack); +function app = definition() + app = labkit.app.Definition( ... + Entrypoint="labkit_CurvatureMeasurement_app", AppId="curvature", ... + Title="Image Curvature Measurement", DisplayName="Curvature Measurement", ... + Family="Image Measurement", AppVersion="1.5.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2", "image", ">=2.0 <3"), ... + ProjectSchema=curvature.projectSpec(), CreateSession=@curvature.createSession, ... + Workbench=curvature.workbench.buildLayout(), ... + PresentWorkbench=@curvature.workbench.present, ... + BuildDebugSample=@curvature.debug.writeSamplePack); end diff --git a/apps/image_measurement/curvature/+curvature/definitionActions.m b/apps/image_measurement/curvature/+curvature/definitionActions.m deleted file mode 100644 index 47d96edac..000000000 --- a/apps/image_measurement/curvature/+curvature/definitionActions.m +++ /dev/null @@ -1,386 +0,0 @@ -% App-owned V2 action registry for Curvature Measurement. Handlers receive -% canonical state/events/services and own durable curve/calibration edits, -% scientific results, model-based exports, and workflow messages. -function actions = definitionActions() - actions = struct( ... - "openImage", @onOpenImage, ... - "toggleCurveEdit", @onToggleCurveEdit, ... - "curvePointsEdited", @onCurvePointsEdited, ... - "undoCurvePoint", @onUndoCurvePoint, ... - "clearCurve", @onClearCurve, ... - "measureScaleReference", @onMeasureScaleReference, ... - "scaleReferenceEdited", @onScaleReferenceEdited, ... - "scaleCalibrationChanged", @onScaleCalibrationChanged, ... - "scaleBarSettingChanged", @onScaleBarSettingChanged, ... - "placeScaleBar", @onPlaceScaleBar, ... - "fitSettingChanged", @onFitSettingChanged, ... - "viewSettingChanged", @onViewSettingChanged, ... - "fitCurvature", @onFitCurvature, ... - "measureCurveLength", @onMeasureCurveLength, ... - "exportCsv", @onExportCsv, ... - "exportOverlay", @onExportOverlay); -end - -function state = onOpenImage(state, event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - if isempty(paths) - state = services.workflow.log(state, "Image selection cancelled."); - return; - end - try - imageData = imread(paths(1)); - catch ME - services.diagnostics.report('Could not read image', ME); - services.dialogs.alert(ME.message, 'Could not read image'); - return; - end - state.project.inputs.sources = services.project.sourceRecord( ... - "image", "image", paths(1), true); - state.project.annotations.curvePoints = zeros(0, 2); - calibration = state.project.annotations.calibration; - state.project.annotations.calibration = ... - labkit.ui.interaction.scaleBarCalibration( ... - [], calibration.referenceLength, calibration.unit); - state.session.cache.imagePath = paths(1); - state.session.cache.image = imageData; - state.session.workflow.editMode = "none"; - state.session.workflow.statusMessage = "Image loaded."; - state.session.view.scaleBar = []; - state = clearMeasurements(state); - state = services.workflow.log(state, "Loaded image: " + paths(1)); -end - -function state = onToggleCurveEdit(state, ~, services) - if isempty(state.session.cache.image) - services.dialogs.alert('Open an image before editing curve points.', ... - 'No image loaded'); - return; - end - if state.session.workflow.editMode == "curve" - state.session.workflow.editMode = "none"; - message = "Finished curve edit."; - else - state.session.workflow.editMode = "curve"; - state = clearMeasurements(state); - message = ["Started curve edit. Double-click blank image space to " ... - "add or insert points; drag points to move them; double-click " ... - "a point to delete it."]; - end - state = services.workflow.log(state, message); -end - -function state = onCurvePointsEdited(state, event, services) - points = normalizePoints(event.value); - state.project.annotations.curvePoints = points; - state = clearMeasurements(state); - state = services.workflow.log(state, sprintf( ... - 'Curve edit updated: %d point(s).', size(points, 1))); -end - -function state = onUndoCurvePoint(state, ~, services) - points = state.project.annotations.curvePoints; - if isempty(points) - return; - end - state.project.annotations.curvePoints(end, :) = []; - state = clearMeasurements(state); - state = services.workflow.log(state, "Undid last curve point."); -end - -function state = onClearCurve(state, ~, services) - state.project.annotations.curvePoints = zeros(0, 2); - state = clearMeasurements(state); - state = services.workflow.log(state, "Cleared curve points."); -end - -function state = onMeasureScaleReference(state, ~, services) - if isempty(state.session.cache.image) - services.dialogs.alert( ... - 'Open an image before measuring reference pixels.', ... - 'No image loaded'); - return; - end - if state.session.workflow.editMode == "reference" - state.session.workflow.editMode = "none"; - message = "Finished reference-pixel edit."; - else - state.session.workflow.editMode = "reference"; - state.session.view.scaleBar = []; - message = ["Started reference-pixel edit. Double-click two endpoints " ... - "and drag them to refine the reference line."]; - end - state = clearMeasurements(state); - state = services.workflow.log(state, message); -end - -function state = onScaleReferenceEdited(state, event, ~) - points = normalizePoints(event.value); - if size(points, 1) > 2 - points = points(end-1:end, :); - end - calibration = state.project.annotations.calibration; - state.project.annotations.calibration = ... - labkit.ui.interaction.scaleBarCalibration( ... - NaN, calibration.referenceLength, calibration.unit, ... - struct("referenceLine", points)); - state.session.view.scaleBar = []; - state = clearMeasurements(state); -end - -function state = onScaleCalibrationChanged(state, event, ~) - calibration = state.project.annotations.calibration; - referencePixels = calibration.referencePixels; - referenceLength = calibration.referenceLength; - unit = calibration.unit; - referenceLine = calibration.referenceLine; - target = string(event.target); - if target == "scaleReferencePixels" - referencePixels = positiveOrNaN(event.value); - referenceLine = zeros(0, 2); - elseif target == "scaleReferenceLength" - referenceLength = nonnegativeScalar(event.value, referenceLength); - elseif target == "scaleCalibrationUnit" - unit = string(event.value); - end - state.project.annotations.calibration = ... - labkit.ui.interaction.scaleBarCalibration( ... - referencePixels, referenceLength, unit, ... - struct("referenceLine", referenceLine)); - state.session.view.scaleBar = []; - state = clearMeasurements(state); -end - -function state = onScaleBarSettingChanged(state, ~, ~) - state.project.parameters.scaleBarLength = nonnegativeScalar( ... - state.project.parameters.scaleBarLength, 0); - state.session.view.scaleBar = []; -end - -function state = onPlaceScaleBar(state, ~, services) - calibration = state.project.annotations.calibration; - if isempty(state.session.cache.image) || ~calibration.isCalibrated - services.dialogs.alert(["Measure or enter reference pixels, then " ... - "enter a positive reference length and unit."], ... - 'Calibration required'); - return; - end - try - state.session.view.scaleBar = ... - labkit.ui.interaction.scaleBarGeometry( ... - size(state.session.cache.image), calibration, ... - state.project.parameters.scaleBarLength, ... - state.project.parameters.scaleBarPosition, ... - state.project.parameters.scaleBarColor); - state.session.workflow.editMode = "none"; - state = services.workflow.log(state, sprintf( ... - 'Placed scale bar: %.6g %s.', ... - state.project.parameters.scaleBarLength, calibration.unit)); - catch ME - services.diagnostics.report('Could not place scale bar', ME); - services.dialogs.alert(ME.message, 'Could not place scale bar'); - end -end - -function state = onFitSettingChanged(state, ~, ~) - state.project.parameters.densePointCount = max(3, round( ... - nonnegativeScalar(state.project.parameters.densePointCount, 300))); - state = clearMeasurements(state); -end - -function state = onViewSettingChanged(state, ~, ~) - state.project.parameters.showDensePoints = ... - logical(state.project.parameters.showDensePoints); -end - -function state = onFitCurvature(state, ~, services) - points = state.project.annotations.curvePoints; - if size(points, 1) < 3 - services.dialogs.alert( ... - 'At least 3 curve points are required to fit curvature.', ... - 'Not enough points'); - return; - end - try - path = visibleCurve(state); - task = curvature.analysisRun.fitTask(points, path, ... - state.project.annotations.calibration, struct( ... - "doDensify", state.project.parameters.densify, ... - "denseN", state.project.parameters.densePointCount)); - if state.project.results.fit.ok && ... - state.session.cache.fitFingerprint == task.fingerprint - state = services.workflow.log(state, ... - "Curvature fit already matches current curve and scale."); - return; - end - fit = curvature.analysisRun.computeCurvatureFit( ... - task.points(:, 1), task.points(:, 2), task.calibration, ... - task.options.doDensify, task.options.denseN, ... - task.fitPath(:, 1), task.fitPath(:, 2)); - catch ME - services.diagnostics.report('Circle fit failed', ME); - services.dialogs.alert(ME.message, 'Circle fit failed'); - return; - end - state.project.results.fit = fit; - state.project.results.length = ... - curvature.analysisRun.lengthResultFromFit(fit); - state.project.results.lastCsvExport = []; - state.project.results.lastOverlayExport = []; - state.session.cache.fitFingerprint = task.fingerprint; - state.session.cache.lengthFingerprint = ""; - state = services.workflow.log(state, sprintf( ... - 'Fit complete: R = %.6g %s, curvature = %.6g %s.', ... - fit.R_show, fit.unitLen, fit.kappa_show, fit.unitK)); -end - -function state = onMeasureCurveLength(state, ~, services) - points = state.project.annotations.curvePoints; - if size(points, 1) < 2 - services.dialogs.alert( ... - 'At least 2 curve points are required to measure curve length.', ... - 'Not enough points'); - return; - end - try - path = visibleCurve(state); - task = curvature.analysisRun.lengthTask(points, path, ... - state.project.annotations.calibration); - if state.project.results.length.ok && ... - state.session.cache.lengthFingerprint == task.fingerprint - state = services.workflow.log(state, ... - "Curve length already matches current curve and scale."); - return; - end - result = curvature.analysisRun.computeCurveLength( ... - task.lengthPath(:, 1), task.lengthPath(:, 2), task.calibration); - catch ME - services.diagnostics.report('Curve length failed', ME); - services.dialogs.alert(ME.message, 'Curve length failed'); - return; - end - state.project.results.length = result; - state.project.results.lastCsvExport = []; - state.session.cache.lengthFingerprint = task.fingerprint; - state = services.workflow.log(state, sprintf( ... - 'Curve length measured: %.6g %s.', ... - result.length_show, result.unitLen)); -end - -function state = onExportCsv(state, ~, services) - fit = state.project.results.fit; - lengthResult = state.project.results.length; - if ~fit.ok && ~lengthResult.ok - services.dialogs.alert( ... - 'Fit curvature or measure curve length before exporting.', ... - 'No measurement result'); - return; - end - [out, cancelled] = services.dialogs.outputFile( ... - '*.csv', 'Export curvature result CSV', 'curvature_result.csv'); - if cancelled - state = services.workflow.log(state, "Result CSV export cancelled."); - return; - end - tableData = curvature.resultFiles.buildResultTable( ... - fit, state.session.cache.imagePath, lengthResult); - writetable(tableData, out); - [manifestPath, ~] = writeManifest(state, services, out, ... - "curvatureResults", "text/csv", "curvature_result.labkit.json"); - state.project.results.lastCsvExport = struct( ... - "csvPath", string(out), "manifestPath", string(manifestPath)); - state = services.workflow.log(state, "Exported result CSV: " + string(out)); -end - -function state = onExportOverlay(state, ~, services) - if isempty(state.session.cache.image) - services.dialogs.alert('Open an image before exporting an overlay.', ... - 'No image loaded'); - return; - end - [out, cancelled] = services.dialogs.outputFile( ... - '*.png', 'Export overlay PNG', 'curvature_overlay.png'); - if cancelled - state = services.workflow.log(state, "Overlay PNG export cancelled."); - return; - end - model = previewModel(state); - curvature.resultFiles.writeOverlayPng(model, out); - [manifestPath, ~] = writeManifest(state, services, out, ... - "curvatureOverlay", "image/png", "curvature_overlay.labkit.json"); - state.project.results.lastOverlayExport = struct( ... - "pngPath", string(out), "manifestPath", string(manifestPath)); - state = services.workflow.log(state, "Exported overlay PNG: " + string(out)); -end - -function path = visibleCurve(state) - points = state.project.annotations.curvePoints; - path = points; - if size(points, 1) >= 2 && ~isempty(state.session.cache.image) - path = labkit.ui.interaction.anchorPath( ... - points, size(state.session.cache.image), ... - "Style", "Curve", "Closed", false); - end -end - -function model = previewModel(state) - model = struct( ... - "imageData", state.session.cache.image, ... - "points", state.project.annotations.curvePoints, ... - "curve", visibleCurve(state), ... - "fit", state.project.results.fit, ... - "showDensePoints", state.project.parameters.showDensePoints, ... - "scaleBar", state.session.view.scaleBar); -end - -function [manifestPath, report] = writeManifest( ... - state, services, outputPath, id, mediaType, manifestName) - [folder, name, extension] = fileparts(outputPath); - output = services.results.output(id, "primary", ... - string(name) + string(extension), mediaType); - summary = struct( ... - "fitOk", state.project.results.fit.ok, ... - "lengthOk", state.project.results.length.ok, ... - "pointCount", size(state.project.annotations.curvePoints, 1)); - spec = struct( ... - "Outputs", output, "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, "Summary", summary, ... - "ManifestName", manifestName); - [manifestPath, report] = services.results.writeManifest(folder, spec); -end - -function state = clearMeasurements(state) - state.project.results.fit = curvature.analysisRun.emptyFitResult(); - state.project.results.length = curvature.analysisRun.emptyLengthResult(); - state.project.results.lastCsvExport = []; - state.project.results.lastOverlayExport = []; - state.session.cache.fitFingerprint = ""; - state.session.cache.lengthFingerprint = ""; -end - -function points = normalizePoints(value) - if isempty(value) - points = zeros(0, 2); - return; - end - points = double(value); - if size(points, 2) ~= 2 || any(~isfinite(points), 'all') - points = zeros(0, 2); - end -end - -function value = positiveOrNaN(value) - value = double(value); - if isempty(value) || ~isscalar(value) || ~isfinite(value) || value <= 0 - value = NaN; - end -end - -function value = nonnegativeScalar(value, fallback) - value = double(value); - if isempty(value) || ~isscalar(value) || ~isfinite(value) || value < 0 - value = fallback; - end -end diff --git a/apps/image_measurement/curvature/+curvature/projectSpec.m b/apps/image_measurement/curvature/+curvature/projectSpec.m index 0d314b320..a6b6cc7df 100644 --- a/apps/image_measurement/curvature/+curvature/projectSpec.m +++ b/apps/image_measurement/curvature/+curvature/projectSpec.m @@ -1,17 +1,14 @@ -% App-owned durable Curvature contract. Runtime V2 calls this single entry for +% App-owned durable Curvature contract. App SDK runtime calls this single entry for % project creation, validation, and each required version migration step. function spec = projectSpec() - spec = struct( ... - "Version", 2, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", @migrateProject); + spec = labkit.app.project.Schema(Version=2, Create=@createProject, ... + Validate=@validateProject, Migrate=@migrateProject); end function project = createProject() project = struct(); project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + labkit.app.project.emptySourceRecords()); project.parameters = struct( ... "densify", true, ... "densePointCount", 300, ... @@ -21,7 +18,7 @@ "scaleBarColor", "Black"); project.annotations = struct( ... "curvePoints", zeros(0, 2), ... - "calibration", labkit.ui.interaction.scaleBarCalibration( ... + "calibration", labkit.app.interaction.scaleCalibration( ... [], 100, "um")); project.results = struct( ... "fit", curvature.analysisRun.emptyFitResult(), ... diff --git a/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m b/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m index ec926d623..21b45006f 100644 --- a/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m +++ b/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m @@ -1,6 +1,5 @@ function varargout = labkit_CurvatureMeasurement_app(varargin) %LABKIT_CURVATUREMEASUREMENT_APP Measure curve radius and curvature from images. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @curvature.definition, varargin{:}); + [varargout{1:nargout}] = curvature.definition().launch(varargin{:}); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+debug/writeSamplePack.m b/apps/image_measurement/flir_thermal/+flir_thermal/+debug/writeSamplePack.m index dd8a9a97a..6b9bc7ab2 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+debug/writeSamplePack.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+debug/writeSamplePack.m @@ -1,18 +1,17 @@ -% Expected caller: flir_thermal.definitionActions during debug launch and unit tests. Input +% Expected caller: flir_thermal.definition during debug launch and unit tests. Input % is a LabKit debug context. Output is a deterministic synthetic radiometric % JPEG-like sample pack. Side effects: writes anonymous debug files and records % a session manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write FLIR Thermal debug radiometric files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "flir_thermal"); - sampleFolder = fullfile(char(folders.sampleFolder), "flir_thermal"); - ensureFolder(sampleFolder); - - warmPath = string(fullfile(sampleFolder, "flir_representative_gradient_debug.jpg")); - coolPath = string(fullfile(sampleFolder, "flir_representative_cool_spot_debug.jpg")); - edgePath = string(fullfile(sampleFolder, "flir_valid_low_contrast_debug.jpg")); - malformedPath = string(fullfile(sampleFolder, "flir_malformed_plain_jpeg_debug.jpg")); + warmPath = sampleContext.samplePath("flir_thermal/warm.jpg"); + coolPath = sampleContext.samplePath("flir_thermal/cool.jpg"); + edgePath = sampleContext.samplePath("flir_thermal/low_contrast.jpg"); + malformedPath = sampleContext.samplePath("flir_thermal/plain.jpg"); writeSyntheticRjpeg(warmPath, syntheticRaw(96, 128, 18300, 900), struct( ... "emissivity", 0.96, "reflectedTemperatureC", 22, ... @@ -28,17 +27,20 @@ "relativeHumidity", 0.50)); imwrite(uint8(repmat(linspace(30, 210, 96), 64, 1)), char(malformedPath)); - representative = [warmPath; coolPath]; - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_FLIRThermal_app", ... - "description", "Anonymous FLIR radiometric JPEG-like boundary pack for debug launch.", ... - "sampleFolder", string(sampleFolder), ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", representative, ... - "boundaryFiles", struct("validEdgeLowContrast", edgePath, "malformedPlainJpeg", malformedPath)); - recordManifest(debugLog, manifest); - pack = manifest; + project = flir_thermal.projectSpec().Create(); + project.inputs.sources = [ ... + sampleContext.sourceRecord( ... + "thermal1", "thermal-image", warmPath, true), ... + sampleContext.sourceRecord( ... + "thermal2", "thermal-image", coolPath, true)]; + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-thermal-images", InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact("warm", "thermal-image", warmPath), ... + sampleContext.artifact("cool", "thermal-image", coolPath), ... + sampleContext.artifact("lowContrast", "boundaryInput", edgePath), ... + sampleContext.artifact("plainJpeg", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function raw = syntheticRaw(h, w, base, delta) @@ -198,30 +200,6 @@ function writeDirectoryEntry(startIndex, recordType, recordOffset, recordLength) bitand(value, 255)]); end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function value = optionValue(opts, fieldName, defaultValue) value = defaultValue; if isstruct(opts) && isfield(opts, fieldName) @@ -229,12 +207,6 @@ function recordManifest(debugLog, manifest) end end -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end - function deleteIfExists(filepath) if isfile(filepath) delete(filepath); diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/autoRange.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/autoRange.m new file mode 100644 index 000000000..626fff25d --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/autoRange.m @@ -0,0 +1,37 @@ +% App-owned implementation for flir_thermal.displayMapping.autoRange within the flir_thermal product workflow. +function applicationState = autoRange( ... + applicationState, callbackContext) +%AUTORANGE Fit the selected image range to finite thermal values. +item = applicationState.session.cache.currentItem; +if isempty(item) + return +end +values = ... + flir_thermal.thermalPreview.presentationData.valueMatrix(item); +values = double(values(isfinite(values))); +if isempty(values) + return +end +range = normalizeRange([min(values), max(values)]); +item.displayRange = range; +item.rangeControlBounds = [ ... + min(item.rangeControlBounds(1), range(1)), ... + max(item.rangeControlBounds(2), range(2))]; +item.rangeAdjusted = true; +applicationState = flir_thermal.thermalSources.storeCurrentAnnotation( ... + applicationState, item); +applicationState = invalidateResults(applicationState); +callbackContext.appendStatus("Set the selected FLIR auto range."); +end + +function range = normalizeRange(range) +range = sort(double(range(:).')); +if range(2) <= range(1) + range = range + [-0.5 0.5]; +end +end + +function applicationState = invalidateResults(applicationState) +applicationState.project.results.lastExport = []; +applicationState.project.results.resultManifestPath = ""; +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeColorMapping.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeColorMapping.m new file mode 100644 index 000000000..61e16a60a --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeColorMapping.m @@ -0,0 +1,17 @@ +% App-owned implementation for flir_thermal.displayMapping.changeColorMapping within the flir_thermal product workflow. +function applicationState = changeColorMapping( ... + applicationState, mapping, callbackContext) +%CHANGECOLORMAPPING Normalize linear, logarithmic, or gamma display mapping. +mapping = string(mapping); +if any(mapping == ["Linear", "Log", "Gamma"]) + applicationState.project.parameters.colorMapping = mapping; + applicationState = invalidateResults(applicationState); + callbackContext.appendStatus( ... + "Thermal color mapping: " + mapping + "."); +end +end + +function applicationState = invalidateResults(applicationState) +applicationState.project.results.lastExport = []; +applicationState.project.results.resultManifestPath = ""; +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeGamma.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeGamma.m new file mode 100644 index 000000000..fadfea491 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeGamma.m @@ -0,0 +1,13 @@ +% App-owned implementation for flir_thermal.displayMapping.changeGamma within the flir_thermal product workflow. +function applicationState = changeGamma( ... + applicationState, gammaValue, callbackContext) +%CHANGEGAMMA Normalize display gamma without changing thermal values. +gammaValue = ... + flir_thermal.thermalPreview.presentationData.normalizeGammaValue( ... + gammaValue); +applicationState.project.parameters.gammaValue = gammaValue; +applicationState.project.results.lastExport = []; +applicationState.project.results.resultManifestPath = ""; +callbackContext.appendStatus( ... + "Thermal display gamma: " + string(gammaValue) + "."); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMaximum.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMaximum.m new file mode 100644 index 000000000..e13e9f4c9 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMaximum.m @@ -0,0 +1,34 @@ +% App-owned implementation for flir_thermal.displayMapping.changeMaximum within the flir_thermal product workflow. +function applicationState = changeMaximum( ... + applicationState, maximumC, callbackContext) +%CHANGEMAXIMUM Update the current image's display maximum. +item = applicationState.session.cache.currentItem; +maximumC = double(maximumC); +if isempty(item) || ~isscalar(maximumC) || ~isfinite(maximumC) + return +end +range = normalizedRange(item.displayRange); +range(2) = maximumC; +range = normalizedRange(range); +item.displayRange = range; +item.rangeControlBounds = [ ... + min(item.rangeControlBounds(1), range(1)), ... + max(item.rangeControlBounds(2), range(2))]; +item.rangeAdjusted = true; +applicationState = flir_thermal.thermalSources.storeCurrentAnnotation( ... + applicationState, item); +applicationState.project.results.lastExport = []; +applicationState.project.results.resultManifestPath = ""; +callbackContext.appendStatus("Updated current thermal display range."); +end + +function range = normalizedRange(value) +range = double(value(:).'); +if numel(range) ~= 2 || any(~isfinite(range)) + range = [20 40]; +end +range = sort(range); +if range(2) <= range(1) + range(2) = range(1) + 1; +end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMinimum.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMinimum.m new file mode 100644 index 000000000..cc3124528 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMinimum.m @@ -0,0 +1,34 @@ +% App-owned implementation for flir_thermal.displayMapping.changeMinimum within the flir_thermal product workflow. +function applicationState = changeMinimum( ... + applicationState, minimumC, callbackContext) +%CHANGEMINIMUM Update the current image's display minimum. +item = applicationState.session.cache.currentItem; +minimumC = double(minimumC); +if isempty(item) || ~isscalar(minimumC) || ~isfinite(minimumC) + return +end +range = normalizedRange(item.displayRange); +range(1) = minimumC; +range = normalizedRange(range); +item.displayRange = range; +item.rangeControlBounds = [ ... + min(item.rangeControlBounds(1), range(1)), ... + max(item.rangeControlBounds(2), range(2))]; +item.rangeAdjusted = true; +applicationState = flir_thermal.thermalSources.storeCurrentAnnotation( ... + applicationState, item); +applicationState.project.results.lastExport = []; +applicationState.project.results.resultManifestPath = ""; +callbackContext.appendStatus("Updated current thermal display range."); +end + +function range = normalizedRange(value) +range = double(value(:).'); +if numel(range) ~= 2 || any(~isfinite(range)) + range = [20 40]; +end +range = sort(range); +if range(2) <= range(1) + range(2) = range(1) + 1; +end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePalette.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePalette.m new file mode 100644 index 000000000..c5fbb4ee0 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePalette.m @@ -0,0 +1,16 @@ +% App-owned implementation for flir_thermal.displayMapping.changePalette within the flir_thermal product workflow. +function applicationState = changePalette( ... + applicationState, palette, callbackContext) +%CHANGEPALETTE Normalize the thermal display palette. +palette = lower(string(palette)); +if any(palette == ["turbo", "iron", "hot", "parula", "gray"]) + applicationState.project.parameters.palette = palette; + applicationState = invalidateResults(applicationState); + callbackContext.appendStatus("Thermal palette: " + palette + "."); +end +end + +function applicationState = invalidateResults(applicationState) +applicationState.project.results.lastExport = []; +applicationState.project.results.resultManifestPath = ""; +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePreset.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePreset.m new file mode 100644 index 000000000..0eac12c75 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePreset.m @@ -0,0 +1,42 @@ +% App-owned implementation for flir_thermal.displayMapping.changePreset within the flir_thermal product workflow. +function applicationState = changePreset( ... + applicationState, preset, callbackContext) +%CHANGEPRESET Apply one declared control-bound preset to the current image. +item = applicationState.session.cache.currentItem; +if isempty(item) + return +end +items = string( ... + flir_thermal.thermalPreview.presentationData.rangePresetItems()); +preset = string(preset); +if ~any(preset == items) + return +end +bounds = ... + flir_thermal.thermalPreview.presentationData.rangeControlBounds( ... + item, preset, item.rangeControlBounds); +item.rangePreset = preset; +item.rangeControlBounds = bounds; +range = clampRange(item.displayRange, bounds); +if ~isequaln(range, item.displayRange) + item.displayRange = range; + item.rangeAdjusted = true; +end +applicationState = flir_thermal.thermalSources.storeCurrentAnnotation( ... + applicationState, item); +applicationState = invalidateResults(applicationState); +callbackContext.appendStatus("Thermal range bounds: " + preset + "."); +end + +function range = clampRange(range, bounds) +range = sort(double(range(:).')); +range = min(bounds(2), max(bounds(1), range)); +if numel(range) ~= 2 || any(~isfinite(range)) || range(2) <= range(1) + range = double(bounds(:).'); +end +end + +function applicationState = invalidateResults(applicationState) +applicationState.project.results.lastExport = []; +applicationState.project.results.resultManifestPath = ""; +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/groupRange.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/groupRange.m new file mode 100644 index 000000000..1bb6d41d4 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/groupRange.m @@ -0,0 +1,97 @@ +% App-owned implementation for flir_thermal.displayMapping.groupRange within the flir_thermal product workflow. +function applicationState = groupRange( ... + applicationState, callbackContext) +%GROUPRANGE Apply one finite range spanning every registered FLIR image. +[items, ok] = loadAll(applicationState, callbackContext); +if ~ok + return +end +ranges = zeros(numel(items), 2); +for k = 1:numel(items) + ranges(k, :) = automaticRange(items(k)); +end +shared = normalizeRange([min(ranges(:, 1)), max(ranges(:, 2))]); +for k = 1:numel(items) + items(k).displayRange = shared; + items(k).rangeControlBounds = shared; + items(k).rangeAdjusted = true; +end +applicationState = storeAll(applicationState, items); +applicationState = invalidateResults(applicationState); +callbackContext.appendStatus( ... + "Applied one shared range to " + string(numel(items)) + ... + " thermal images."); +end + +function [items, ok] = loadAll(applicationState, callbackContext) +items = repmat(flir_thermal.sourceFiles.emptyItem(), 0, 1); +ok = false; +try + paths = callbackContext.resolveSourcePaths( ... + applicationState.project.inputs.sources); + items = flir_thermal.sourceFiles.readImages( ... + paths, struct("SkipInvalid", false)); + items = applyAnnotations(items, ... + applicationState.project.inputs.sources, ... + applicationState.project.annotations.items); + ok = numel(items) == numel(applicationState.project.inputs.sources); +catch ME + callbackContext.reportError("Load FLIR sources for shared range", ME); + callbackContext.alert(ME.message, "Could not load FLIR sources"); +end +end + +function items = applyAnnotations(items, sources, annotations) +for k = 1:numel(items) + match = find(string({annotations.sourceId}) == ... + string(sources(k).id), 1); + if ~isempty(match) + items(k) = flir_thermal.thermalAnnotations.apply( ... + items(k), annotations(match)); + end +end +end + +function applicationState = storeAll(applicationState, items) +sources = applicationState.project.inputs.sources; +annotations = applicationState.project.annotations.items; +for k = 1:numel(items) + annotation = flir_thermal.thermalAnnotations.fromItem( ... + items(k), sources(k).id); + match = find(string({annotations.sourceId}) == ... + string(sources(k).id), 1); + if isempty(match) + annotations(end + 1, 1) = annotation; + else + annotations(match) = annotation; + end +end +applicationState.project.annotations.items = annotations; +index = applicationState.session.selection.currentIndex; +if index >= 1 && index <= numel(items) + applicationState.session.cache.currentItem = items(index); +end +end + +function range = automaticRange(item) +values = ... + flir_thermal.thermalPreview.presentationData.valueMatrix(item); +values = double(values(isfinite(values))); +if isempty(values) + range = normalizeRange(item.displayRange); +else + range = normalizeRange([min(values), max(values)]); +end +end + +function range = normalizeRange(range) +range = sort(double(range(:).')); +if range(2) <= range(1) + range = range + [-0.5 0.5]; +end +end + +function applicationState = invalidateResults(applicationState) +applicationState.project.results.lastExport = []; +applicationState.project.results.resultManifestPath = ""; +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/layoutSection.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/layoutSection.m new file mode 100644 index 000000000..9762c15a5 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/layoutSection.m @@ -0,0 +1,48 @@ +% App-owned implementation for flir_thermal.displayMapping.layoutSection within the flir_thermal product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare palette, mapping, and temperature-range controls. +labels = ... + flir_thermal.thermalPreview.presentationData.rangeControlLabels(); +actions = labkit.app.layout.group("rangeActions", { ... + labkit.app.layout.button("perImageRange", labels.setEachRange, ... + @flir_thermal.displayMapping.perImageRange, ... + Tooltip="Assign each radiometric image its own temperature color bounds from that image's data."), ... + labkit.app.layout.button("groupRange", labels.setSharedRange, ... + @flir_thermal.displayMapping.groupRange, ... + Tooltip="Use one shared temperature range across loaded images so their colors remain directly comparable."), ... + labkit.app.layout.button("autoRange", labels.setCurrentRange, ... + @flir_thermal.displayMapping.autoRange, ... + Tooltip="Set the current display bounds from the selected thermal image's temperature distribution."), ... + labkit.app.layout.button("roundRange", labels.roundSetRanges, ... + @flir_thermal.displayMapping.roundRanges, ... + Tooltip="Round assigned temperature bounds to reader-friendly values without changing radiometric temperatures.")}); +section = labkit.app.layout.section("displaySection", "Display", { ... + labkit.app.layout.field("palette", Label="Palette", Kind="choice", ... + Choices=["turbo", "iron", "hot", "parula", "gray"], ... + Value="turbo", Bind="project.parameters.palette", ... + OnValueChanged=@flir_thermal.displayMapping.changePalette), ... + labkit.app.layout.field("colorMapping", ... + Label="Color mapping", Kind="choice", ... + Choices=["Linear", "Log", "Gamma"], Value="Linear", ... + Bind="project.parameters.colorMapping", ... + OnValueChanged=@flir_thermal.displayMapping.changeColorMapping), ... + labkit.app.layout.slider("gammaValue", Label="Gamma", ... + Value=2.2, Limits=[0.1 5], Step=0.1, ... + ValueDisplayFormat="%.2f", ... + Bind="project.parameters.gammaValue", ... + OnValueChanged=@flir_thermal.displayMapping.changeGamma), ... + labkit.app.layout.field("rangePreset", Label="Bounds", ... + Kind="choice", ... + Choices=flir_thermal.thermalPreview.presentationData.rangePresetItems(), ... + Value=labels.defaultPreset, ... + OnValueChanged=@flir_thermal.displayMapping.changePreset), ... + actions, ... + labkit.app.layout.slider("temperatureMin", Label="Min C", ... + Value=20, Limits=[-20 120], Step=0.01, ... + ValueDisplayFormat="%.2f", ... + OnValueChanged=@flir_thermal.displayMapping.changeMinimum), ... + labkit.app.layout.slider("temperatureMax", Label="Max C", ... + Value=40, Limits=[-20 120], Step=0.01, ... + ValueDisplayFormat="%.2f", ... + OnValueChanged=@flir_thermal.displayMapping.changeMaximum)}); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/perImageRange.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/perImageRange.m new file mode 100644 index 000000000..f5999d335 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/perImageRange.m @@ -0,0 +1,62 @@ +% App-owned implementation for flir_thermal.displayMapping.perImageRange within the flir_thermal product workflow. +function applicationState = perImageRange( ... + applicationState, callbackContext) +%PERIMAGERANGE Apply an independent finite range to every registered image. +sources = applicationState.project.inputs.sources; +if isempty(sources) + return +end +try + paths = callbackContext.resolveSourcePaths(sources); + items = flir_thermal.sourceFiles.readImages( ... + paths, struct("SkipInvalid", false)); +catch ME + callbackContext.reportError("Load FLIR sources for individual ranges", ME); + callbackContext.alert(ME.message, "Could not load FLIR sources"); + return +end +annotations = applicationState.project.annotations.items; +for k = 1:numel(items) + old = find(string({annotations.sourceId}) == ... + string(sources(k).id), 1); + if ~isempty(old) + items(k) = flir_thermal.thermalAnnotations.apply( ... + items(k), annotations(old)); + end + values = flir_thermal.thermalPreview.presentationData.valueMatrix( ... + items(k)); + values = double(values(isfinite(values))); + if isempty(values) + range = normalizeRange(items(k).displayRange); + else + range = normalizeRange([min(values), max(values)]); + end + items(k).displayRange = range; + items(k).rangeControlBounds = range; + items(k).rangeAdjusted = true; + annotation = flir_thermal.thermalAnnotations.fromItem( ... + items(k), sources(k).id); + if isempty(old) + annotations(end + 1, 1) = annotation; + else + annotations(old) = annotation; + end +end +applicationState.project.annotations.items = annotations; +index = applicationState.session.selection.currentIndex; +if index >= 1 && index <= numel(items) + applicationState.session.cache.currentItem = items(index); +end +applicationState.project.results.lastExport = []; +applicationState.project.results.resultManifestPath = ""; +callbackContext.appendStatus( ... + "Applied individual auto ranges to " + ... + string(numel(items)) + " thermal images."); +end + +function range = normalizeRange(range) +range = sort(double(range(:).')); +if range(2) <= range(1) + range = range + [-0.5 0.5]; +end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/present.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/present.m new file mode 100644 index 000000000..0ab5173d8 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/present.m @@ -0,0 +1,25 @@ +% App-owned implementation for flir_thermal.displayMapping.present within the flir_thermal product workflow. +function view = present( ... + parameters, annotations, hasItem, range, bounds, preset) +%PRESENT Describe display settings and range-action availability. +adjusted = false; +if ~isempty(annotations) + adjusted = any(logical([annotations.rangeAdjusted])); +end +view = labkit.app.view.Snapshot() ... + .value("palette", string(parameters.palette)) ... + .value("colorMapping", string(parameters.colorMapping)) ... + .value("gammaValue", parameters.gammaValue) ... + .value("rangePreset", preset) ... + .enabled("rangePreset", hasItem) ... + .enabled("perImageRange", hasItem) ... + .enabled("groupRange", hasItem) ... + .enabled("autoRange", hasItem) ... + .enabled("roundRange", adjusted) ... + .limits("temperatureMin", bounds) ... + .value("temperatureMin", range(1)) ... + .enabled("temperatureMin", hasItem) ... + .limits("temperatureMax", bounds) ... + .value("temperatureMax", range(2)) ... + .enabled("temperatureMax", hasItem); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/roundRanges.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/roundRanges.m new file mode 100644 index 000000000..2cfde684f --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/roundRanges.m @@ -0,0 +1,40 @@ +% App-owned implementation for flir_thermal.displayMapping.roundRanges within the flir_thermal product workflow. +function applicationState = roundRanges( ... + applicationState, callbackContext) +%ROUNDRANGES Round every explicitly set range outward to whole Celsius. +annotations = applicationState.project.annotations.items; +count = 0; +for k = 1:numel(annotations) + if ~logical(annotations(k).rangeAdjusted) + continue + end + range = sort(double(annotations(k).displayRange(:).')); + range = [floor(range(1)), ceil(range(2))]; + if range(2) <= range(1) + range(2) = range(1) + 1; + end + annotations(k).displayRange = range; + annotations(k).rangeControlBounds = [ ... + min(annotations(k).rangeControlBounds(1), range(1)), ... + max(annotations(k).rangeControlBounds(2), range(2))]; + count = count + 1; +end +applicationState.project.annotations.items = annotations; +index = applicationState.session.selection.currentIndex; +if index >= 1 && index <= ... + numel(applicationState.project.inputs.sources) + sourceId = applicationState.project.inputs.sources(index).id; + match = find(string({annotations.sourceId}) == string(sourceId), 1); + if ~isempty(match) && ... + ~isempty(applicationState.session.cache.currentItem) + applicationState.session.cache.currentItem = ... + flir_thermal.thermalAnnotations.apply( ... + applicationState.session.cache.currentItem, ... + annotations(match)); + end +end +applicationState.project.results.lastExport = []; +applicationState.project.results.resultManifestPath = ""; +callbackContext.appendStatus( ... + "Rounded " + string(count) + " thermal ranges."); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/chooseOutputFolder.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/chooseOutputFolder.m new file mode 100644 index 000000000..8675a6cb3 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/chooseOutputFolder.m @@ -0,0 +1,16 @@ +% App-owned implementation for flir_thermal.resultFiles.chooseOutputFolder within the flir_thermal product workflow. +function applicationState = chooseOutputFolder( ... + applicationState, callbackContext) +%CHOOSEOUTPUTFOLDER Select the durable FLIR export destination. +choice = callbackContext.chooseOutputFolder( ... + applicationState.project.parameters.outputFolder); +if choice.Cancelled + callbackContext.appendStatus("FLIR output-folder selection cancelled."); + return +end +applicationState.project.parameters.outputFolder = string(choice.Value); +applicationState.project.results.lastExport = []; +applicationState.project.results.resultManifestPath = ""; +callbackContext.appendStatus( ... + "FLIR output folder: " + string(choice.Value)); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportAll.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportAll.m new file mode 100644 index 000000000..fb23442ed --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportAll.m @@ -0,0 +1,25 @@ +% App-owned implementation for flir_thermal.resultFiles.exportAll within the flir_thermal product workflow. +function applicationState = exportAll( ... + applicationState, callbackContext) +%EXPORTALL Export every registered FLIR image and its numeric products. +sources = applicationState.project.inputs.sources; +if isempty(sources) + callbackContext.alert( ... + "Load FLIR radiometric images before exporting.", ... + "Export unavailable"); + return +end +[payload, manifestPath, ok] = ... + flir_thermal.resultFiles.writeSelection( ... + sources, applicationState.project.annotations.items, ... + applicationState.project.parameters, callbackContext); +if ~ok + return +end +payload.resultManifestPath = manifestPath; +applicationState.project.results.lastExport = payload; +applicationState.project.results.resultManifestPath = manifestPath; +callbackContext.appendStatus(sprintf( ... + "Exported %d FLIR image(s): %s", ... + numel(sources), payload.manifestPath)); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportCurrent.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportCurrent.m new file mode 100644 index 000000000..b258f702f --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportCurrent.m @@ -0,0 +1,25 @@ +% App-owned implementation for flir_thermal.resultFiles.exportCurrent within the flir_thermal product workflow. +function applicationState = exportCurrent( ... + applicationState, callbackContext) +%EXPORTCURRENT Export the selected FLIR image and its numeric products. +sources = applicationState.project.inputs.sources; +index = applicationState.session.selection.currentIndex; +if isempty(sources) || index < 1 || index > numel(sources) + callbackContext.alert( ... + "Load a FLIR radiometric image before exporting.", ... + "Export unavailable"); + return +end +[payload, manifestPath, ok] = ... + flir_thermal.resultFiles.writeSelection( ... + sources(index), applicationState.project.annotations.items, ... + applicationState.project.parameters, callbackContext); +if ~ok + return +end +payload.resultManifestPath = manifestPath; +applicationState.project.results.lastExport = payload; +applicationState.project.results.resultManifestPath = manifestPath; +callbackContext.appendStatus( ... + "Exported current FLIR image: " + payload.manifestPath); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/formatChanged.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/formatChanged.m new file mode 100644 index 000000000..ea9885b6f --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/formatChanged.m @@ -0,0 +1,13 @@ +% App-owned implementation for flir_thermal.resultFiles.formatChanged within the flir_thermal product workflow. +function applicationState = formatChanged( ... + applicationState, formatName, callbackContext) +%FORMATCHANGED Normalize the rendered thermal-image export format. +formatName = upper(string(formatName)); +if any(formatName == ["PNG", "TIFF", "JPEG"]) + applicationState.project.parameters.exportFormat = formatName; + applicationState.project.results.lastExport = []; + applicationState.project.results.resultManifestPath = ""; + callbackContext.appendStatus( ... + "FLIR export image format: " + formatName + "."); +end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/layoutSection.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/layoutSection.m new file mode 100644 index 000000000..d3e618855 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/layoutSection.m @@ -0,0 +1,23 @@ +% App-owned implementation for flir_thermal.resultFiles.layoutSection within the flir_thermal product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare output folder, image format, and export scope. +actions = labkit.app.layout.group("exportActions", { ... + labkit.app.layout.button("chooseOutputFolder", "Choose folder", ... + @flir_thermal.resultFiles.chooseOutputFolder, ... + Tooltip="Choose the destination for rendered thermal images and temperature metadata."), ... + labkit.app.layout.button("exportCurrent", "Export current", ... + @flir_thermal.resultFiles.exportCurrent, ... + Tooltip="Export the selected thermal image using its calibrated temperatures, palette, bounds, and annotations."), ... + labkit.app.layout.button("exportAll", "Export all", ... + @flir_thermal.resultFiles.exportAll, ... + Tooltip="Export every loaded thermal image using the assigned per-image or shared temperature mapping.")}); +section = labkit.app.layout.section("exportSection", "Export", { ... + labkit.app.layout.field("outputFolder", ... + Label="Output folder", Kind="readonly"), ... + labkit.app.layout.field("exportFormat", ... + Label="Image format", Kind="choice", ... + Choices=["PNG", "TIFF", "JPEG"], Value="PNG", ... + Bind="project.parameters.exportFormat", ... + OnValueChanged=@flir_thermal.resultFiles.formatChanged), ... + actions}); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/present.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/present.m new file mode 100644 index 000000000..2fc0e5cc1 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/present.m @@ -0,0 +1,9 @@ +% App-owned implementation for flir_thermal.resultFiles.present within the flir_thermal product workflow. +function view = present(parameters, hasItem, hasSources) +%PRESENT Describe export destination, format, and action availability. +view = labkit.app.view.Snapshot() ... + .value("outputFolder", string(parameters.outputFolder)) ... + .value("exportFormat", string(parameters.exportFormat)) ... + .enabled("exportCurrent", hasItem) ... + .enabled("exportAll", hasSources); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/writeOutputs.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/writeOutputs.m index 8f24c4946..e50a3140f 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/writeOutputs.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/writeOutputs.m @@ -23,7 +23,7 @@ result.gammaValue = opts.gammaValue; result = addReadingResults(result, items(k)); try - [values, units] = flir_thermal.userInterface.valueMatrix(items(k)); + [values, units] = flir_thermal.thermalPreview.presentationData.valueMatrix(items(k)); range = itemRange(items(k), opts.range); result.rangeMin = range(1); result.rangeMax = range(2); @@ -34,7 +34,7 @@ "_colorbar", "PNG"); temperatureCsvPath = uniqueOutputPath(opts.outputFolder, items(k).path, ... "_temperature_c", "CSV"); - rgb = flir_thermal.userInterface.renderThermalImage(values, ... + rgb = flir_thermal.thermalPreview.presentationData.renderThermalImage(values, ... range, opts.palette, opts.colorMapping, opts.gammaValue); labkit.image.writeFile(rgb, imagePath); labkit.image.writeFile(colorbarImage(range, opts.palette, ... @@ -88,7 +88,7 @@ otherwise opts.colorMapping = "Linear"; end - opts.gammaValue = flir_thermal.userInterface.normalizeGammaValue(opts.gammaValue); + opts.gammaValue = flir_thermal.thermalPreview.presentationData.normalizeGammaValue(opts.gammaValue); opts.range = opts.range(:).'; if ~(isempty(opts.range) || (numel(opts.range) == 2 && ... all(isfinite(opts.range)) && opts.range(2) > opts.range(1))) @@ -102,7 +102,7 @@ range = double(item.displayRange(:)).'; end if numel(range) ~= 2 || ~all(isfinite(range)) || range(2) <= range(1) - [values] = flir_thermal.userInterface.valueMatrix(item); + [values] = flir_thermal.thermalPreview.presentationData.valueMatrix(item); values = values(isfinite(values)); if isempty(values) range = [20 40]; @@ -283,7 +283,7 @@ function image = colorbarImage(range, palette, colorMapping, gammaValue) values = linspace(range(2), range(1), 256).'; values = repmat(values, 1, 32); - image = flir_thermal.userInterface.renderThermalImage(values, range, ... + image = flir_thermal.thermalPreview.presentationData.renderThermalImage(values, range, ... palette, colorMapping, gammaValue); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/writeSelection.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/writeSelection.m new file mode 100644 index 000000000..50e620310 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/writeSelection.m @@ -0,0 +1,101 @@ +% App-owned implementation for flir_thermal.resultFiles.writeSelection within the flir_thermal product workflow. +function [payload, manifestPath, ok] = writeSelection( ... + sources, annotations, parameters, callbackContext) +%WRITESELECTION Decode, export, and register one FLIR result package. +payload = []; +manifestPath = ""; +ok = false; +folder = string(parameters.outputFolder); +if strlength(folder) == 0 + callbackContext.alert( ... + "Choose an output folder before exporting.", ... + "Output folder required"); + return +end +try + paths = callbackContext.resolveSourcePaths(sources); + items = flir_thermal.sourceFiles.readImages( ... + paths, struct("SkipInvalid", false)); + items = applyAnnotations(items, sources, annotations); + options = struct( ... + "outputFolder", folder, ... + "format", parameters.exportFormat, ... + "palette", parameters.palette, ... + "colorMapping", parameters.colorMapping, ... + "gammaValue", parameters.gammaValue, ... + "range", []); + payload = flir_thermal.resultFiles.writeOutputs(items, options); + outputs = resultFiles(payload); + package = labkit.app.result.Package( ... + Outputs=outputs, ... + Inputs=struct("sources", sources), ... + Parameters=parameters, ... + Summary=struct( ... + "imageCount", numel(items), ... + "savedCount", sum(string({payload.results.status}) == "saved"), ... + "failedCount", sum(string({payload.results.status}) == "failed")), ... + ManifestName="flir_thermal.labkit.json"); + written = callbackContext.writeResultPackage(folder, package); +catch ME + callbackContext.reportError("Export FLIR thermal results", ME); + callbackContext.alert(ME.message, "Could not export FLIR images"); + return +end +manifestPath = string(written.Value); +ok = true; +end + +function items = applyAnnotations(items, sources, annotations) +for k = 1:numel(items) + match = find(string({annotations.sourceId}) == ... + string(sources(k).id), 1); + if ~isempty(match) + items(k) = flir_thermal.thermalAnnotations.apply( ... + items(k), annotations(match)); + end +end +end + +function outputs = resultFiles(payload) +outputs = cell(1, 3 * numel(payload.results) + 1); +cursor = 0; +fields = ["thermalImagePath", "colorbarPath", "temperatureCsvPath"]; +roles = ["thermal-image", "temperature-colorbar", "temperature-csv"]; +for k = 1:numel(payload.results) + status = "failed"; + if string(payload.results(k).status) == "saved" + status = "success"; + end + for n = 1:numel(fields) + cursor = cursor + 1; + path = string(payload.results(k).(fields(n))); + [~, name, extension] = fileparts(path); + relativePath = string(name) + string(extension); + if strlength(relativePath) == 0 + relativePath = "failed_" + roles(n) + "_" + string(k) + ".dat"; + end + outputs{cursor} = labkit.app.result.File( ... + roles(n) + "-" + string(k), roles(n), relativePath, ... + MediaType=mediaType(extension), Status=status, ... + Message=string(payload.results(k).message)); + end +end +cursor = cursor + 1; +[~, name, extension] = fileparts(payload.manifestPath); +outputs{cursor} = labkit.app.result.File( ... + "thermal-manifest", "summary", ... + string(name) + string(extension), MediaType="text/csv"); +end + +function value = mediaType(extension) +extension = lower(string(extension)); +if extension == ".csv" + value = "text/csv"; +elseif any(extension == [".jpg", ".jpeg"]) + value = "image/jpeg"; +elseif any(extension == [".tif", ".tiff"]) + value = "image/tiff"; +else + value = "image/png"; +end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+sourceFiles/emptyItem.m b/apps/image_measurement/flir_thermal/+flir_thermal/+sourceFiles/emptyItem.m index f408cc6b4..d51b5f852 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+sourceFiles/emptyItem.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+sourceFiles/emptyItem.m @@ -1,7 +1,7 @@ % Return one empty decoded FLIR item. Source readers and tests use this shape; % it contains transient matrices plus app-owned display and reading fields. function item = emptyItem() - labels = flir_thermal.userInterface.rangeControlLabels(); + labels = flir_thermal.thermalPreview.presentationData.rangeControlLabels(); item = struct( ... 'path', "", ... 'name', "", ... diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+sourceFiles/readImages.m b/apps/image_measurement/flir_thermal/+flir_thermal/+sourceFiles/readImages.m index c280cb95d..b957ac785 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+sourceFiles/readImages.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+sourceFiles/readImages.m @@ -20,7 +20,7 @@ end function item = itemFromRecord(record, template) - labels = flir_thermal.userInterface.rangeControlLabels(); + labels = flir_thermal.thermalPreview.presentationData.rangeControlLabels(); item = template; item.path = record.path; item.name = record.name; @@ -31,7 +31,7 @@ flir_thermal.analysisRun.extremeTemperatureReadings(record.temperatureC); item.displayRange = initialRange(item); item.rangePreset = labels.defaultPreset; - item.rangeControlBounds = flir_thermal.userInterface.rangeControlBounds( ... + item.rangeControlBounds = flir_thermal.thermalPreview.presentationData.rangeControlBounds( ... item, item.rangePreset, [-20 120]); item.rangeAdjusted = false; item.units = record.units; @@ -40,7 +40,7 @@ end function range = initialRange(item) - values = flir_thermal.userInterface.valueMatrix(item); + values = flir_thermal.thermalPreview.presentationData.valueMatrix(item); values = values(isfinite(values)); if isempty(values) range = [20 40]; diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changePoint.m b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changePoint.m new file mode 100644 index 000000000..94c621176 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changePoint.m @@ -0,0 +1,12 @@ +% App-owned implementation for flir_thermal.temperatureReadings.changePoint within the flir_thermal product workflow. +function state=changePoint(state,points,context) +%CHANGEPOINT Store an app-owned manual temperature reading. +item=state.session.cache.currentItem; +if isempty(item)||isempty(points),return,end +[item,reading]=flir_thermal.analysisRun.withManualPoint(item,points(1,:)); +if ~isfinite(reading.temperatureC),return,end +state=flir_thermal.thermalSources.storeCurrentAnnotation(state,item); +state.project.results.lastExport=[]; +state.project.results.resultManifestPath=""; +context.appendStatus(sprintf('Set manual point: %.2f C.',reading.temperatureC)); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changeRegion.m b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changeRegion.m new file mode 100644 index 000000000..07c341532 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changeRegion.m @@ -0,0 +1,12 @@ +% App-owned implementation for flir_thermal.temperatureReadings.changeRegion within the flir_thermal product workflow. +function state=changeRegion(state,position,context) +%CHANGEREGION Store the chosen thermal ROI and its configured statistic. +item=state.session.cache.currentItem; position=double(position(:).'); +if isempty(item)||numel(position)~=4,return,end +[item,reading]=flir_thermal.analysisRun.withRoiReading(item,state.project.parameters.roiMode,position(1:2),position(1:2)+position(3:4)); +if ~isfinite(reading.temperatureC),return,end +state=flir_thermal.thermalSources.storeCurrentAnnotation(state,item); +state.project.results.lastExport=[]; +state.project.results.resultManifestPath=""; +context.appendStatus(sprintf('Set temperature ROI: %.2f C.',reading.temperatureC)); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/layoutSection.m b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/layoutSection.m new file mode 100644 index 000000000..34d3a5acd --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/layoutSection.m @@ -0,0 +1,18 @@ +% App-owned implementation for flir_thermal.temperatureReadings.layoutSection within the flir_thermal product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare the three ROI statistic modes. +labels = ... + flir_thermal.thermalPreview.presentationData.rangeControlLabels(); +buttons = labkit.app.layout.group("roiReadings", { ... + labkit.app.layout.button("roiHotMode", labels.roiHotSpot, ... + @flir_thermal.temperatureReadings.selectHotMode, ... + Tooltip="Report the maximum calibrated temperature inside the managed ROI."), ... + labkit.app.layout.button("roiColdMode", labels.roiColdSpot, ... + @flir_thermal.temperatureReadings.selectColdMode, ... + Tooltip="Report the minimum calibrated temperature inside the managed ROI."), ... + labkit.app.layout.button("roiMeanMode", labels.roiMean, ... + @flir_thermal.temperatureReadings.selectMeanMode, ... + Tooltip="Report the arithmetic mean calibrated temperature across pixels inside the managed ROI.")}); +section = labkit.app.layout.section( ... + "readingToolsSection", "Reading Tools", {buttons}); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/present.m b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/present.m new file mode 100644 index 000000000..9e77c4789 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/present.m @@ -0,0 +1,8 @@ +% App-owned implementation for flir_thermal.temperatureReadings.present within the flir_thermal product workflow. +function view = present(hasItem) +%PRESENT Describe ROI mode-button availability. +view = labkit.app.view.Snapshot() ... + .enabled("roiHotMode", hasItem) ... + .enabled("roiColdMode", hasItem) ... + .enabled("roiMeanMode", hasItem); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectColdMode.m b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectColdMode.m new file mode 100644 index 000000000..2e9f861a4 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectColdMode.m @@ -0,0 +1,8 @@ +% App-owned implementation for flir_thermal.temperatureReadings.selectColdMode within the flir_thermal product workflow. +function applicationState = selectColdMode( ... + applicationState, callbackContext) +%SELECTCOLDMODE Configure dragged regions to report their coldest pixel. +applicationState.project.parameters.roiMode = "cold"; +callbackContext.appendStatus( ... + "ROI mode: ROI cold spot. Drag on the thermal image to set the ROI."); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectHotMode.m b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectHotMode.m new file mode 100644 index 000000000..1b052e548 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectHotMode.m @@ -0,0 +1,8 @@ +% App-owned implementation for flir_thermal.temperatureReadings.selectHotMode within the flir_thermal product workflow. +function applicationState = selectHotMode( ... + applicationState, callbackContext) +%SELECTHOTMODE Configure dragged regions to report their hottest pixel. +applicationState.project.parameters.roiMode = "hot"; +callbackContext.appendStatus( ... + "ROI mode: ROI hot spot. Drag on the thermal image to set the ROI."); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectMeanMode.m b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectMeanMode.m new file mode 100644 index 000000000..3b71d4c4b --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectMeanMode.m @@ -0,0 +1,8 @@ +% App-owned implementation for flir_thermal.temperatureReadings.selectMeanMode within the flir_thermal product workflow. +function applicationState = selectMeanMode( ... + applicationState, callbackContext) +%SELECTMEANMODE Configure dragged regions to report their mean temperature. +applicationState.project.parameters.roiMode = "mean"; +callbackContext.appendStatus( ... + "ROI mode: ROI mean. Drag on the thermal image to set the ROI."); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/calibrationStatus.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/calibrationStatus.m similarity index 100% rename from apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/calibrationStatus.m rename to apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/calibrationStatus.m diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/detailLines.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/detailLines.m new file mode 100644 index 000000000..65ba6cba8 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/detailLines.m @@ -0,0 +1,129 @@ +% Expected caller: FLIR thermal app UI and tests. Inputs are app items, +% current selection, and export folder. Output is status-panel text. +function lines = detailLines(items, currentIndex, outputFolder) + + if isempty(items) + lines = { + 'No FLIR radiometric images loaded.' + 'Load original FLIR JPG/RJPEG files, not exported screenshots.' + ['Output folder: ' char(string(outputFolder))]}; + return; + end + currentIndex = max(1, min(currentIndex, numel(items))); + item = items(currentIndex); + range = double(item.displayRange(:)).'; + labels = flir_thermal.thermalPreview.presentationData.rangeControlLabels(); + calibration = flir_thermal.thermalPreview.presentationData.calibrationStatus(item); + lines = { + sprintf('Loaded files: %d', numel(items)) + sprintf('Current file: %s', char(item.name)) + sprintf('Range status: %s', rangeStatus(item)) + sprintf('Display range: %.3g to %.3g %s', ... + range(1), range(2), char(string(item.units))) + sprintf('Image hot spot: %s', pointText(item, 'hotSpot')) + sprintf('Image cold spot: %s', pointText(item, 'coldSpot')) + sprintf('Manual point: %s', pointText(item, 'manualPoint')) + sprintf('%s: %s', char(labels.roiHotSpot), pointText(item, 'roiHotSpot')) + sprintf('%s: %s', char(labels.roiColdSpot), pointText(item, 'roiColdSpot')) + sprintf('%s: %s', char(labels.roiMean), roiText(item, 'roiMean')) + sprintf('Temperature differences: %s', differenceSummary(item)) + sprintf('Reader: %s', metadataText(item, 'reader')) + sprintf('Raw byte order: %s', metadataText(item, 'rawByteOrder')) + char(calibration.detailText) + sprintf('Message: %s', char(string(item.message))) + ['Output folder: ' char(string(outputFolder))]}; +end + +function text = rangeStatus(item) + if isfield(item, 'rangeAdjusted') && logical(item.rangeAdjusted) + text = 'range set'; + else + text = 'needs range'; + end +end + +function text = metadataText(item, field) + text = "-"; + if isfield(item, 'metadata') && isfield(item.metadata, field) + text = char(string(item.metadata.(field))); + end +end + +function text = pointText(item, field) + text = "-"; + if ~isfield(item, field) + return; + end + reading = item.(field); + if ~isPointReading(reading) + return; + end + text = sprintf('x %.0f, y %.0f, %.2f C', ... + reading.x, reading.y, reading.temperatureC); +end + +function text = roiText(item, field) + text = "-"; + if ~isfield(item, field) + return; + end + reading = item.(field); + if ~isRoiReading(reading) + return; + end + text = sprintf('x %.0f, y %.0f, %.0f x %.0f px, %.2f C', ... + reading.x, reading.y, reading.width, reading.height, ... + reading.temperatureC); +end + +function text = differenceSummary(item) + labels = flir_thermal.thermalPreview.presentationData.rangeControlLabels(); + names = ["image hot", "image cold", "manual", ... + labels.roiHotSpot, labels.roiColdSpot, labels.roiMean]; + values = [ + readingTemperature(item, 'hotSpot') + readingTemperature(item, 'coldSpot') + readingTemperature(item, 'manualPoint') + readingTemperature(item, 'roiHotSpot') + readingTemperature(item, 'roiColdSpot') + readingTemperature(item, 'roiMean')]; + parts = strings(1, 0); + for a = 1:numel(values) + if ~isfinite(values(a)) + continue; + end + for b = a+1:numel(values) + if ~isfinite(values(b)) + continue; + end + parts(end+1) = sprintf('%s - %s = %.2f C', ... + names(a), names(b), values(a) - values(b)); + end + end + if isempty(parts) + text = "-"; + else + text = strjoin(parts, '; '); + end +end + +function value = readingTemperature(item, field) + value = NaN; + if isfield(item, field) && isstruct(item.(field)) && ... + isfield(item.(field), 'temperatureC') + value = double(item.(field).temperatureC); + end +end + +function tf = isPointReading(reading) + tf = isstruct(reading) && all(isfield(reading, ... + {'x', 'y', 'temperatureC'})) && ... + all(isfinite([reading.x, reading.y, reading.temperatureC])); +end + +function tf = isRoiReading(reading) + tf = isstruct(reading) && all(isfield(reading, ... + {'x', 'y', 'width', 'height', 'temperatureC'})) && ... + all(isfinite([reading.x, reading.y, reading.width, ... + reading.height, reading.temperatureC])); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/drawTemperatureReadings.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/drawTemperatureReadings.m similarity index 100% rename from apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/drawTemperatureReadings.m rename to apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/drawTemperatureReadings.m diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/filePanelEntries.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/filePanelEntries.m new file mode 100644 index 000000000..0d11448c6 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/filePanelEntries.m @@ -0,0 +1,21 @@ +% Expected caller: FLIR thermal runner refresh logic and tests. Input is the +% loaded item struct array. Output is filePanel entry structs with per-image +% range-session status labels. +function entries = filePanelEntries(items) + + entries = repmat(struct('path', "", 'status', ""), numel(items), 1); + for k = 1:numel(items) + entries(k).path = string(items(k).path); + if isfield(items(k), 'rangeAdjusted') && logical(items(k).rangeAdjusted) + entries(k).status = "range set"; + else + entries(k).status = "needs range"; + end + calibration = flir_thermal.thermalPreview.presentationData.calibrationStatus(items(k)); + if calibration.severity == "warning" + entries(k).status = entries(k).status + "; calibration defaults used"; + elseif calibration.severity == "unavailable" + entries(k).status = entries(k).status + "; temperature unavailable"; + end + end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/normalizeGammaValue.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/normalizeGammaValue.m similarity index 100% rename from apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/normalizeGammaValue.m rename to apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/normalizeGammaValue.m diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/rangeControlBounds.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/rangeControlBounds.m similarity index 90% rename from apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/rangeControlBounds.m rename to apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/rangeControlBounds.m index 41720b01d..83f5a0aa6 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/rangeControlBounds.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/rangeControlBounds.m @@ -7,7 +7,7 @@ fallbackBounds = [-20 120]; end preset = string(preset); - labels = flir_thermal.userInterface.rangeControlLabels(); + labels = flir_thermal.thermalPreview.presentationData.rangeControlLabels(); switch preset case labels.standardPreset bounds = [-20 120]; @@ -23,7 +23,7 @@ end function bounds = estimatedExpandedBounds(item, fallbackBounds) - values = flir_thermal.userInterface.valueMatrix(item); + values = flir_thermal.thermalPreview.presentationData.valueMatrix(item); values = values(isfinite(values)); if isempty(values) bounds = normalizeBounds(fallbackBounds); diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/rangeControlLabels.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/rangeControlLabels.m similarity index 100% rename from apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/rangeControlLabels.m rename to apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/rangeControlLabels.m diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/rangePresetItems.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/rangePresetItems.m similarity index 81% rename from apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/rangePresetItems.m rename to apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/rangePresetItems.m index 2805b6541..d1f909359 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/rangePresetItems.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/rangePresetItems.m @@ -2,7 +2,7 @@ % ordered display-range preset labels used by the app; no side effects. function items = rangePresetItems() - labels = flir_thermal.userInterface.rangeControlLabels(); + labels = flir_thermal.thermalPreview.presentationData.rangeControlLabels(); items = { ... char(labels.standardPreset), ... char(labels.estimatedPreset), ... diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/renderThermalImage.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/renderThermalImage.m similarity index 95% rename from apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/renderThermalImage.m rename to apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/renderThermalImage.m index aead7a8b5..041c264b8 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/renderThermalImage.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/renderThermalImage.m @@ -29,7 +29,7 @@ curveStrength = 99; mapped = log1p(curveStrength * scaled) ./ log1p(curveStrength); else - displayGamma = flir_thermal.userInterface.normalizeGammaValue(gammaValue); + displayGamma = flir_thermal.thermalPreview.presentationData.normalizeGammaValue(gammaValue); mapped = scaled .^ (1 / displayGamma); end cmap = paletteMap(palette, 256); diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/roiModeLabel.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/roiModeLabel.m similarity index 82% rename from apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/roiModeLabel.m rename to apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/roiModeLabel.m index c3a5e47e7..b1e6d1278 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/roiModeLabel.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/roiModeLabel.m @@ -2,7 +2,7 @@ % is the user-facing label for that mode. Side effects: none. function label = roiModeLabel(mode) - labels = flir_thermal.userInterface.rangeControlLabels(); + labels = flir_thermal.thermalPreview.presentationData.rangeControlLabels(); switch string(mode) case "hot" label = labels.roiHotSpot; diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/summaryTableData.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/summaryTableData.m new file mode 100644 index 000000000..ec9e46b25 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/summaryTableData.m @@ -0,0 +1,38 @@ +% Expected caller: FLIR thermal app UI and tests. Inputs are the current item, +% active display range, and palette. Output is metric/value table data. +function data = summaryTableData(item, range, palette) + + if isempty(item) || strlength(string(item.path)) == 0 + data = { ... + 'Files loaded', '0'; ... + 'Current image', '-'; ... + 'Matrix size', '-'; ... + 'Value range', '-'; ... + 'Palette', char(string(palette))}; + return; + end + [values, units, label] = flir_thermal.thermalPreview.presentationData.valueMatrix(item); + finiteValues = values(isfinite(values)); + if isempty(finiteValues) + measured = '-'; + else + measured = sprintf('%.3g to %.3g %s', ... + min(finiteValues), max(finiteValues), char(units)); + end + data = { ... + 'Current image', char(item.name); ... + 'Matrix size', sprintf('%d x %d px', size(values, 2), size(values, 1)); ... + 'Displayed value', char(label); ... + 'Measured range', measured; ... + 'Display range', sprintf('%.3g to %.3g %s', range(1), range(2), char(units)); ... + 'Range status', rangeStatus(item); ... + 'Palette', char(string(palette))}; +end + +function text = rangeStatus(item) + if isfield(item, 'rangeAdjusted') && logical(item.rangeAdjusted) + text = 'range set'; + else + text = 'needs range'; + end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/valueMatrix.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/valueMatrix.m similarity index 100% rename from apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/valueMatrix.m rename to apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/valueMatrix.m diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/draw.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/draw.m new file mode 100644 index 000000000..1f350f409 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/draw.m @@ -0,0 +1,84 @@ +% App-owned implementation for flir_thermal.thermalPreview.draw within the flir_thermal product workflow. +function draw(axesById, model) +%DRAW Render the clean thermal image, readings, and dedicated scale axis. +drawThermalImage(axesById.thermalImage, model); +drawTemperatureScale(axesById.temperatureScale, model); +end + +function drawThermalImage(ax, model) +view = captureImageView(ax, model.values); +labkit.app.plot.clearAxes(ax); +if isempty(model.values) + title(ax, char(model.title)); + box(ax, "on"); + return +end +rgb = flir_thermal.thermalPreview.presentationData.renderThermalImage( ... + model.values, model.range, model.palette, ... + model.colorMapping, model.gammaValue); +image(ax, rgb, HitTest="off", PickableParts="none"); +axis(ax, "image"); +ax.YDir = "reverse"; +title(ax, char(model.title)); +xlabel(ax, ""); +ylabel(ax, ""); +box(ax, "on"); +flir_thermal.thermalPreview.presentationData.drawTemperatureReadings( ... + ax, model.item); +restoreImageView(ax, view); +end + +function drawTemperatureScale(ax, model) +labkit.app.plot.clearAxes(ax); +if isempty(model.values) + title(ax, "Scale"); + box(ax, "on"); + return +end +range = double(model.range(:).'); +values = linspace(range(1), range(2), 256).'; +imageData = ... + flir_thermal.thermalPreview.presentationData.renderThermalImage( ... + repmat(values, 1, 12), range, model.palette, ... + model.colorMapping, model.gammaValue); +image(ax, CData=imageData, XData=[0 1], YData=range, ... + HitTest="off", PickableParts="none"); +title(ax, ""); +ax.DataAspectRatioMode = "auto"; +ax.PlotBoxAspectRatioMode = "auto"; +ax.XLim = [0 1]; +ax.YLim = range; +ax.YDir = "normal"; +ax.XTick = []; +ax.YTick = [range(1), mean(range), range(2)]; +ax.YTickLabel = cellstr(string(compose("%.1f", ax.YTick))); +if string(model.units) == "C" + ylabel(ax, "deg C"); +else + ylabel(ax, char(string(model.units))); +end +box(ax, "on"); +end + +function view = captureImageView(ax, values) +view = struct("preserve", false, "xLimits", [], "yLimits", []); +images = findobj(ax, "Type", "image"); +if isempty(images) || isempty(values) + return +end +previousSize = size(images(1).CData); +nextSize = size(values); +if numel(previousSize) < 2 || numel(nextSize) < 2 || ... + ~isequal(previousSize(1:2), nextSize(1:2)) + return +end +view = struct("preserve", true, ... + "xLimits", double(ax.XLim), "yLimits", double(ax.YLim)); +end + +function restoreImageView(ax, view) +if view.preserve + ax.XLim = view.xLimits; + ax.YLim = view.yLimits; +end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/model.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/model.m new file mode 100644 index 000000000..1ac37a892 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/model.m @@ -0,0 +1,17 @@ +% App-owned implementation for flir_thermal.thermalPreview.model within the flir_thermal product workflow. +function model = model(item, parameters, range) +%MODEL Build the shared paired-axis FLIR preview model. +values = []; +units = "C"; +titleText = "Clean thermal image"; +if ~isempty(item) + [values, units, titleText] = ... + flir_thermal.thermalPreview.presentationData.valueMatrix(item); +end +model = struct( ... + "values", values, "item", item, "range", range, ... + "units", units, "title", titleText, ... + "palette", string(parameters.palette), ... + "colorMapping", string(parameters.colorMapping), ... + "gammaValue", parameters.gammaValue); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/present.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/present.m new file mode 100644 index 000000000..86613dbbf --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/present.m @@ -0,0 +1,15 @@ +% App-owned implementation for flir_thermal.thermalPreview.present within the flir_thermal product workflow. +function view = present(item, parameters, range) +%PRESENT Prepare the paired thermal image/scale model and reading gesture. +model = flir_thermal.thermalPreview.model(item, parameters, range); +imageSize = []; +if ~isempty(item) + values = ... + flir_thermal.thermalPreview.presentationData.valueMatrix(item); + imageSize = size(values); +end +view = labkit.app.view.Snapshot() ... + .renderPlot("preview", model) ... + .regionSelection("temperatureReading", ... + ImageSize=imageSize, Enabled=~isempty(item)); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/layoutSection.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/layoutSection.m new file mode 100644 index 000000000..11929a747 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/layoutSection.m @@ -0,0 +1,31 @@ +% App-owned implementation for flir_thermal.thermalSources.layoutSection within the flir_thermal product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare the FLIR source library and image navigation. +files = labkit.app.layout.fileList("thermalFiles", ... + Label="FLIR files", ... + Filters=labkit.thermal.fileDialogFilter("IncludeAll", true), ... + SelectionMode="single", Bind="project.inputs.sources", ... + SelectionBind="session.selection.thermalSources", ... + OnSelectionChanged=@flir_thermal.thermalSources.selectCurrent, ... + SourceRole="thermal-image", SourceIdPrefix="thermal", Required=true, ... + ChooseLabel="Add FLIR files or folder", ... + ChooseTooltip="Add radiometric FLIR files with embedded calibration metadata for temperature conversion.", ... + FolderLabel="Add folder", RecursiveFolderLabel="Add folder tree", ... + RemoveLabel="Remove selected", ClearLabel="Clear files", ... + EmptyText="No FLIR files loaded", ShowStatus=false); +navigation = labkit.app.layout.group("navigationActions", { ... + labkit.app.layout.button("previousImage", "Previous image", ... + @flir_thermal.thermalSources.previous, ... + Tooltip="Display the preceding radiometric image with its own temperature data and annotations."), ... + labkit.app.layout.button("nextImage", "Next image", ... + @flir_thermal.thermalSources.next, ... + Tooltip="Display the next radiometric image with its own temperature data and annotations.")}, Layout="horizontal"); +section = labkit.app.layout.section("fileSection", "FLIR Images", { ... + files, ... + labkit.app.layout.field("fileStatus", ... + Label="Status", Kind="readonly", Value="Files: 0"), ... + navigation, ... + labkit.app.layout.field("currentImage", ... + Label="Current image", Kind="readonly", ... + Value="No FLIR image loaded")}); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/next.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/next.m new file mode 100644 index 000000000..c364702a6 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/next.m @@ -0,0 +1,14 @@ +% App-owned implementation for flir_thermal.thermalSources.next within the flir_thermal product workflow. +function applicationState = next(applicationState, callbackContext) +%NEXT Select and decode the following FLIR source. +sources = applicationState.project.inputs.sources; +if isempty(sources) + return +end +index = min(numel(sources), ... + applicationState.session.selection.currentIndex + 1); +selection = labkit.app.event.ListSelection( ... + Ids=string(sources(index).id), Indices=index); +applicationState = flir_thermal.thermalSources.selectCurrent( ... + applicationState, selection, callbackContext); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/present.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/present.m new file mode 100644 index 000000000..602f63d5e --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/present.m @@ -0,0 +1,61 @@ +% App-owned implementation for flir_thermal.thermalSources.present within the flir_thermal product workflow. +function view = present(sources, annotations, index, item) +%PRESENT Describe source rows, navigation, and current image status. +sourceCount = numel(sources); +hasItem = ~isempty(item); +statuses = strings(1, sourceCount); +for k = 1:sourceCount + annotation = annotationFor(annotations, sources(k).id); + if ~isempty(annotation) && logical(annotation.rangeAdjusted) + statuses(k) = "range set"; + else + statuses(k) = "needs range"; + end +end +selection = labkit.app.event.ListSelection(); +if index >= 1 && index <= sourceCount + selection = labkit.app.event.ListSelection( ... + Ids=string(sources(index).id), Indices=index); +end +view = labkit.app.view.Snapshot() ... + .fileItemStatuses("thermalFiles", statuses) ... + .listSelection("thermalFiles", selection) ... + .value("fileStatus", fileStatus(sourceCount, index, item)) ... + .enabled("previousImage", hasItem && index > 1) ... + .enabled("nextImage", hasItem && index < sourceCount) ... + .value("currentImage", currentImageText(item)); +end + +function value = fileStatus(sourceCount, index, item) +if isempty(item) + value = "Files: " + string(sourceCount); + return +end +status = "needs range"; +if logical(item.rangeAdjusted) + status = "range set"; +end +value = sprintf("Files: %d | Current: %d/%d | %s", ... + sourceCount, index, sourceCount, status); +end + +function value = currentImageText(item) +if isempty(item) + value = "No FLIR image loaded"; +elseif logical(item.rangeAdjusted) + value = string(item.name) + " (range set)"; +else + value = string(item.name) + " (needs range)"; +end +end + +function annotation = annotationFor(annotations, sourceId) +annotation = []; +if isempty(annotations) + return +end +match = find(string({annotations.sourceId}) == string(sourceId), 1); +if ~isempty(match) + annotation = annotations(match); +end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/previous.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/previous.m new file mode 100644 index 000000000..f47f0024d --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/previous.m @@ -0,0 +1,20 @@ +% App-owned implementation for flir_thermal.thermalSources.previous within the flir_thermal product workflow. +function applicationState = previous( ... + applicationState, callbackContext) +%PREVIOUS Select and decode the preceding FLIR source. +index = max(1, applicationState.session.selection.currentIndex - 1); +applicationState = selectIndex( ... + applicationState, index, callbackContext); +end + +function applicationState = selectIndex( ... + applicationState, index, callbackContext) +sources = applicationState.project.inputs.sources; +if isempty(sources) + return +end +selection = labkit.app.event.ListSelection( ... + Ids=string(sources(index).id), Indices=index); +applicationState = flir_thermal.thermalSources.selectCurrent( ... + applicationState, selection, callbackContext); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/selectCurrent.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/selectCurrent.m new file mode 100644 index 000000000..06d2f0d0f --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/selectCurrent.m @@ -0,0 +1,94 @@ +% App-owned implementation for flir_thermal.thermalSources.selectCurrent within the flir_thermal product workflow. +function applicationState = selectCurrent( ... + applicationState, selection, callbackContext) +%SELECTCURRENT Reconcile annotations and decode the selected FLIR source. +sources = applicationState.project.inputs.sources; +annotations = applicationState.project.annotations.items; +sourceIds = strings(1, 0); +if ~isempty(sources) + sourceIds = string({sources.id}); +end +annotationIds = strings(1, 0); +if ~isempty(annotations) + annotationIds = string({annotations.sourceId}); + annotations(~ismember(annotationIds, sourceIds)) = []; +end +sourceSetChanged = ~isequal(sort(sourceIds), sort(annotationIds)); +applicationState.project.annotations.items = annotations; + +if isempty(sources) + applicationState.session.selection.currentIndex = 0; + applicationState.session.selection.thermalSources = ... + labkit.app.event.ListSelection(); + applicationState.session.cache.currentItem = []; + applicationState = invalidateResults(applicationState); + callbackContext.appendStatus("Cleared loaded FLIR files."); + return +end + +index = selectedIndex(selection, sources); +try + item = loadItem(sources(index), annotations, callbackContext); +catch ME + callbackContext.reportError("Load selected FLIR image", ME); + callbackContext.alert(ME.message, "Could not load FLIR image"); + return +end +applicationState.session.selection.currentIndex = index; +applicationState.session.selection.thermalSources = ... + labkit.app.event.ListSelection( ... + Ids=string(sources(index).id), Indices=index); +applicationState.session.cache.currentItem = item; +applicationState = ... + flir_thermal.thermalSources.storeCurrentAnnotation( ... + applicationState, item); +if strlength(applicationState.project.parameters.outputFolder) == 0 + paths = callbackContext.resolveSourcePaths(sources); + if ~isempty(paths) + applicationState.project.parameters.outputFolder = ... + string(fullfile(fileparts(paths(1)), "flir_thermal")); + end +end +if sourceSetChanged + applicationState = invalidateResults(applicationState); +end +callbackContext.appendStatus(sprintf( ... + "Selected FLIR image %d of %d.", index, numel(sources))); +end + +function index = selectedIndex(selection, sources) +index = 1; +if ~isempty(selection.Ids) + match = find(string({sources.id}) == string(selection.Ids(1)), 1); + if ~isempty(match) + index = match; + return + end +end +if ~isempty(selection.Indices) + index = min(numel(sources), max(1, selection.Indices(1))); +end +end + +function item = loadItem(source, annotations, callbackContext) +paths = callbackContext.resolveSourcePaths(source); +items = flir_thermal.sourceFiles.readImages( ... + paths, struct("SkipInvalid", false)); +if isempty(items) + error("flir_thermal:UnreadableSource", ... + "The selected FLIR source could not be decoded."); +end +annotation = []; +if ~isempty(annotations) + match = find(string({annotations.sourceId}) == string(source.id), 1); + if ~isempty(match) + annotation = annotations(match); + end +end +item = flir_thermal.thermalAnnotations.apply(items(1), annotation); +end + +function applicationState = invalidateResults(applicationState) +applicationState.project.results.lastExport = []; +applicationState.project.results.resultManifestPath = ""; +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/storeCurrentAnnotation.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/storeCurrentAnnotation.m new file mode 100644 index 000000000..b930d0ebb --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/storeCurrentAnnotation.m @@ -0,0 +1,11 @@ +% App-owned implementation for flir_thermal.thermalSources.storeCurrentAnnotation within the flir_thermal product workflow. +function state=storeCurrentAnnotation(state,item) +index=state.session.selection.currentIndex; +if index<1||index>numel(state.project.inputs.sources),return,end +sourceId=state.project.inputs.sources(index).id; +annotation=flir_thermal.thermalAnnotations.fromItem(item,sourceId); +items=state.project.annotations.items; +old=find(string({items.sourceId})==string(sourceId),1); +if isempty(old),items(end+1,1)=annotation;else,items(old)=annotation;end +state.project.annotations.items=items;state.session.cache.currentItem=item; +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 3f731dcaf..000000000 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,182 +0,0 @@ -% Expected caller: labkit_FLIRThermal_app. Input is the V2 semantic callback -% registry. Output is a data-only UI 5 workbench layout. -function layout = buildWorkbenchLayout(callbacks) - - layout = labkit.ui.layout.workbench('flirThermalApp', 'FLIR Thermal Postprocess', ... - 'controlTabs', controlTabs(callbacks), ... - 'workspace', previewWorkspace(), ... - 'usage', { ... - 'Load original FLIR radiometric JPG/RJPEG files.', ... - 'Set palette and temperature display range.', ... - 'Export clean thermal images, colorbars, and Celsius CSV files.'}); -end - -function tabs = controlTabs(callbacks) - tabs = { ... - setupTab(callbacks), ... - detailsTab(callbacks), ... - logTab()}; -end - -function tab = setupTab(callbacks) - tab = labkit.ui.layout.tab('setup', 'Files + Display + Export', { ... - fileSection(callbacks), ... - displaySection(callbacks), ... - exportSection(callbacks)}); -end - -function tab = detailsTab(callbacks) - tab = labkit.ui.layout.tab('detailsTab', 'Details', { ... - currentSection(), ... - readingToolsSection(callbacks), ... - detailSection()}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab('log', 'Log', { ... - labkit.ui.layout.section('logSection', 'Log', { ... - labkit.ui.layout.logPanel('logPanel', 'Log')})}); -end - -function section = fileSection(callbacks) - section = labkit.ui.layout.section('fileSection', 'FLIR Images', { ... - labkit.ui.layout.filePanel('thermalFiles', 'FLIR files', ... - 'selectionMode', 'single', ... - 'chooseLabel', 'Add FLIR files or folder', ... - 'clearLabel', 'Clear files', ... - 'filters', labkit.thermal.fileDialogFilter("IncludeAll", true), ... - 'status', 'No FLIR files loaded', ... - 'emptyText', 'No FLIR files loaded', ... - 'showStatus', false, ... - 'onChoose', callbacks.filesChosen, ... - 'onRemove', callbacks.removeFiles, ... - 'onSelectionChange', callbacks.selectionChanged, ... - 'onClear', callbacks.clearFiles), ... - labkit.ui.layout.field('fileStatus', 'Status', ... - 'kind', 'readonly', ... - 'value', 'Files: 0'), ... - labkit.ui.layout.group('navigationActions', "", { ... - labkit.ui.layout.action('previousImage', 'Previous image', ... - callbacks.previousImage, ... - 'enabled', false), ... - labkit.ui.layout.action('nextImage', 'Next image', ... - callbacks.nextImage, ... - 'enabled', false)}), ... - labkit.ui.layout.field('currentImage', 'Current image', ... - 'kind', 'readonly', ... - 'value', 'No FLIR image loaded')}); -end - -function section = currentSection() - section = labkit.ui.layout.section('currentSection', 'Current Image', { ... - labkit.ui.layout.resultTable('summaryTable', 'Current Image', ... - 'columns', {'Metric', 'Value'}, ... - 'data', flir_thermal.userInterface.summaryTableData([], [-20 120], "turbo"))}); -end - -function section = displaySection(callbacks) - labels = flir_thermal.userInterface.rangeControlLabels(); - section = labkit.ui.layout.section('displaySection', 'Display', { ... - labkit.ui.layout.field('palette', 'Palette', ... - 'kind', 'dropdown', ... - 'items', {'turbo', 'iron', 'hot', 'parula', 'gray'}, ... - 'value', 'turbo', ... - 'onChange', callbacks.paletteChanged), ... - labkit.ui.layout.field('colorMapping', 'Color mapping', ... - 'kind', 'dropdown', ... - 'items', {'Linear', 'Log', 'Gamma'}, ... - 'value', 'Linear', ... - 'onChange', callbacks.paletteChanged), ... - labkit.ui.layout.panner('gammaValue', 'Gamma', ... - 'value', 2.2, ... - 'limits', [0.1 5], ... - 'step', 0.1, ... - 'valueDisplayFormat', '%.2f', ... - 'showTicks', false, ... - 'onChange', callbacks.paletteChanged), ... - labkit.ui.layout.field('rangePreset', 'Bounds', ... - 'kind', 'dropdown', ... - 'items', flir_thermal.userInterface.rangePresetItems(), ... - 'value', labels.defaultPreset, ... - 'onChange', callbacks.rangePresetChanged), ... - labkit.ui.layout.group('rangeActions', "", { ... - labkit.ui.layout.action('perImageRange', labels.setEachRange, ... - callbacks.perImageRange, ... - 'enabled', false), ... - labkit.ui.layout.action('groupRange', labels.setSharedRange, ... - callbacks.groupRange, ... - 'enabled', false), ... - labkit.ui.layout.action('autoRange', labels.setCurrentRange, ... - callbacks.autoRange, ... - 'enabled', false), ... - labkit.ui.layout.action('roundRange', labels.roundSetRanges, ... - callbacks.roundRange, ... - 'enabled', false)}), ... - labkit.ui.layout.panner('temperatureMin', 'Min C', ... - 'value', -20, ... - 'limits', [-20 120], ... - 'step', 0.01, ... - 'valueDisplayFormat', '%.2f', ... - 'showTicks', false, ... - 'enabled', false, ... - 'onChange', callbacks.rangeChanged), ... - labkit.ui.layout.panner('temperatureMax', 'Max C', ... - 'value', 120, ... - 'limits', [-20 120], ... - 'step', 0.01, ... - 'valueDisplayFormat', '%.2f', ... - 'showTicks', false, ... - 'enabled', false, ... - 'onChange', callbacks.rangeChanged)}); -end - -function section = exportSection(callbacks) - section = labkit.ui.layout.section('exportSection', 'Export', { ... - labkit.ui.layout.field('outputFolder', 'Output folder', ... - 'kind', 'readonly', ... - 'value', ''), ... - labkit.ui.layout.field('exportFormat', 'Image format', ... - 'kind', 'dropdown', ... - 'items', {'PNG', 'TIFF', 'JPEG'}, ... - 'value', 'PNG'), ... - labkit.ui.layout.group('exportActions', "", { ... - labkit.ui.layout.action('chooseOutputFolder', 'Choose folder', ... - callbacks.chooseOutputFolder), ... - labkit.ui.layout.action('exportCurrent', 'Export current', ... - callbacks.exportCurrent, ... - 'enabled', false), ... - labkit.ui.layout.action('exportAll', 'Export all', ... - callbacks.exportAll, ... - 'enabled', false)})}); -end - -function section = detailSection() - section = labkit.ui.layout.section('detailSection', 'Details', { ... - labkit.ui.layout.statusPanel('details', 'Details', ... - 'value', flir_thermal.userInterface.detailLines( ... - repmat(flir_thermal.sourceFiles.emptyItem(), 0, 1), 0, ""))}); -end - -function section = readingToolsSection(callbacks) - labels = flir_thermal.userInterface.rangeControlLabels(); - section = labkit.ui.layout.section('readingToolsSection', 'Reading Tools', { ... - labkit.ui.layout.group('roiReadings', "", { ... - labkit.ui.layout.action('roiHotMode', labels.roiHotSpot, ... - callbacks.roiHotMode, ... - 'enabled', false), ... - labkit.ui.layout.action('roiColdMode', labels.roiColdSpot, ... - callbacks.roiColdMode, ... - 'enabled', false), ... - labkit.ui.layout.action('roiMeanMode', labels.roiMean, ... - callbacks.roiMeanMode, ... - 'enabled', false)})}); -end - -function workspace = previewWorkspace() - workspace = labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.previewArea('preview', 'Thermal Preview', ... - 'layout', 'pair', ... - 'axisIds', {'thermalImage', 'temperatureScale'}, ... - 'axisTitles', {'Clean thermal image', 'Scale'}, ... - 'columnWidths', {'1x', 70})}); -end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/detailLines.m b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/detailLines.m deleted file mode 100644 index f09309641..000000000 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/detailLines.m +++ /dev/null @@ -1,129 +0,0 @@ -% Expected caller: FLIR thermal app UI and tests. Inputs are app items, -% current selection, and export folder. Output is status-panel text. -function lines = detailLines(items, currentIndex, outputFolder) - - if isempty(items) - lines = { - 'No FLIR radiometric images loaded.' - 'Load original FLIR JPG/RJPEG files, not exported screenshots.' - ['Output folder: ' char(string(outputFolder))]}; - return; - end - currentIndex = max(1, min(currentIndex, numel(items))); - item = items(currentIndex); - range = double(item.displayRange(:)).'; - labels = flir_thermal.userInterface.rangeControlLabels(); - calibration = flir_thermal.userInterface.calibrationStatus(item); - lines = { - sprintf('Loaded files: %d', numel(items)) - sprintf('Current file: %s', char(item.name)) - sprintf('Range status: %s', rangeStatus(item)) - sprintf('Display range: %.3g to %.3g %s', ... - range(1), range(2), char(string(item.units))) - sprintf('Image hot spot: %s', pointText(item, 'hotSpot')) - sprintf('Image cold spot: %s', pointText(item, 'coldSpot')) - sprintf('Manual point: %s', pointText(item, 'manualPoint')) - sprintf('%s: %s', char(labels.roiHotSpot), pointText(item, 'roiHotSpot')) - sprintf('%s: %s', char(labels.roiColdSpot), pointText(item, 'roiColdSpot')) - sprintf('%s: %s', char(labels.roiMean), roiText(item, 'roiMean')) - sprintf('Temperature differences: %s', differenceSummary(item)) - sprintf('Reader: %s', metadataText(item, 'reader')) - sprintf('Raw byte order: %s', metadataText(item, 'rawByteOrder')) - char(calibration.detailText) - sprintf('Message: %s', char(string(item.message))) - ['Output folder: ' char(string(outputFolder))]}; -end - -function text = rangeStatus(item) - if isfield(item, 'rangeAdjusted') && logical(item.rangeAdjusted) - text = 'range set'; - else - text = 'needs range'; - end -end - -function text = metadataText(item, field) - text = "-"; - if isfield(item, 'metadata') && isfield(item.metadata, field) - text = char(string(item.metadata.(field))); - end -end - -function text = pointText(item, field) - text = "-"; - if ~isfield(item, field) - return; - end - reading = item.(field); - if ~isPointReading(reading) - return; - end - text = sprintf('x %.0f, y %.0f, %.2f C', ... - reading.x, reading.y, reading.temperatureC); -end - -function text = roiText(item, field) - text = "-"; - if ~isfield(item, field) - return; - end - reading = item.(field); - if ~isRoiReading(reading) - return; - end - text = sprintf('x %.0f, y %.0f, %.0f x %.0f px, %.2f C', ... - reading.x, reading.y, reading.width, reading.height, ... - reading.temperatureC); -end - -function text = differenceSummary(item) - labels = flir_thermal.userInterface.rangeControlLabels(); - names = ["image hot", "image cold", "manual", ... - labels.roiHotSpot, labels.roiColdSpot, labels.roiMean]; - values = [ - readingTemperature(item, 'hotSpot') - readingTemperature(item, 'coldSpot') - readingTemperature(item, 'manualPoint') - readingTemperature(item, 'roiHotSpot') - readingTemperature(item, 'roiColdSpot') - readingTemperature(item, 'roiMean')]; - parts = strings(1, 0); - for a = 1:numel(values) - if ~isfinite(values(a)) - continue; - end - for b = a+1:numel(values) - if ~isfinite(values(b)) - continue; - end - parts(end+1) = sprintf('%s - %s = %.2f C', ... - names(a), names(b), values(a) - values(b)); - end - end - if isempty(parts) - text = "-"; - else - text = strjoin(parts, '; '); - end -end - -function value = readingTemperature(item, field) - value = NaN; - if isfield(item, field) && isstruct(item.(field)) && ... - isfield(item.(field), 'temperatureC') - value = double(item.(field).temperatureC); - end -end - -function tf = isPointReading(reading) - tf = isstruct(reading) && all(isfield(reading, ... - {'x', 'y', 'temperatureC'})) && ... - all(isfinite([reading.x, reading.y, reading.temperatureC])); -end - -function tf = isRoiReading(reading) - tf = isstruct(reading) && all(isfield(reading, ... - {'x', 'y', 'width', 'height', 'temperatureC'})) && ... - all(isfinite([reading.x, reading.y, reading.width, ... - reading.height, reading.temperatureC])); -end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/filePanelEntries.m b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/filePanelEntries.m deleted file mode 100644 index 0d6ab6218..000000000 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/filePanelEntries.m +++ /dev/null @@ -1,21 +0,0 @@ -% Expected caller: FLIR thermal runner refresh logic and tests. Input is the -% loaded item struct array. Output is filePanel entry structs with per-image -% range-session status labels. -function entries = filePanelEntries(items) - - entries = repmat(struct('path', "", 'status', ""), numel(items), 1); - for k = 1:numel(items) - entries(k).path = string(items(k).path); - if isfield(items(k), 'rangeAdjusted') && logical(items(k).rangeAdjusted) - entries(k).status = "range set"; - else - entries(k).status = "needs range"; - end - calibration = flir_thermal.userInterface.calibrationStatus(items(k)); - if calibration.severity == "warning" - entries(k).status = entries(k).status + "; calibration defaults used"; - elseif calibration.severity == "unavailable" - entries(k).status = entries(k).status + "; temperature unavailable"; - end - end -end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m deleted file mode 100644 index c8b348d70..000000000 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,201 +0,0 @@ -% Expected caller: the LabKit V2 runtime. Input is canonical FLIR Thermal -% state. Output is deterministic controls, two preview axes, and one -% transient point/region semantic interaction. -function view = presentWorkbench(state) - sources = state.project.inputs.sources; - annotations = state.project.annotations.items; - index = state.session.selection.currentIndex; - item = state.session.cache.currentItem; - hasItem = ~isempty(item); - p = state.project.parameters; - [range, bounds, preset] = rangeState(item); - - view = struct(); - view.controls.thermalFiles = fileSpec(sources, annotations, index); - view.controls.fileStatus = valueSpec(fileStatus(sources, index, item)); - view.controls.currentImage = valueSpec(currentImageText(item)); - view.controls.previousImage = enabledSpec(hasItem && index > 1); - view.controls.nextImage = enabledSpec(hasItem && index < numel(sources)); - view.controls.palette = valueSpec(p.palette); - view.controls.colorMapping = valueSpec(p.colorMapping); - view.controls.gammaValue = valueSpec(p.gammaValue); - view.controls.rangePreset = controlSpec(hasItem, preset); - view.controls.temperatureMin = rangeSpec(hasItem, range(1), bounds); - view.controls.temperatureMax = rangeSpec(hasItem, range(2), bounds); - view.controls.autoRange = enabledSpec(hasItem); - view.controls.groupRange = enabledSpec(hasItem); - view.controls.perImageRange = enabledSpec(hasItem); - view.controls.roundRange = enabledSpec(anyAdjusted(annotations)); - view.controls.roiHotMode = enabledSpec(hasItem); - view.controls.roiColdMode = enabledSpec(hasItem); - view.controls.roiMeanMode = enabledSpec(hasItem); - view.controls.summaryTable = dataSpec( ... - flir_thermal.userInterface.summaryTableData(item, range, p.palette)); - view.controls.outputFolder = valueSpec(p.outputFolder); - view.controls.exportFormat = valueSpec(p.exportFormat); - view.controls.exportCurrent = enabledSpec(hasItem); - view.controls.exportAll = enabledSpec(~isempty(sources)); - view.controls.details = valueSpec(detailLines(state, item)); - model = previewModel(item, p, range); - view.previews.preview.Axes.thermalImage = struct( ... - "Renderer", "thermalPreview", "Model", model); - view.previews.preview.Axes.temperatureScale = struct( ... - "Renderer", "temperatureScale", "Model", model); - if hasItem - [values] = flir_thermal.userInterface.valueMatrix(item); - view.interactions.temperatureReading = struct( ... - "Kind", "regionSelection", ... - "Targets", "preview.thermalImage", ... - "Value", [], ... - "Event", "temperatureRegionSelected", ... - "BackgroundEvent", "temperaturePointSelected", ... - "ImageSize", size(values), ... - "ChangePolicy", "commit", ... - "Options", struct("color", [1 1 1], ... - "lineWidth", 1.2, "pointThreshold", 2)); - end -end - -function spec = fileSpec(sources, annotations, index) - files = repmat(struct("id", "", "path", "", "status", "ready"), ... - numel(sources), 1); - paths = labkit.ui.runtime.sourcePaths(sources); - for k = 1:numel(sources) - files(k).id = string(sources(k).id); - files(k).path = paths(k); - annotation = annotationFor(annotations, sources(k).id); - if ~isempty(annotation) && logical(annotation.rangeAdjusted) - files(k).status = "range set"; - else - files(k).status = "needs range"; - end - end - selection = strings(0, 1); - if index >= 1 && index <= numel(sources) - selection = string(sources(index).id); - end - spec = struct(); - spec.Files = files; - spec.Selection = selection; -end - -function value = fileStatus(sources, index, item) - if isempty(item) - value = "Files: " + string(numel(sources)); - return; - end - if logical(item.rangeAdjusted) - status = "range set"; - else - status = "needs range"; - end - value = sprintf('Files: %d | Current: %d/%d | %s', ... - numel(sources), index, numel(sources), status); -end - -function value = currentImageText(item) - if isempty(item) - value = "No FLIR image loaded"; - elseif logical(item.rangeAdjusted) - value = string(item.name) + " (range set)"; - else - value = string(item.name) + " (needs range)"; - end -end - -function [range, bounds, preset] = rangeState(item) - labels = flir_thermal.userInterface.rangeControlLabels(); - range = [20 40]; - bounds = [-20 120]; - preset = labels.defaultPreset; - if isempty(item) - return; - end - range = normalizeRange(item.displayRange); - bounds = normalizeRange(item.rangeControlBounds); - bounds = [min(bounds(1), range(1)), max(bounds(2), range(2))]; - preset = string(item.rangePreset); -end - -function model = previewModel(item, parameters, range) - values = []; - units = "C"; - titleText = "Clean thermal image"; - if ~isempty(item) - [values, units, titleText] = ... - flir_thermal.userInterface.valueMatrix(item); - end - model = struct( ... - "values", values, ... - "item", item, ... - "range", range, ... - "units", units, ... - "title", titleText, ... - "palette", parameters.palette, ... - "colorMapping", parameters.colorMapping, ... - "gammaValue", parameters.gammaValue); -end - -function lines = detailLines(state, item) - folder = state.project.parameters.outputFolder; - if isempty(item) - lines = flir_thermal.userInterface.detailLines([], 0, folder); - else - lines = flir_thermal.userInterface.detailLines(item, 1, folder); - lines{1} = sprintf('Loaded files: %d', ... - numel(state.project.inputs.sources)); - end - if strlength(state.project.results.resultManifestPath) > 0 - lines{end + 1} = char("Last result manifest: " + ... - state.project.results.resultManifestPath); - end -end - -function annotation = annotationFor(annotations, sourceId) - annotation = []; - index = find(string({annotations.sourceId}) == string(sourceId), 1); - if ~isempty(index) - annotation = annotations(index); - end -end - -function tf = anyAdjusted(annotations) - tf = false; - if ~isempty(annotations) - tf = any(logical([annotations.rangeAdjusted])); - end -end - -function range = normalizeRange(value) - range = double(value(:).'); - if numel(range) ~= 2 || ~all(isfinite(range)) - range = [20 40]; - end - range = sort(range); - if range(2) <= range(1) - range(2) = range(1) + 1; - end -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = dataSpec(value) - spec = struct(); - spec.Data = value; -end - -function spec = enabledSpec(value) - spec = struct("Enabled", logical(value)); -end - -function spec = controlSpec(enabled, value) - spec = struct("Enabled", logical(enabled), "Value", value); -end - -function spec = rangeSpec(enabled, value, limits) - spec = struct("Enabled", logical(enabled), "Value", value, ... - "Limits", limits); -end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/renderTemperatureScale.m b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/renderTemperatureScale.m deleted file mode 100644 index 89a99281b..000000000 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/renderTemperatureScale.m +++ /dev/null @@ -1,31 +0,0 @@ -% Expected caller: the registered FLIR V2 renderer. Inputs are target axes -% and a prepared thermal model. Side effects are limited to drawing the scale. -function renderTemperatureScale(ax, model) - if isempty(model.values) - labkit.ui.plot.clear(ax, "ResetScale", true, "ClearLegend", false); - title(ax, 'Scale'); - box(ax, 'on'); - return; - end - range = double(model.range(:).'); - values = linspace(range(1), range(2), 256).'; - imageData = flir_thermal.userInterface.renderThermalImage( ... - repmat(values, 1, 12), range, model.palette, ... - model.colorMapping, model.gammaValue); - labkit.ui.plot.clear(ax, "ResetScale", true, "ClearLegend", false); - image(ax, 'CData', imageData, 'XData', [0 1], 'YData', range); - title(ax, ''); - ax.DataAspectRatioMode = 'auto'; - ax.PlotBoxAspectRatioMode = 'auto'; - ax.XLim = [0 1]; - ax.YLim = range; - ax.YDir = 'normal'; - ax.XTick = []; - ax.YTick = [range(1), mean(range), range(2)]; - ax.YTickLabel = cellstr(string(compose('%.1f', ax.YTick))); - if string(model.units) == "C" - ylabel(ax, 'deg C'); - else - ylabel(ax, char(string(model.units))); - end -end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/renderThermalPreview.m b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/renderThermalPreview.m deleted file mode 100644 index 432d08ca7..000000000 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/renderThermalPreview.m +++ /dev/null @@ -1,21 +0,0 @@ -% Expected caller: the registered FLIR V2 renderer. Inputs are target axes -% and a prepared thermal model. Side effects are limited to drawing the axes. -function renderThermalPreview(ax, model) - labkit.ui.plot.clear(ax, "ResetScale", true); - if isempty(model.values) - title(ax, char(model.title)); - box(ax, 'on'); - return; - end - rgb = flir_thermal.userInterface.renderThermalImage( ... - model.values, model.range, model.palette, ... - model.colorMapping, model.gammaValue); - image(ax, rgb); - axis(ax, 'image'); - ax.YDir = 'reverse'; - title(ax, char(model.title)); - xlabel(ax, ''); - ylabel(ax, ''); - box(ax, 'on'); - flir_thermal.userInterface.drawTemperatureReadings(ax, model.item); -end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/summaryTableData.m b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/summaryTableData.m deleted file mode 100644 index 8e00c781c..000000000 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/summaryTableData.m +++ /dev/null @@ -1,38 +0,0 @@ -% Expected caller: FLIR thermal app UI and tests. Inputs are the current item, -% active display range, and palette. Output is metric/value table data. -function data = summaryTableData(item, range, palette) - - if isempty(item) || strlength(string(item.path)) == 0 - data = { ... - 'Files loaded', '0'; ... - 'Current image', '-'; ... - 'Matrix size', '-'; ... - 'Value range', '-'; ... - 'Palette', char(string(palette))}; - return; - end - [values, units, label] = flir_thermal.userInterface.valueMatrix(item); - finiteValues = values(isfinite(values)); - if isempty(finiteValues) - measured = '-'; - else - measured = sprintf('%.3g to %.3g %s', ... - min(finiteValues), max(finiteValues), char(units)); - end - data = { ... - 'Current image', char(item.name); ... - 'Matrix size', sprintf('%d x %d px', size(values, 2), size(values, 1)); ... - 'Displayed value', char(label); ... - 'Measured range', measured; ... - 'Display range', sprintf('%.3g to %.3g %s', range(1), range(2), char(units)); ... - 'Range status', rangeStatus(item); ... - 'Palette', char(string(palette))}; -end - -function text = rangeStatus(item) - if isfield(item, 'rangeAdjusted') && logical(item.rangeAdjusted) - text = 'range set'; - else - text = 'needs range'; - end -end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+workbench/buildLayout.m b/apps/image_measurement/flir_thermal/+flir_thermal/+workbench/buildLayout.m new file mode 100644 index 000000000..43a4d513f --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+workbench/buildLayout.m @@ -0,0 +1,41 @@ +% App-owned implementation for flir_thermal.workbench.buildLayout within the flir_thermal product workflow. +function layout = buildLayout() +%BUILDLAYOUT Assemble FLIR sources, display, readings, and result export. +controls = { ... + labkit.app.layout.tab("setup", "Files + Display + Export", { ... + flir_thermal.thermalSources.layoutSection(), ... + flir_thermal.displayMapping.layoutSection(), ... + flir_thermal.resultFiles.layoutSection()}), ... + labkit.app.layout.tab("detailsTab", "Details", { ... + labkit.app.layout.section("currentSection", "Current Image", { ... + labkit.app.layout.dataTable("summaryTable", ... + Title="Current Image", Columns=["Metric", "Value"])}), ... + flir_thermal.temperatureReadings.layoutSection(), ... + labkit.app.layout.section("detailSection", "Details", { ... + labkit.app.layout.statusPanel("details", Title="Details")})}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("logPanel", Title="Log")})})}; +reading = labkit.app.interaction.regionSelection( ... + "temperatureReading", @flir_thermal.temperatureReadings.changeRegion, ... + Axis="thermalImage", ... + Style=struct("color", [1 1 1], "lineWidth", 1.2, ... + "pointThreshold", 2), ... + Instruction="Click for a manual point or drag a region for the " + ... + "selected ROI reading.", ... + OnBackgroundPressed=@flir_thermal.temperatureReadings.changePoint, ... + ViewportPolicy="preserve"); +preview = labkit.app.layout.plotArea("preview", ... + @flir_thermal.thermalPreview.draw, ... + Title="Thermal Preview", Layout="pair", ... + AxisIds=["thermalImage", "temperatureScale"], ... + AxisTitles=["Clean thermal image", "Scale"], ... + ColumnWidths={'1x', 70}, Interactions={reading}); +workspace = labkit.app.layout.workspace(preview, Title="Preview"); +usage = [ ... + "Load original FLIR radiometric JPG/RJPEG files.", ... + "Set palette and temperature display range.", ... + "Export clean thermal images, colorbars, and Celsius CSV files."]; +layout = labkit.app.layout.workbench(controls, Workspace=workspace, ... + Usage=usage); +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+workbench/present.m b/apps/image_measurement/flir_thermal/+flir_thermal/+workbench/present.m new file mode 100644 index 000000000..2e6fa5c0d --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+workbench/present.m @@ -0,0 +1,69 @@ +% App-owned implementation for flir_thermal.workbench.present within the flir_thermal product workflow. +function view = present(applicationState) +%PRESENT Compose FLIR Thermal's complete semantic snapshot. +project = applicationState.project; +session = applicationState.session; +sources = project.inputs.sources; +annotations = project.annotations.items; +item = session.cache.currentItem; +index = session.selection.currentIndex; +hasItem = ~isempty(item); +[range, bounds, preset] = rangeState(item); + +summary = flir_thermal.thermalPreview.presentationData.summaryTableData( ... + item, range, project.parameters.palette); +details = detailLines(applicationState, item); +view = flir_thermal.thermalSources.present( ... + sources, annotations, index, item) ... + .include(flir_thermal.displayMapping.present( ... + project.parameters, annotations, hasItem, range, bounds, preset)) ... + .include(flir_thermal.resultFiles.present( ... + project.parameters, hasItem, ~isempty(sources))) ... + .include(flir_thermal.temperatureReadings.present(hasItem)) ... + .include(flir_thermal.thermalPreview.present( ... + item, project.parameters, range)) ... + .tableData("summaryTable", summary, Columns=["Metric", "Value"]) ... + .text("details", join(string(details), newline)); +end + +function [range, bounds, preset] = rangeState(item) +labels = flir_thermal.thermalPreview.presentationData.rangeControlLabels(); +range = [20 40]; +bounds = [-20 120]; +preset = labels.defaultPreset; +if isempty(item) + return +end +range = normalizeRange(item.displayRange, [20 40]); +bounds = normalizeRange(item.rangeControlBounds, [-20 120]); +bounds = [min(bounds(1), range(1)), max(bounds(2), range(2))]; +preset = string(item.rangePreset); +end + +function range = normalizeRange(value, fallback) +range = double(value(:).'); +if numel(range) ~= 2 || any(~isfinite(range)) + range = fallback; +end +range = sort(range); +if range(2) <= range(1) + range(2) = range(1) + 1; +end +end + +function lines = detailLines(applicationState, item) +folder = applicationState.project.parameters.outputFolder; +if isempty(item) + lines = flir_thermal.thermalPreview.presentationData.detailLines( ... + [], 0, folder); +else + lines = flir_thermal.thermalPreview.presentationData.detailLines( ... + item, 1, folder); + lines{1} = sprintf("Loaded files: %d", ... + numel(applicationState.project.inputs.sources)); +end +manifest = string(applicationState.project.results.resultManifestPath); +if strlength(manifest) > 0 + lines{end + 1} = char("Last result manifest: " + manifest); +end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/createSession.m b/apps/image_measurement/flir_thermal/+flir_thermal/createSession.m index 7cac2b030..58e4f8564 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/createSession.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/createSession.m @@ -1,21 +1,22 @@ % Rebuild the selected transient decoded image from one validated FLIR -% project. Runtime V2 calls this after portable source relinking. -function session = createSession(project) +% project. App SDK runtime calls this after portable source relinking. +function session = createSession(project, context) index = double(~isempty(project.inputs.sources)); item = []; if index > 0 - item = loadSelected(project, index); + item = loadSelected(project, index, context); end session = struct( ... - "selection", struct("currentIndex", index), ... + "selection", struct("currentIndex", index, ... + "thermalSources", labkit.app.event.ListSelection()), ... "cache", struct("currentItem", item)); end -function item = loadSelected(project, index) +function item = loadSelected(project, index, context) item = []; source = project.inputs.sources(index); loaded = flir_thermal.sourceFiles.readImages( ... - labkit.ui.runtime.sourcePaths(source), ... + context.resolveSourcePaths(source), ... struct("SkipInvalid", false)); if ~isempty(loaded) annotation = annotationFor(project.annotations.items, source.id); diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/definition.m b/apps/image_measurement/flir_thermal/+flir_thermal/definition.m index 1ef0bdcab..1c0b383dc 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/definition.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/definition.m @@ -1,25 +1,16 @@ % App-owned runtime definition for labkit_FLIRThermal_app. Expected caller: % the public app entrypoint. Output is a declarative LabKit app definition; % side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_FLIRThermal_app", ... - "Id", "flir_thermal", ... - "Title", "FLIR Thermal Postprocess", ... - "DisplayName", "FLIR Thermal", ... - "Family", "Image Measurement", ... - "AppVersion", "1.4.8", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "image", ">=2.0 <3", ... +function app = definition() + app = labkit.app.Definition( ... + Entrypoint="labkit_FLIRThermal_app", ... + AppId="flir_thermal", ... + Title="FLIR Thermal Postprocess", DisplayName="FLIR Thermal", ... + Family="Image Measurement", AppVersion="1.5.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements( ... + "app", ">=1 <2", "image", ">=2.0 <3", ... "thermal", ">=1.1 <2"), ... - "Project", flir_thermal.projectSpec(), ... - "CreateSession", @flir_thermal.createSession, ... - "Layout", @flir_thermal.userInterface.buildWorkbenchLayout, ... - "Actions", flir_thermal.definitionActions(), ... - "Present", @flir_thermal.userInterface.presentWorkbench, ... - "Renderers", struct( ... - "thermalPreview", @flir_thermal.userInterface.renderThermalPreview, ... - "temperatureScale", @flir_thermal.userInterface.renderTemperatureScale), ... - "DebugSample", @flir_thermal.debug.writeSamplePack); + ProjectSchema=flir_thermal.projectSpec(), CreateSession=@flir_thermal.createSession, ... + Workbench=flir_thermal.workbench.buildLayout(), PresentWorkbench=@flir_thermal.workbench.present, ... + BuildDebugSample=@flir_thermal.debug.writeSamplePack); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m b/apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m deleted file mode 100644 index 7a8988221..000000000 --- a/apps/image_measurement/flir_thermal/+flir_thermal/definitionActions.m +++ /dev/null @@ -1,600 +0,0 @@ -% App-owned V2 actions for FLIR Thermal. Handlers own portable source -% records, lightweight durable readings/ranges, one selected decode cache, -% and export boundaries without UI handles or control synchronization. -function actions = definitionActions() - actions = struct( ... - "filesChosen", @onFilesChosen, ... - "removeFiles", @onRemoveFiles, ... - "clearFiles", @onClearFiles, ... - "selectionChanged", @onSelectionChanged, ... - "previousImage", @onPreviousImage, ... - "nextImage", @onNextImage, ... - "autoRange", @onAutoRange, ... - "groupRange", @onGroupRange, ... - "perImageRange", @onPerImageRange, ... - "roundRange", @onRoundRange, ... - "paletteChanged", @onDisplaySettingChanged, ... - "rangePresetChanged", @onRangePresetChanged, ... - "rangeChanged", @onRangeChanged, ... - "roiHotMode", @(state, event, services) setRoiMode( ... - state, "hot", services), ... - "roiColdMode", @(state, event, services) setRoiMode( ... - state, "cold", services), ... - "roiMeanMode", @(state, event, services) setRoiMode( ... - state, "mean", services), ... - "temperaturePointSelected", @onTemperaturePointSelected, ... - "temperatureRegionSelected", @onTemperatureRegionSelected, ... - "exportSettingChanged", @onExportSettingChanged, ... - "chooseOutputFolder", @onChooseOutputFolder, ... - "exportCurrent", @(state, event, services) exportSelection( ... - state, true, services), ... - "exportAll", @(state, event, services) exportSelection( ... - state, false, services)); -end - -function state = onFilesChosen(state, event, services) - requestedPaths = services.events.paths(event, "files"); - newSourcePaths = services.events.paths(event, "addedFiles"); - if isempty(newSourcePaths) - newSourcePaths = requestedPaths; - end - if isempty(newSourcePaths) - state = services.workflow.log(state, "FLIR file selection cancelled."); - return; - end - try - [addedItems, report] = flir_thermal.sourceFiles.readImages(newSourcePaths); - catch ME - state = reportFailure(state, "Could not read FLIR images", ME, services); - return; - end - oldSources = state.project.inputs.sources; - oldPaths = labkit.ui.runtime.sourcePaths(oldSources); - acceptedPaths = string({addedItems.path}).'; - desiredPaths = requestedPaths; - if isempty(desiredPaths) - desiredPaths = [oldPaths; acceptedPaths]; - end - desiredPaths = desiredPaths(ismember(desiredPaths, ... - [oldPaths; acceptedPaths])); - sources = services.project.reconcileSources( ... - oldSources, desiredPaths, "thermal-image", "thermal", true); - annotations = reconcileAnnotations( ... - state.project.annotations.items, sources, addedItems); - state.project.inputs.sources = sources; - state.project.annotations.items = annotations; - state.session.selection.currentIndex = selectedIndex(sources, acceptedPaths); - state.session.cache.currentItem = selectedLoadedItem( ... - state, addedItems, services); - state.project.parameters.outputFolder = string( ... - services.dialogs.defaultOutputFolder(desiredPaths, "flir_thermal", ... - state.project.parameters.outputFolder)); - state = invalidateResults(state); - state = services.workflow.log(state, sprintf( ... - 'Registered %d FLIR source(s); decoded only newly added files.', ... - numel(sources))); - if report.skipped > 0 - services.dialogs.alert(importReportMessage(report), ... - "Some FLIR files were skipped"); - end -end - -function state = onRemoveFiles(state, event, services) - sources = state.project.inputs.sources; - indices = services.events.indices(event, "removedFiles", numel(sources)); - if isempty(indices) - return; - end - removedIds = string({sources(indices).id}); - sources(indices) = []; - annotations = state.project.annotations.items; - annotations(ismember(string({annotations.sourceId}), removedIds)) = []; - state.project.inputs.sources = sources; - state.project.annotations.items = annotations; - state.session.selection.currentIndex = min( ... - state.session.selection.currentIndex, numel(sources)); - state = reloadCurrent(state, services); - state = invalidateResults(state); -end - -function state = onClearFiles(state, ~, services) - state.project.inputs.sources = labkit.ui.runtime.emptySourceRecords(); - state.project.annotations.items = repmat( ... - flir_thermal.thermalAnnotations.empty(), 0, 1); - state.session.selection.currentIndex = 0; - state.session.cache.currentItem = []; - state = invalidateResults(state); - state = services.workflow.log(state, "Cleared loaded FLIR files."); -end - -function state = onSelectionChanged(state, event, services) - indices = services.events.indices(event, "selectedFiles", ... - numel(state.project.inputs.sources)); - if isempty(indices) - return; - end - state.session.selection.currentIndex = indices(1); - state = reloadCurrent(state, services); -end - -function state = onPreviousImage(state, ~, services) - state.session.selection.currentIndex = max(1, ... - state.session.selection.currentIndex - 1); - state = reloadCurrent(state, services); -end - -function state = onNextImage(state, ~, services) - state.session.selection.currentIndex = min( ... - numel(state.project.inputs.sources), ... - state.session.selection.currentIndex + 1); - state = reloadCurrent(state, services); -end - -function state = onAutoRange(state, ~, services) - item = state.session.cache.currentItem; - if isempty(item) - return; - end - item = setItemRange(item, autoRangeForItem(item), true); - state = storeCurrentItem(state, item); - state = invalidateResults(state); - state = services.workflow.log(state, "Set the selected FLIR auto range."); -end - -function state = onGroupRange(state, ~, services) - [items, ok] = loadAllItems(state, services); - if ~ok - return; - end - ranges = zeros(numel(items), 2); - for k = 1:numel(items) - ranges(k, :) = autoRangeForItem(items(k)); - end - sharedRange = normalizeRange([min(ranges(:, 1)), max(ranges(:, 2))]); - for k = 1:numel(items) - items(k).displayRange = sharedRange; - items(k).rangeControlBounds = sharedRange; - items(k).rangeAdjusted = true; - end - state = storeLoadedItems(state, items); - state = invalidateResults(state); - state = services.workflow.log(state, sprintf( ... - 'Set %d FLIR files to one shared range.', numel(items))); -end - -function state = onPerImageRange(state, ~, services) - [items, ok] = loadAllItems(state, services); - if ~ok - return; - end - for k = 1:numel(items) - items(k) = setItemRange(items(k), autoRangeForItem(items(k)), true); - end - state = storeLoadedItems(state, items); - state = invalidateResults(state); - state = services.workflow.log(state, sprintf( ... - 'Set individual ranges for %d FLIR files.', numel(items))); -end - -function state = onRoundRange(state, ~, services) - annotations = state.project.annotations.items; - count = 0; - for k = 1:numel(annotations) - if ~logical(annotations(k).rangeAdjusted) - continue; - end - range = normalizeRange(annotations(k).displayRange); - range = normalizeRange([floor(range(1)), ceil(range(2))]); - annotations(k).displayRange = range; - annotations(k).rangeControlBounds = controlBoundsContaining( ... - range, annotations(k).rangeControlBounds); - count = count + 1; - end - state.project.annotations.items = annotations; - state = reloadCurrent(state, services); - state = invalidateResults(state); - state = services.workflow.log(state, sprintf( ... - 'Rounded %d already-set FLIR range(s).', count)); -end - -function state = onDisplaySettingChanged(state, event, ~) - id = string(event.id); - value = event.value; - if id == "palette" && any(string(value) == ... - ["turbo", "iron", "hot", "parula", "gray"]) - state.project.parameters.palette = string(value); - elseif id == "colorMapping" && any(string(value) == ... - ["Linear", "Log", "Gamma"]) - state.project.parameters.colorMapping = string(value); - elseif id == "gammaValue" - state.project.parameters.gammaValue = ... - flir_thermal.userInterface.normalizeGammaValue(value); - end - state = invalidateResults(state); -end - -function state = onRangePresetChanged(state, event, ~) - item = state.session.cache.currentItem; - if isempty(item) - return; - end - preset = string(event.value); - if ~any(preset == string(flir_thermal.userInterface.rangePresetItems())) - return; - end - bounds = flir_thermal.userInterface.rangeControlBounds( ... - item, preset, item.rangeControlBounds); - item.rangePreset = preset; - item.rangeControlBounds = bounds; - range = clampRangeToBounds(item.displayRange, bounds); - if ~isequaln(range, item.displayRange) - item.displayRange = range; - item.rangeAdjusted = true; - end - state = storeCurrentItem(state, item); - state = invalidateResults(state); -end - -function state = onRangeChanged(state, event, ~) - item = state.session.cache.currentItem; - if isempty(item) - return; - end - range = normalizeRange(item.displayRange); - if string(event.id) == "temperatureMin" - range(1) = finiteScalar(event.value, range(1)); - elseif string(event.id) == "temperatureMax" - range(2) = finiteScalar(event.value, range(2)); - end - item.displayRange = normalizeRange(range); - item.rangeAdjusted = true; - state = storeCurrentItem(state, item); - state = invalidateResults(state); -end - -function state = setRoiMode(state, mode, services) - state.project.parameters.roiMode = string(mode); - state = services.workflow.log(state, "ROI mode: " + ... - flir_thermal.userInterface.roiModeLabel(mode) + ... - ". Drag on the thermal image to set the ROI."); -end - -function state = onTemperaturePointSelected(state, event, services) - item = state.session.cache.currentItem; - if isempty(item) - return; - end - [item, reading] = flir_thermal.analysisRun.withManualPoint( ... - item, event.value); - if ~isfinite(reading.temperatureC) - return; - end - state = storeCurrentItem(state, item); - state = invalidateResults(state); - state = services.workflow.log(state, sprintf( ... - 'Set manual point at x=%.0f, y=%.0f, %.2f C.', ... - reading.x, reading.y, reading.temperatureC)); -end - -function state = onTemperatureRegionSelected(state, event, services) - item = state.session.cache.currentItem; - position = double(event.value(:).'); - if isempty(item) || numel(position) ~= 4 || ~all(isfinite(position)) - return; - end - startPoint = position(1:2); - endPoint = startPoint + position(3:4); - [item, reading] = flir_thermal.analysisRun.withRoiReading( ... - item, state.project.parameters.roiMode, startPoint, endPoint); - if ~isfinite(reading.temperatureC) - return; - end - state = storeCurrentItem(state, item); - state = invalidateResults(state); - state = services.workflow.log(state, "Set " + ... - flir_thermal.userInterface.roiModeLabel( ... - state.project.parameters.roiMode) + " ROI."); -end - -function state = onExportSettingChanged(state, event, ~) - format = upper(string(event.value)); - if any(format == ["PNG", "TIFF", "JPEG"]) - state.project.parameters.exportFormat = format; - state = invalidateResults(state); - end -end - -function state = onChooseOutputFolder(state, ~, services) - [folder, cancelled] = services.dialogs.outputFolder( ... - "Select FLIR thermal export folder", ... - state.project.parameters.outputFolder); - if cancelled - return; - end - state.project.parameters.outputFolder = string(folder); - state = invalidateResults(state); -end - -function state = exportSelection(state, currentOnly, services) - sources = state.project.inputs.sources; - if isempty(sources) - services.dialogs.alert( ... - "Load FLIR radiometric images before exporting.", ... - "Export unavailable"); - return; - end - if currentOnly - index = state.session.selection.currentIndex; - sources = sources(index); - end - try - items = loadSources(sources, state.project.annotations.items); - p = state.project.parameters; - opts = struct("outputFolder", p.outputFolder, ... - "format", p.exportFormat, "palette", p.palette, ... - "colorMapping", p.colorMapping, "gammaValue", p.gammaValue, ... - "range", []); - payload = flir_thermal.resultFiles.writeOutputs(items, opts); - spec = struct(); - spec.Outputs = resultOutputs(payload.results, services); - spec.Inputs = sources; - spec.Parameters = p; - spec.Summary = struct("imageCount", numel(items)); - spec.ManifestName = "flir_thermal.labkit.json"; - [manifestPath, ~] = services.results.writeManifest(p.outputFolder, spec); - catch ME - state = reportFailure(state, "Could not export FLIR images", ME, services); - return; - end - payload.resultManifestPath = string(manifestPath); - state.project.results.lastExport = payload; - state.project.results.resultManifestPath = string(manifestPath); - state = services.workflow.log(state, sprintf( ... - 'Exported %d FLIR image(s): %s', numel(items), payload.manifestPath)); -end - -function state = reloadCurrent(state, services) - sources = state.project.inputs.sources; - index = state.session.selection.currentIndex; - if isempty(sources) || index < 1 || index > numel(sources) - state.session.selection.currentIndex = 0; - state.session.cache.currentItem = []; - return; - end - try - state.session.cache.currentItem = loadSources( ... - sources(index), state.project.annotations.items); - catch ME - state.session.cache.currentItem = []; - state = reportFailure(state, "Could not load FLIR image", ME, services); - end -end - -function state = storeCurrentItem(state, item) - index = state.session.selection.currentIndex; - sourceId = state.project.inputs.sources(index).id; - annotations = state.project.annotations.items; - annotationIndex = find(string({annotations.sourceId}) == string(sourceId), 1); - annotation = flir_thermal.thermalAnnotations.fromItem(item, sourceId); - if isempty(annotationIndex) - annotations(end + 1, 1) = annotation; - else - annotations(annotationIndex) = annotation; - end - state.project.annotations.items = annotations; - state.session.cache.currentItem = item; -end - -function state = storeLoadedItems(state, items) - sources = state.project.inputs.sources; - annotations = state.project.annotations.items; - paths = labkit.ui.runtime.sourcePaths(sources); - for k = 1:numel(items) - index = find(paths == string(items(k).path), 1); - if isempty(index) - continue; - end - sourceId = sources(index).id; - annotation = flir_thermal.thermalAnnotations.fromItem( ... - items(k), sourceId); - annotationIndex = find(string({annotations.sourceId}) == string(sourceId), 1); - annotations(annotationIndex) = annotation; - end - state.project.annotations.items = annotations; - currentIndex = state.session.selection.currentIndex; - if currentIndex >= 1 && currentIndex <= numel(sources) - path = labkit.ui.runtime.sourcePaths(sources(currentIndex)); - itemIndex = find(string({items.path}) == path, 1); - if ~isempty(itemIndex) - state.session.cache.currentItem = items(itemIndex); - end - end -end - -function [items, ok] = loadAllItems(state, services) - ok = false; - items = repmat(flir_thermal.sourceFiles.emptyItem(), 0, 1); - try - items = loadSources(state.project.inputs.sources, ... - state.project.annotations.items); - ok = numel(items) == numel(state.project.inputs.sources); - if ~ok - error('flir_thermal:UnreadableSource', ... - 'One or more registered FLIR sources could not be decoded.'); - end - catch ME - services.diagnostics.report("Could not load FLIR sources", ME); - services.dialogs.alert(ME.message, "Could not load FLIR sources"); - end -end - -function items = loadSources(sources, annotations) - paths = labkit.ui.runtime.sourcePaths(sources); - items = flir_thermal.sourceFiles.readImages(paths); - for k = 1:numel(items) - sourceIndex = find(paths == string(items(k).path), 1); - sourceId = sources(sourceIndex).id; - annotationIndex = find(string({annotations.sourceId}) == string(sourceId), 1); - if ~isempty(annotationIndex) - items(k) = flir_thermal.thermalAnnotations.apply( ... - items(k), annotations(annotationIndex)); - end - end -end - -function item = selectedLoadedItem(state, addedItems, services) - item = []; - index = state.session.selection.currentIndex; - if index < 1 || index > numel(state.project.inputs.sources) - return; - end - path = labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources(index)); - addedIndex = find(string({addedItems.path}) == path, 1); - if ~isempty(addedIndex) - annotation = annotationFor(state.project.annotations.items, ... - state.project.inputs.sources(index).id); - item = flir_thermal.thermalAnnotations.apply( ... - addedItems(addedIndex), annotation); - return; - end - state = reloadCurrent(state, services); - item = state.session.cache.currentItem; -end - -function annotations = reconcileAnnotations(oldAnnotations, sources, addedItems) - annotations = repmat(flir_thermal.thermalAnnotations.empty(), 0, 1); - for k = 1:numel(sources) - sourceId = string(sources(k).id); - oldIndex = find(string({oldAnnotations.sourceId}) == sourceId, 1); - if ~isempty(oldIndex) - annotations(end + 1, 1) = oldAnnotations(oldIndex); - continue; - end - path = labkit.ui.runtime.sourcePaths(sources(k)); - itemIndex = find(string({addedItems.path}) == path, 1); - if ~isempty(itemIndex) - annotations(end + 1, 1) = ... - flir_thermal.thermalAnnotations.fromItem( ... - addedItems(itemIndex), sourceId); - end - end -end - -function annotation = annotationFor(annotations, sourceId) - annotation = []; - index = find(string({annotations.sourceId}) == string(sourceId), 1); - if ~isempty(index) - annotation = annotations(index); - end -end - -function item = setItemRange(item, range, adjusted) - item.displayRange = normalizeRange(range); - item.rangeControlBounds = controlBoundsContaining( ... - item.displayRange, item.rangeControlBounds); - item.rangeAdjusted = logical(adjusted); -end - -function range = autoRangeForItem(item) - values = flir_thermal.userInterface.valueMatrix(item); - values = values(isfinite(values)); - if isempty(values) - range = normalizeRange(item.displayRange); - else - range = normalizeRange([min(values), max(values)]); - end -end - -function range = normalizeRange(value) - range = double(value(:).'); - if numel(range) ~= 2 || ~all(isfinite(range)) - range = [20 40]; - end - range = sort(range); - if range(2) <= range(1) - range(2) = range(1) + 1; - end -end - -function range = clampRangeToBounds(range, bounds) - range = min(bounds(2), max(bounds(1), normalizeRange(range))); - if range(2) <= range(1) - range = normalizeRange(bounds); - end -end - -function bounds = controlBoundsContaining(range, bounds) - range = normalizeRange(range); - bounds = normalizeRange(bounds); - bounds = [min(bounds(1), range(1)), max(bounds(2), range(2))]; -end - -function value = finiteScalar(candidate, fallback) - value = double(candidate); - if isempty(value) || ~isscalar(value) || ~isfinite(value) - value = fallback; - end -end - -function index = selectedIndex(sources, newSourcePaths) - index = double(~isempty(sources)); - paths = labkit.ui.runtime.sourcePaths(sources); - for k = 1:numel(newSourcePaths) - match = find(paths == newSourcePaths(k), 1); - if ~isempty(match) - index = match; - return; - end - end -end - -function message = importReportMessage(report) - message = sprintf('Loaded %d compatible FLIR file(s) and skipped %d.', ... - report.loaded, report.skipped); - names = string({report.failures.name}); - if ~isempty(names) - message = string(message) + newline + "Skipped: " + ... - strjoin(names(1:min(5, numel(names))), ", "); - end -end - -function state = invalidateResults(state) - state.project.results.lastExport = []; - state.project.results.resultManifestPath = ""; -end - -function state = reportFailure(state, titleText, ME, services) - services.diagnostics.report(titleText, ME); - services.dialogs.alert(ME.message, titleText); - state = services.workflow.log(state, titleText + ": " + string(ME.message)); -end - -function outputs = resultOutputs(results, services) - outputs = services.results.emptyOutputs(); - fields = ["thermalImagePath", "colorbarPath", "temperatureCsvPath"]; - roles = ["thermal-image", "temperature-colorbar", "temperature-csv"]; - for k = 1:numel(results) - for j = 1:numel(fields) - path = string(results(k).(fields(j))); - [~, name, extension] = fileparts(path); - outputs(end + 1, 1) = services.results.output( ... - roles(j) + "-" + string(k), roles(j), ... - string(name) + string(extension), mediaType(extension), ... - results(k).status, results(k).message); - end - end -end - -function value = mediaType(extension) - extension = lower(string(extension)); - if extension == ".csv" - value = "text/csv"; - elseif any(extension == [".jpg", ".jpeg"]) - value = "image/jpeg"; - elseif any(extension == [".tif", ".tiff"]) - value = "image/tiff"; - else - value = "image/png"; - end -end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/projectSpec.m b/apps/image_measurement/flir_thermal/+flir_thermal/projectSpec.m index 0fad0e0b6..a4cbe8527 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/projectSpec.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/projectSpec.m @@ -1,17 +1,13 @@ -% App-owned durable FLIR Thermal contract. Runtime V2 calls this single entry +% App-owned durable FLIR Thermal contract. App SDK runtime calls this single entry % for current project creation and validation; version 1 needs no migration. function spec = projectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", []); + spec = labkit.app.project.Schema(Version=1,Create=@createProject,Validate=@validateProject); end function project = createProject() project = struct(); project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + labkit.app.project.emptySourceRecords()); project.parameters = struct( ... "palette", "turbo", ... "colorMapping", "Linear", ... diff --git a/apps/image_measurement/flir_thermal/labkit_FLIRThermal_app.m b/apps/image_measurement/flir_thermal/labkit_FLIRThermal_app.m index b441627c6..49bf744f3 100644 --- a/apps/image_measurement/flir_thermal/labkit_FLIRThermal_app.m +++ b/apps/image_measurement/flir_thermal/labkit_FLIRThermal_app.m @@ -1,6 +1,5 @@ function varargout = labkit_FLIRThermal_app(varargin) %LABKIT_FLIRTHERMAL_APP FLIR radiometric image post-processing app. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @flir_thermal.definition, varargin{:}); + [varargout{1:nargout}] = flir_thermal.definition().launch(varargin{:}); end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/applyPreset.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/applyPreset.m new file mode 100644 index 000000000..ef9561ebc --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/applyPreset.m @@ -0,0 +1,11 @@ +% App-owned implementation for focus_stack.analysisRun.applyPreset within the focus_stack product workflow. +function state = applyPreset(state, ~, context) +%APPLYPRESET Apply the selected named fusion preset and clear its result. +settings = focus_stack.analysisRun.fusionPresetSettings( ... + state.project.parameters.fusionPreset); +state.project.parameters.focusWindow = settings.focusWindow; +state.project.parameters.smoothRadius = settings.smoothRadius; +state.project.parameters.uncertainBlend = settings.minConfidencePercent; +state = focus_stack.analysisRun.invalidate(state, [], context); +context.appendStatus("Fusion preset set to " + state.project.parameters.fusionPreset + "."); +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/fusionPresetSettings.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/fusionPresetSettings.m index d7ebf4e12..e2e226f6b 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/fusionPresetSettings.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/fusionPresetSettings.m @@ -13,7 +13,7 @@ % Side effects: % None. - items = focus_stack.userInterface.fusionPresetItems(); + items = focus_stack.focusPreview.fusionPresetItems(); index = find(items == string(preset), 1); if isempty(index) index = 1; diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/invalidate.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/invalidate.m new file mode 100644 index 000000000..ac7a3b7e8 --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/invalidate.m @@ -0,0 +1,12 @@ +% App-owned implementation for focus_stack.analysisRun.invalidate within the focus_stack product workflow. +function state = invalidate(state, ~, ~) +%INVALIDATE Discard a result after one fusion setting changes. +state.session.cache.alignedImages = {}; +state.session.cache.result = focus_stack.analysisRun.emptyResult(); +state.session.cache.currentFingerprint = ""; +state.project.results.lastRun = focus_stack.analysisRun.emptyResult(); +state.project.results.lastRunFingerprint = ""; +state.project.results.registrationLines = strings(0, 1); +state.project.results.lastExport = []; +state.project.results.resultManifestPath = ""; +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/runFocusStack.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/runFocusStack.m new file mode 100644 index 000000000..7f3d81b29 --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/runFocusStack.m @@ -0,0 +1,53 @@ +% App-owned implementation for focus_stack.analysisRun.runFocusStack within the focus_stack product workflow. +function state = runFocusStack(state, context) +%RUNFOCUSSTACK Compute one deterministic fusion from the rebuilt source cache. +images = state.session.cache.images; +if numel(images) < 2 + context.alert("Load at least two images before running focus stacking.", "Not enough images"); + return; +end +p = state.project.parameters; +options = struct("focusWindow", p.focusWindow, "smoothRadius", p.smoothRadius, ... + "minConfidence", p.uncertainBlend / 100); +paths = context.resolveSourcePaths(state.project.inputs.sources); +task = focus_stack.analysisRun.runTask(paths, images, options, p.autoRegister); +if state.session.cache.result.ok && state.project.results.lastRunFingerprint == task.fingerprint + context.appendStatus("Focus stack result is already up to date."); + return; +end +try + aligned = images; + lines = strings(0, 1); + if p.autoRegister + [aligned, rawLines] = focus_stack.analysisRun.alignImages(images); + lines = string(rawLines(:)); + end + result = focus_stack.analysisRun.computeFocusStack(aligned, options); +catch ME + context.reportError("Focus stacking", ME); + context.alert(ME.message, "Focus stacking failed"); + context.appendStatus("Focus stacking failed: " + string(ME.message)); + return; +end +state.session.cache.alignedImages = aligned; +state.session.cache.result = result; +state.session.cache.currentFingerprint = task.fingerprint; +state.session.workflow.registrationLines = lines; +state.project.results.lastRun = compact(result); +state.project.results.lastRunFingerprint = task.fingerprint; +state.project.results.registrationLines = lines; +state.project.results.lastExport = []; +state.project.results.resultManifestPath = ""; +context.appendStatus(sprintf("Focus stack complete: %d images fused with %s.", result.inputCount, result.method)); +for line = lines(:).' + context.appendStatus(line); +end +end + +function value = compact(value) +for name = ["fused" "focusIndex" "confidence"] + if isfield(value, name) + value = rmfield(value, name); + end +end +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+debug/writeSamplePack.m b/apps/image_measurement/focus_stack/+focus_stack/+debug/writeSamplePack.m index 72be1acc8..6263ad85d 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+debug/writeSamplePack.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+debug/writeSamplePack.m @@ -2,45 +2,44 @@ % is a LabKit debug context. Output is a deterministic synthetic focus-stack % sample pack. Side effects: writes anonymous debug images and records a % session manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Focus Stack debug image files. - - folders = debugFolders(debugLog, "focus_stack"); - imageFolder = fullfile(char(folders.sampleFolder), "images"); - ensureFolder(imageFolder); + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end [base, detailMask] = baseScene(); slicePaths = strings(4, 1); focusCenters = [52 108 164 220]; for k = 1:numel(slicePaths) image = focusSlice(base, detailMask, focusCenters(k)); - slicePaths(k) = string(fullfile(imageFolder, sprintf("focus_slice_%02d.png", k))); + slicePaths(k) = sampleContext.samplePath( ... + sprintf("focus_stack/slice_%02d.png", k)); imwrite(image, char(slicePaths(k))); end - lowTexturePath = string(fullfile(imageFolder, "focus_valid_low_texture.png")); + lowTexturePath = sampleContext.samplePath("focus_stack/low_texture.png"); imwrite(lowTextureImage(), char(lowTexturePath)); - malformedPath = string(fullfile(imageFolder, "focus_malformed_not_image.png")); + malformedPath = sampleContext.samplePath("focus_stack/malformed.png"); writeTextFile(malformedPath, "not an image payload" + newline); - pack = struct( ... - "sampleFolder", folders.sampleFolder, ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", slicePaths, ... - "boundaryFiles", struct( ... - "validEdge", lowTexturePath, ... - "malformed", malformedPath)); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_FocusStack_app", ... - "description", "Anonymous textured focus-stack boundary image pack.", ... - "inputs", struct( ... - "representativeFocusSlices", slicePaths, ... - "validEdgeImage", lowTexturePath, ... - "malformedImage", malformedPath), ... - "outputFolder", folders.outputFolder); - pack.manifest = manifest; - recordManifest(debugLog, manifest); + project = focus_stack.projectSpec().Create(); + artifacts = cell(1, numel(slicePaths) + 2); + for k = 1:numel(slicePaths) + sourceId = "image" + k; + project.inputs.sources(k) = sampleContext.sourceRecord( ... + sourceId, "focus-image", slicePaths(k), true); + artifacts{k} = sampleContext.artifact( ... + sourceId, "focus-image", slicePaths(k)); + end + artifacts{end - 1} = sampleContext.artifact( ... + "lowTexture", "boundaryInput", lowTexturePath); + artifacts{end} = sampleContext.artifact( ... + "malformed", "boundaryInput", malformedPath, ... + Expectation="rejects"); + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-focus-stack", ... + InitialProject=project, Artifacts=artifacts); end function [image, detailMask] = baseScene() @@ -85,30 +84,6 @@ image = uint8(round(255 .* min(max(image, 0), 1))); end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 @@ -118,9 +93,3 @@ function writeTextFile(filepath, text) cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); end - -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/details.m b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/details.m similarity index 92% rename from apps/image_measurement/focus_stack/+focus_stack/+userInterface/details.m rename to apps/image_measurement/focus_stack/+focus_stack/+focusPreview/details.m index d838ee6e6..ce52cfa74 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/details.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/details.m @@ -12,7 +12,7 @@ sprintf('Detail scale: %d px; blend radius: %d px; uncertain blend: %.1f%%', ... result.focusWindow, result.smoothRadius, 100 * result.minConfidence), ... 'Selected pixel coverage by source:'}; - names = focus_stack.userInterface.displayImageNamesForDetails(paths, result.inputCount); + names = focus_stack.focusPreview.displayImageNamesForDetails(paths, result.inputCount); for k = 1:result.inputCount lines{end+1} = sprintf(' %d. %s: %.2f%%', ... k, names{k}, 100 * result.focusCoverage(k)); diff --git a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/displayImageNamesForDetails.m b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/displayImageNamesForDetails.m similarity index 100% rename from apps/image_measurement/focus_stack/+focus_stack/+userInterface/displayImageNamesForDetails.m rename to apps/image_measurement/focus_stack/+focus_stack/+focusPreview/displayImageNamesForDetails.m diff --git a/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/draw.m b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/draw.m new file mode 100644 index 000000000..b83f9a765 --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/draw.m @@ -0,0 +1,25 @@ +% App-owned implementation for focus_stack.focusPreview.draw within the focus_stack product workflow. +function draw(axesById, model) +%DRAW Render the declared fused-image and focus-map axes. +fused = axesById.fused; +map = axesById.focusMap; +cla(fused); cla(map); +title(fused, "Fused all-in-focus image"); +title(map, "Focus-depth index map"); +if model.result.ok + showImage(fused, model.result.fused); + title(fused, "Fused all-in-focus image"); + showImage(map, focus_stack.focusPreview.focusIndexRgb( ... + model.result.focusIndex, model.result.inputCount)); + title(map, "Focus-depth index map"); +elseif ~isempty(model.images) + showImage(fused, model.images{1}); + title(fused, "First source image"); +end +end + +function showImage(axesHandle, imageData) +image(axesHandle, imageData); +axis(axesHandle, "image"); +axis(axesHandle, "off"); +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/focusIndexRgb.m b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/focusIndexRgb.m similarity index 100% rename from apps/image_measurement/focus_stack/+focus_stack/+userInterface/focusIndexRgb.m rename to apps/image_measurement/focus_stack/+focus_stack/+focusPreview/focusIndexRgb.m diff --git a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/fusionPresetItems.m b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/fusionPresetItems.m similarity index 100% rename from apps/image_measurement/focus_stack/+focus_stack/+userInterface/fusionPresetItems.m rename to apps/image_measurement/focus_stack/+focus_stack/+focusPreview/fusionPresetItems.m diff --git a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/initialResultTable.m b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/initialResultTable.m similarity index 100% rename from apps/image_measurement/focus_stack/+focus_stack/+userInterface/initialResultTable.m rename to apps/image_measurement/focus_stack/+focus_stack/+focusPreview/initialResultTable.m diff --git a/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/present.m b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/present.m new file mode 100644 index 000000000..85a6634c1 --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/present.m @@ -0,0 +1,58 @@ +% App-owned implementation for focus_stack.focusPreview.present within the focus_stack product workflow. +function view = present(state) +%PRESENT Build the Focus Stack result summary and paired preview model. +cacheResult = state.session.cache.result; +result = visibleResult(state, cacheResult); +view = labkit.app.view.Snapshot(); +view = view.enabled("exportFused", cacheResult.ok); +view = view.enabled("exportFocusMap", cacheResult.ok); +view = view.enabled("exportSummary", cacheResult.ok); +if result.ok + data = focus_stack.focusPreview.resultTableData(result); + details = focus_stack.focusPreview.details( ... + result, state.session.cache.sourcePaths, ... + cellstr(state.project.results.registrationLines)); + if ~cacheResult.ok + details{end + 1} = ... + "Saved summary restored; rerun to rebuild image previews and exports."; + end + if strlength(state.project.results.resultManifestPath) > 0 + details{end + 1} = ... + "Last manifest: " + state.project.results.resultManifestPath; + end +else + data = focus_stack.focusPreview.initialResultTable(); + details = pendingDetails(numel(state.session.cache.images), ... + numel(state.project.inputs.sources)); +end +view = view.tableData("resultTable", data, Columns=["Metric" "Value"]); +view = view.text("details", strjoin(string(details), newline)); +view = view.renderPlot("preview", struct( ... + "images", {state.session.cache.images}, "result", cacheResult)); +end + +function result = visibleResult(state, cacheResult) +result = cacheResult; +if result.ok + return; +end +durable = state.project.results.lastRun; +if durable.ok && strlength(state.session.cache.currentFingerprint) > 0 && ... + state.session.cache.currentFingerprint == ... + state.project.results.lastRunFingerprint + result = durable; +end +end + +function lines = pendingDetails(imageCount, sourceCount) +if imageCount >= 2 + lines = {sprintf("Loaded images: %d", sourceCount), ... + "Run focus stack to compute the fused image and focus-depth map."}; +elseif sourceCount > 0 + lines = {sprintf("Loaded images: %d", sourceCount), ... + "Load at least two images before running focus stack."}; +else + lines = { ... + "Load a focus image folder or select image files to begin."}; +end +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/previewImage.m b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/previewImage.m similarity index 100% rename from apps/image_measurement/focus_stack/+focus_stack/+userInterface/previewImage.m rename to apps/image_measurement/focus_stack/+focus_stack/+focusPreview/previewImage.m diff --git a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/resultTableData.m b/apps/image_measurement/focus_stack/+focus_stack/+focusPreview/resultTableData.m similarity index 100% rename from apps/image_measurement/focus_stack/+focus_stack/+userInterface/resultTableData.m rename to apps/image_measurement/focus_stack/+focus_stack/+focusPreview/resultTableData.m diff --git a/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportFocusMap.m b/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportFocusMap.m new file mode 100644 index 000000000..d06d4bd21 --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportFocusMap.m @@ -0,0 +1,5 @@ +% App-owned implementation for focus_stack.resultFiles.exportFocusMap within the focus_stack product workflow. +function state = exportFocusMap(state, context) +%EXPORTFOCUSMAP Write the current focus-depth index map as PNG. +state = focus_stack.resultFiles.exportResult(state, "focus-map", context); +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportFused.m b/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportFused.m new file mode 100644 index 000000000..9f6f3bd52 --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportFused.m @@ -0,0 +1,5 @@ +% App-owned implementation for focus_stack.resultFiles.exportFused within the focus_stack product workflow. +function state = exportFused(state, context) +%EXPORTFUSED Write the current fused image as a user-selected PNG. +state = focus_stack.resultFiles.exportResult(state, "fused", context); +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportResult.m b/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportResult.m new file mode 100644 index 000000000..a4c195923 --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportResult.m @@ -0,0 +1,100 @@ +% App-owned implementation for focus_stack.resultFiles.exportResult within the focus_stack product workflow. +function applicationState = exportResult( ... + applicationState, kind, callbackContext) +%EXPORTRESULT Write one Focus Stack output and its standard result manifest. +result = applicationState.session.cache.result; +if ~result.ok + callbackContext.alert( ... + "Run focus stack before exporting results.", "No result"); + return; +end +[defaultName, filters, mediaType] = outputContract(kind); +defaultPath = fullfile(defaultFolder(applicationState), defaultName); +choice = callbackContext.chooseOutputFile(filters, defaultPath); +if choice.Cancelled + callbackContext.appendStatus( ... + "Export " + kind + " cancelled."); + return; +end +filepath = string(choice.Value); +try + writeOutput(kind, result, ... + applicationState.session.cache.sourcePaths, filepath); + [folder, name, extension] = fileparts(filepath); + relativePath = string(name) + string(extension); + output = labkit.app.result.File( ... + replace(kind, "-", "_"), kind, relativePath, ... + MediaType=mediaType); + package = labkit.app.result.Package( ... + Outputs={output}, ... + Inputs=struct( ... + "sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=applicationState.project.results.lastRun, ... + ManifestName="focus_stack.labkit.json"); + written = callbackContext.writeResultPackage(folder, package); +catch ME + callbackContext.reportError("Export Focus Stack result", ME); + callbackContext.alert(ME.message, "Could not export result"); + callbackContext.appendStatus( ... + "Could not export " + kind + ": " + string(ME.message)); + return; +end +applicationState.project.parameters.outputFolder = string(folder); +applicationState.project.results.lastExport = struct( ... + "kind", kind, "outputPath", filepath, ... + "manifestPath", string(written.Value)); +applicationState.project.results.resultManifestPath = string(written.Value); +callbackContext.appendStatus( ... + "Exported " + kind + ": " + filepath); +end + +function [name, filters, mediaType] = outputContract(kind) +switch string(kind) + case "fused" + name = "focus_stack_fused.png"; + filters = ["*.png", "PNG image (*.png)"]; + mediaType = "image/png"; + case "focus-map" + name = "focus_stack_map.png"; + filters = ["*.png", "PNG image (*.png)"]; + mediaType = "image/png"; + case "summary" + name = "focus_stack_summary.csv"; + filters = ["*.csv", "CSV files (*.csv)"]; + mediaType = "text/csv"; + otherwise + error("labkit_FocusStack_app:UnknownExport", ... + "Unknown Focus Stack export kind: %s.", kind); +end +end + +function writeOutput(kind, result, paths, filepath) +switch string(kind) + case "fused" + labkit.image.writeFile(result.fused, filepath); + case "focus-map" + imageData = focus_stack.focusPreview.focusIndexRgb( ... + result.focusIndex, result.inputCount); + labkit.image.writeFile(imageData, filepath); + case "summary" + writetable(focus_stack.resultFiles.buildSummaryTable( ... + result, paths), filepath); +end +end + +function folder = defaultFolder(applicationState) +folder = string(applicationState.project.parameters.outputFolder); +if strlength(folder) > 0 && isfolder(folder) + return; +end +paths = applicationState.session.cache.sourcePaths; +if ~isempty(paths) + candidate = string(fileparts(paths(1))); + if isfolder(candidate) + folder = candidate; + return; + end +end +folder = string(pwd); +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportSummary.m b/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportSummary.m new file mode 100644 index 000000000..924183ccb --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportSummary.m @@ -0,0 +1,5 @@ +% App-owned implementation for focus_stack.resultFiles.exportSummary within the focus_stack product workflow. +function state = exportSummary(state, context) +%EXPORTSUMMARY Write the current fusion summary as CSV. +state = focus_stack.resultFiles.exportResult(state, "summary", context); +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/chooseFolder.m b/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/chooseFolder.m new file mode 100644 index 000000000..1caa470ba --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/chooseFolder.m @@ -0,0 +1,49 @@ +% App-owned implementation for focus_stack.sourceFiles.chooseFolder within the focus_stack product workflow. +function applicationState = chooseFolder(applicationState, callbackContext) +%CHOOSEFOLDER Replace the current stack with supported images from a folder. +startPath = applicationState.project.parameters.outputFolder; +if strlength(startPath) == 0 && ... + ~isempty(applicationState.session.cache.sourcePaths) + startPath = string(fileparts( ... + applicationState.session.cache.sourcePaths(1))); +end +choice = callbackContext.chooseInputFolder(startPath); +if choice.Cancelled + callbackContext.appendStatus( ... + "Focus image folder selection cancelled."); + return; +end +folder = string(choice.Value); +try + paths = focus_stack.sourceFiles.findImages(folder); + images = focus_stack.sourceFiles.readImages(paths); +catch ME + callbackContext.reportError("Load focus image folder", ME); + callbackContext.alert(ME.message, "Could not load focus image folder"); + callbackContext.appendStatus( ... + "Could not load focus image folder: " + string(ME.message)); + return; +end +incoming = labkit.app.project.emptySourceRecords(); +for index = 1:numel(paths) + record = labkit.app.project.sourceRecord( ... + "image_" + compose("%03d", index), ... + "focus-image", paths(index), true); + incoming(end + 1, 1) = record; +end +applicationState.project.inputs.sources = incoming; +applicationState = focus_stack.analysisRun.invalidate( ... + applicationState, [], callbackContext); +applicationState.project.parameters.outputFolder = folder; +applicationState.session.cache.images = images; +applicationState.session.cache.alignedImages = {}; +applicationState.session.cache.result = ... + focus_stack.analysisRun.emptyResult(); +applicationState.session.cache.sourcePaths = paths; +ids = string({applicationState.project.inputs.sources.id}); +applicationState.session.selection.sourceImages = ... + labkit.app.event.ListSelection( ... + Ids=ids, Indices=1:numel(ids)); +callbackContext.appendStatus(sprintf( ... + "Loaded %d focus image file(s) from folder.", numel(paths))); +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/selectionChanged.m b/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/selectionChanged.m new file mode 100644 index 000000000..2fe549532 --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/selectionChanged.m @@ -0,0 +1,38 @@ +% App-owned implementation for focus_stack.sourceFiles.selectionChanged within the focus_stack product workflow. +function applicationState = selectionChanged( ... + applicationState, listSelection, callbackContext) +%SELECTIONCHANGED Reconcile source-derived result and output state. +% Called after the framework updates the portable source list or its visible +% selection. Source replacement rebuilds the decoded session before this +% callback; a selection-only change leaves a current result intact. +paths = applicationState.session.cache.sourcePaths; +if isempty(paths) + if ~isempty(applicationState.project.inputs.sources) + paths = callbackContext.resolveSourcePaths( ... + applicationState.project.inputs.sources); + end +else + paths = string(paths(:)); +end +if strlength(applicationState.project.parameters.outputFolder) == 0 && ... + ~isempty(paths) + applicationState.project.parameters.outputFolder = ... + string(fileparts(paths(1))); +end +if isempty(applicationState.project.results.lastRunFingerprint) + return; +end +parameters = applicationState.project.parameters; +options = struct( ... + "focusWindow", parameters.focusWindow, ... + "smoothRadius", parameters.smoothRadius, ... + "minConfidence", parameters.uncertainBlend / 100); +task = focus_stack.analysisRun.runTask(paths, ... + applicationState.session.cache.images, options, ... + parameters.autoRegister); +if task.fingerprint ~= ... + applicationState.project.results.lastRunFingerprint + applicationState = focus_stack.analysisRun.invalidate( ... + applicationState, listSelection, callbackContext); +end +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m b/apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 8d61a24b0..000000000 --- a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,125 +0,0 @@ -% Expected caller: focus_stack.definition. Inputs are app-owned callback -% handles and initial app state. Output is a data-only UI 5 workbench layout -% for the Focus Stack app. -function layout = buildWorkbenchLayout(callbacks, ~) - fusionPresets = cellstr(focus_stack.userInterface.fusionPresetItems()); - workflowNotes = { ... - '1. Add a folder or selected image files from the same microscope field of view.', ... - '2. Remove selected files 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.'}; - - layout = labkit.ui.layout.workbench('focusStackApp', 'Microscope Focus Stack Fusion', ... - 'controlTabs', controlTabs(fusionPresets, callbacks), ... - 'workspace', previewWorkspace(), ... - 'usageTitle', 'Workflow Notes', ... - 'usage', workflowNotes); -end - -function tabs = controlTabs(fusionPresets, callbacks) - tabs = { ... - filesAnalysisTab(fusionPresets, callbacks), ... - summaryResultsTab(), ... - logTab()}; -end - -function tab = filesAnalysisTab(fusionPresets, callbacks) - tab = labkit.ui.layout.tab('filesAnalysis', 'Files + Analysis', { ... - imagesSection(callbacks), ... - fusionOptionsSection(fusionPresets, callbacks), ... - exportSection(callbacks)}); -end - -function tab = summaryResultsTab() - tab = labkit.ui.layout.tab('summaryResults', 'Summary + Results', { ... - labkit.ui.layout.section('summaryTableSection', 'Summary', { ... - labkit.ui.layout.resultTable('resultTable', 'Summary', ... - 'columns', {'Metric', 'Value'}, ... - 'data', focus_stack.userInterface.initialResultTable())}), ... - labkit.ui.layout.section('detailsSection', 'Details', { ... - labkit.ui.layout.statusPanel('details', 'Details', ... - 'value', {'Load a focus image folder or select image files to begin.'})})}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab('log', 'Log', { ... - labkit.ui.layout.section('logSection', 'Log', { ... - labkit.ui.layout.logPanel('logPanel', 'Log')})}); -end - -function section = imagesSection(callbacks) - section = labkit.ui.layout.section('imagesSection', 'Images', { ... - labkit.ui.layout.field('sourceLocation', 'Source', ... - 'kind', 'readonly', ... - 'value', 'No images loaded'), ... - labkit.ui.layout.filePanel('sourceImages', 'Focus images', ... - 'selectionMode', 'single', ... - 'chooseLabel', 'Add images or folder', ... - 'clearLabel', 'Clear images', ... - 'filters', labkit.image.fileDialogFilter(), ... - 'status', 'No images loaded', ... - 'emptyText', 'No images loaded', ... - 'onChoose', callbacks.sourceImagesChosen, ... - 'onRemove', callbacks.removeImages, ... - 'onClear', callbacks.clearImages), ... - labkit.ui.layout.action('sourceFolderChosen', 'Choose folder', ... - callbacks.sourceFolderChosen)}); -end - -function section = fusionOptionsSection(fusionPresets, callbacks) - section = labkit.ui.layout.section('fusionOptionsSection', 'Fusion Options', { ... - labkit.ui.layout.field('fusionPreset', 'Preset', ... - 'kind', 'dropdown', ... - 'items', fusionPresets, ... - 'value', fusionPresets{1}, ... - 'Bind', 'project.parameters.fusionPreset', ... - 'Event', 'fusionPresetChanged'), ... - labkit.ui.layout.field('autoRegister', ... - 'Auto-register stack to middle image', ... - 'kind', 'checkbox', ... - 'value', false, ... - 'Bind', 'project.parameters.autoRegister', ... - 'Event', 'fusionOptionsChanged'), ... - labkit.ui.layout.panner('focusWindow', 'Detail scale (px)', ... - 'value', 31, ... - 'limits', [3 99], ... - 'step', 2, ... - 'Bind', 'project.parameters.focusWindow', ... - 'Event', 'fusionOptionsChanged'), ... - labkit.ui.layout.panner('smoothRadius', 'Blend radius (px)', ... - 'value', 4, ... - 'limits', [0 50], ... - 'step', 1, ... - 'Bind', 'project.parameters.smoothRadius', ... - 'Event', 'fusionOptionsChanged'), ... - labkit.ui.layout.panner('uncertainBlend', 'Uncertain blend (%)', ... - 'value', 5, ... - 'limits', [0 100], ... - 'step', 1, ... - 'Bind', 'project.parameters.uncertainBlend', ... - 'Event', 'fusionOptionsChanged'), ... - labkit.ui.layout.action('runFocusStack', 'Run focus stack', ... - callbacks.runFocusStack, ... - 'enabled', false)}); -end - -function section = exportSection(callbacks) - section = labkit.ui.layout.section('exportSection', 'Export', { ... - labkit.ui.layout.action('exportFused', 'Export fused PNG', ... - callbacks.exportFused, ... - 'enabled', false), ... - labkit.ui.layout.action('exportFocusMap', 'Export focus map PNG', ... - callbacks.exportFocusMap, ... - 'enabled', false), ... - labkit.ui.layout.action('exportSummary', 'Export summary CSV', ... - callbacks.exportSummary, ... - 'enabled', false)}); -end - -function workspace = previewWorkspace() - workspace = labkit.ui.layout.workspace('workspace', 'Focus Stack Preview', { ... - labkit.ui.layout.previewArea('preview', 'Focus Stack Preview', ... - 'layout', 'pair', ... - 'axisIds', {'fused', 'focusMap'}, ... - 'axisTitles', {'Fused all-in-focus image', 'Focus-depth index map'})}); -end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m b/apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m deleted file mode 100644 index 1abe8cb78..000000000 --- a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,122 +0,0 @@ -% Expected caller: the LabKit V2 runtime. Input is canonical Focus Stack -% state. Output is deterministic controls and paired image previews. -function view = presentWorkbench(state) - sources = state.project.inputs.sources; - images = state.session.cache.images; - cacheResult = state.session.cache.result; - durableResult = state.project.results.lastRun; - hasSources = ~isempty(sources); - hasStack = numel(images) >= 2; - hasResult = cacheResult.ok; - - view = struct(); - view.controls.sourceLocation = valueSpec(sourceDescription(sources)); - view.controls.sourceImages = fileSpec(sources); - view.controls.runFocusStack = enabledSpec(hasStack); - view.controls.exportFused = enabledSpec(hasResult); - view.controls.exportFocusMap = enabledSpec(hasResult); - view.controls.exportSummary = enabledSpec(hasResult); - view.controls.resultTable = dataSpec(resultTableData( ... - cacheResult, durableResult)); - view.controls.details = valueSpec(detailLines(state, hasSources, hasStack)); - [fusedModel, mapModel] = previewModels(images, cacheResult); - view.previews.preview.Axes.fused = struct( ... - "Renderer", "focusImage", "Model", fusedModel); - view.previews.preview.Axes.focusMap = struct( ... - "Renderer", "focusImage", "Model", mapModel); -end - -function spec = fileSpec(sources) - files = repmat(struct("id", "", "path", "", "status", "ready"), ... - numel(sources), 1); - paths = labkit.ui.runtime.sourcePaths(sources); - for k = 1:numel(sources) - files(k).id = string(sources(k).id); - files(k).path = paths(k); - end - status = "No images loaded"; - if ~isempty(sources) - status = string(numel(sources)) + " image(s)"; - end - spec = struct("Files", files, "Status", status); -end - -function text = sourceDescription(sources) - if isempty(sources) - text = "No images loaded"; - return; - end - folder = string(fileparts(labkit.ui.runtime.sourcePaths(sources(1)))); - text = "Selected image files from " + folder; -end - -function data = resultTableData(cacheResult, durableResult) - if cacheResult.ok - data = focus_stack.userInterface.resultTableData(cacheResult); - elseif durableResult.ok - data = focus_stack.userInterface.resultTableData(durableResult); - else - data = focus_stack.userInterface.initialResultTable(); - end -end - -function lines = detailLines(state, hasSources, hasStack) - result = state.session.cache.result; - if ~result.ok - result = state.project.results.lastRun; - end - if result.ok - lines = focus_stack.userInterface.details(result, ... - labkit.ui.runtime.sourcePaths(state.project.inputs.sources), ... - cellstr(state.project.results.registrationLines)); - if ~state.session.cache.result.ok - lines{end + 1} = ... - 'Saved summary restored; rerun to rebuild image previews and exports.'; - end - if strlength(state.project.results.resultManifestPath) > 0 - lines{end + 1} = "Last manifest: " + ... - state.project.results.resultManifestPath; - end - elseif hasStack - lines = {sprintf('Loaded images: %d', ... - numel(state.project.inputs.sources)), ... - 'Run focus stack to compute the fused image and focus-depth map.'}; - elseif hasSources - lines = {sprintf('Loaded images: %d', ... - numel(state.project.inputs.sources)), ... - 'Load at least two images before running focus stack.'}; - else - lines = {'Load a focus image folder or select image files to begin.'}; - end - lines = cellstr(string(lines)); -end - -function [fused, map] = previewModels(images, result) - fused = imageModel([], "Fused all-in-focus image"); - map = imageModel([], "Focus-depth index map"); - if result.ok - fused.imageData = result.fused; - map.imageData = focus_stack.userInterface.focusIndexRgb( ... - result.focusIndex, result.inputCount); - elseif ~isempty(images) - fused.imageData = focus_stack.userInterface.previewImage(images{1}); - fused.title = "First source image"; - end -end - -function model = imageModel(imageData, titleText) - model = struct("imageData", imageData, "title", string(titleText)); -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = dataSpec(value) - spec = struct("Data", {value}); -end - -function spec = enabledSpec(value) - spec = struct("Enabled", logical(value)); -end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/renderFocusImage.m b/apps/image_measurement/focus_stack/+focus_stack/+userInterface/renderFocusImage.m deleted file mode 100644 index b96de0485..000000000 --- a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/renderFocusImage.m +++ /dev/null @@ -1,22 +0,0 @@ -% Expected caller: the registered Focus Stack V2 renderer. Inputs are target -% axes and a prepared image/title model. Side effects are limited to the axes. -function renderFocusImage(ax, model) - labkit.ui.plot.clear(ax, "ResetScale", true); - if isempty(model.imageData) - title(ax, char(model.title)); - box(ax, 'on'); - return; - end - if ndims(model.imageData) == 2 - imagesc(ax, model.imageData); - colormap(ax, gray(256)); - else - image(ax, model.imageData); - end - axis(ax, 'image'); - ax.YDir = 'reverse'; - title(ax, char(model.title)); - xlabel(ax, ''); - ylabel(ax, ''); - box(ax, 'on'); -end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m b/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m new file mode 100644 index 000000000..dd4527119 --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m @@ -0,0 +1,73 @@ +% App-owned implementation for focus_stack.workbench.buildLayout within the focus_stack product workflow. +function layout = buildLayout() +%BUILDLAYOUT Declare the Focus Stack workbench and its direct callbacks. +files = labkit.app.layout.fileList("sourceImages", ... + Label="Focus images", Filters=labkit.image.fileDialogFilter(), ... + SelectionMode="multiple", Bind="project.inputs.sources", ... + SelectionBind="session.selection.sourceImages", SourceRole="focus-image", ... + SourceIdPrefix="image", Required=true, ... + ChooseLabel="Add images or folder", FolderLabel="Add folder", ... + ChooseTooltip="Add a z-stack of images from the same field of view at different focal planes.", ... + RecursiveFolderLabel="Add folder tree", ... + RemoveLabel="Remove selected", ClearLabel="Clear images", ... + EmptyText="No images loaded", ... + OnSelectionChanged=@focus_stack.sourceFiles.selectionChanged); +controls = { ... + labkit.app.layout.tab("filesAnalysis", "Files + Analysis", { ... + labkit.app.layout.section("imagesSection", "Images", { ... + labkit.app.layout.field("sourceLocation", ... + Label="Source", Kind="readonly", Value="No images loaded"), ... + files, ... + labkit.app.layout.button("sourceFolderChosen", ... + "Choose folder", @focus_stack.sourceFiles.chooseFolder, ... + Tooltip="Replace the source stack with supported images discovered in one folder.")}), ... + labkit.app.layout.section("fusionOptions", "Fusion Options", { ... + labkit.app.layout.field("fusionPreset", Label="Preset", Kind="choice", ... + Choices=focus_stack.workbench.fusionPresets(), ... + Bind="project.parameters.fusionPreset", ... + OnValueChanged=@focus_stack.analysisRun.applyPreset), ... + labkit.app.layout.field("autoRegister", ... + Label="Auto-register stack to middle image", ... + Kind="logical", Bind="project.parameters.autoRegister", ... + OnValueChanged=@focus_stack.analysisRun.invalidate), ... + labkit.app.layout.slider("focusWindow", Label="Detail scale (px)", ... + Limits=[3 99], Step=2, Bind="project.parameters.focusWindow", ... + OnValueChanged=@focus_stack.analysisRun.invalidate), ... + labkit.app.layout.slider("smoothRadius", Label="Blend radius (px)", ... + Limits=[0 50], Step=1, Bind="project.parameters.smoothRadius", ... + OnValueChanged=@focus_stack.analysisRun.invalidate), ... + labkit.app.layout.slider("uncertainBlend", Label="Uncertain blend (%)", ... + Limits=[0 100], Step=1, Bind="project.parameters.uncertainBlend", ... + OnValueChanged=@focus_stack.analysisRun.invalidate), ... + labkit.app.layout.button("runFocusStack", "Run focus stack", ... + @focus_stack.analysisRun.runFocusStack, ... + Tooltip="Estimate local focus across the registered stack and blend the sharpest source regions into one image.")}), ... + labkit.app.layout.section("exports", "Export", { ... + labkit.app.layout.button("exportFused", "Export fused PNG", @focus_stack.resultFiles.exportFused, ... + Tooltip="Export the all-in-focus composite generated from the current stack and fusion settings."), ... + labkit.app.layout.button("exportFocusMap", "Export focus map PNG", @focus_stack.resultFiles.exportFocusMap, ... + Tooltip="Export the source-depth index selected at each pixel by the focus estimator."), ... + labkit.app.layout.button("exportSummary", "Export summary CSV", @focus_stack.resultFiles.exportSummary, ... + Tooltip="Export focus-stack dimensions, registration, blend settings, and source-use summary.")})}), ... + labkit.app.layout.tab("summaryResults", "Summary + Results", { ... + labkit.app.layout.section("summaryTableSection", "Summary", { ... + labkit.app.layout.dataTable("resultTable", ... + Title="Summary", Columns=["Metric" "Value"])}), ... + labkit.app.layout.section("detailsSection", "Details", { ... + labkit.app.layout.statusPanel("details", Title="Details")})}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("logPanel", Title="Log")})})}; +workspace = labkit.app.layout.workspace(labkit.app.layout.plotArea("preview", ... + @focus_stack.focusPreview.draw, Title="Focus Stack Preview", ... + Layout="pair", AxisIds=["fused" "focusMap"], ... + AxisTitles=["Fused all-in-focus image" "Focus-depth index map"]), ... + Title="Focus Stack Preview"); +workflowNotes = [ ... + "1. Add a folder or selected image files from the same microscope field of view." + "2. Remove selected files 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."]; +layout = labkit.app.layout.workbench(controls, Workspace=workspace, ... + UsageTitle="Workflow Notes", Usage=workflowNotes); +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+workbench/fusionPresets.m b/apps/image_measurement/focus_stack/+focus_stack/+workbench/fusionPresets.m new file mode 100644 index 000000000..16c32d08b --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+workbench/fusionPresets.m @@ -0,0 +1,4 @@ +% App-owned implementation for focus_stack.workbench.fusionPresets within the focus_stack product workflow. +function values = fusionPresets() +values = ["Balanced" "Crisp" "Smooth" "Noisy"]; +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+workbench/present.m b/apps/image_measurement/focus_stack/+focus_stack/+workbench/present.m new file mode 100644 index 000000000..4b272f3f7 --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+workbench/present.m @@ -0,0 +1,19 @@ +% App-owned implementation for focus_stack.workbench.present within the focus_stack product workflow. +function view = present(state) +%PRESENT Map Focus Stack state to a complete semantic workbench snapshot. +hasStack = numel(state.session.cache.images) >= 2; +view = labkit.app.view.Snapshot(); +view = view.enabled("runFocusStack", hasStack); +view = view.value("sourceLocation", sourceDescription( ... + state.session.cache.sourcePaths)); +view = view.include(focus_stack.focusPreview.present(state)); +end + +function text = sourceDescription(paths) +if isempty(paths) + text = "No images loaded"; + return; +end +text = "Selected image files from " + ... + string(fileparts(string(paths(1)))); +end diff --git a/apps/image_measurement/focus_stack/+focus_stack/createSession.m b/apps/image_measurement/focus_stack/+focus_stack/createSession.m index 41bbaf577..aa397babe 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/createSession.m +++ b/apps/image_measurement/focus_stack/+focus_stack/createSession.m @@ -1,19 +1,38 @@ % Rebuild transient decoded images and full result caches from one validated -% Focus Stack project. Runtime V2 calls this after source relinking. -function session = createSession(project) - images = loadImages(project.inputs.sources); +% Focus Stack project. App SDK runtime calls this after source relinking. +function session = createSession(project, context) + [images, sourcePaths] = loadImages(project.inputs.sources, context); + currentFingerprint = fingerprint(project.parameters, sourcePaths, images); session = struct( ... "workflow", struct("registrationLines", strings(0, 1)), ... + "selection", struct("sourceImages", labkit.app.event.ListSelection()), ... "cache", struct( ... "images", {images}, ... + "sourcePaths", sourcePaths, ... + "currentFingerprint", currentFingerprint, ... "alignedImages", {{}}, ... "result", focus_stack.analysisRun.emptyResult())); end -function images = loadImages(sources) +function [images, paths] = loadImages(sources, context) images = {}; + paths = strings(0, 1); if isempty(sources) return; end - images = focus_stack.sourceFiles.readImages( ... - labkit.ui.runtime.sourcePaths(sources)); + paths = context.resolveSourcePaths(sources); + images = focus_stack.sourceFiles.readImages(paths); +end + +function value = fingerprint(parameters, paths, images) +value = ""; +if numel(images) < 2 + return; +end +options = struct( ... + "focusWindow", parameters.focusWindow, ... + "smoothRadius", parameters.smoothRadius, ... + "minConfidence", parameters.uncertainBlend / 100); +task = focus_stack.analysisRun.runTask( ... + paths, images, options, parameters.autoRegister); +value = task.fingerprint; end diff --git a/apps/image_measurement/focus_stack/+focus_stack/definition.m b/apps/image_measurement/focus_stack/+focus_stack/definition.m index ba7ba183d..d03c51353 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/definition.m +++ b/apps/image_measurement/focus_stack/+focus_stack/definition.m @@ -1,23 +1,14 @@ % App-owned runtime definition for labkit_FocusStack_app. Expected caller: % the public app entrypoint. Output is a declarative LabKit app definition; % side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_FocusStack_app", ... - "Id", "focus_stack", ... - "Title", "Microscope Focus Stack Fusion", ... - "DisplayName", "Focus Stack", ... - "Family", "Image Measurement", ... - "AppVersion", "1.5.6", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "image", ">=2.0 <3"), ... - "Project", focus_stack.projectSpec(), ... - "CreateSession", @focus_stack.createSession, ... - "Layout", @focus_stack.userInterface.buildWorkbenchLayout, ... - "Actions", focus_stack.definitionActions(), ... - "Present", @focus_stack.userInterface.presentWorkbench, ... - "Renderers", struct("focusImage", ... - @focus_stack.userInterface.renderFocusImage), ... - "DebugSample", @focus_stack.debug.writeSamplePack); +function app = definition() + app = labkit.app.Definition( ... + Entrypoint="labkit_FocusStack_app", AppId="focus_stack", ... + Title="Microscope Focus Stack Fusion", DisplayName="Focus Stack", ... + Family="Image Measurement", AppVersion="1.6.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2", "image", ">=2.0 <3"), ... + ProjectSchema=focus_stack.projectSpec(), CreateSession=@focus_stack.createSession, ... + Workbench=focus_stack.workbench.buildLayout(), ... + PresentWorkbench=@focus_stack.workbench.present, ... + BuildDebugSample=@focus_stack.debug.writeSamplePack); end diff --git a/apps/image_measurement/focus_stack/+focus_stack/definitionActions.m b/apps/image_measurement/focus_stack/+focus_stack/definitionActions.m deleted file mode 100644 index e5605dbf4..000000000 --- a/apps/image_measurement/focus_stack/+focus_stack/definitionActions.m +++ /dev/null @@ -1,307 +0,0 @@ -% App-owned V2 actions for Focus Stack. Handlers own source registration, -% fusion, compact durable results, session caches, and exports without UI access. -function actions = definitionActions() - actions = struct( ... - "sourceImagesChosen", @onOpenFilesChosen, ... - "sourceFolderChosen", @onSourceFolderChosen, ... - "removeImages", @onRemoveImages, ... - "clearImages", @onClearImages, ... - "fusionPresetChanged", @onFusionPresetChanged, ... - "fusionOptionsChanged", @onFusionOptionsChanged, ... - "runFocusStack", @onRunFocusStack, ... - "exportFused", @onExportFused, ... - "exportFocusMap", @onExportFocusMap, ... - "exportSummary", @onExportSummary); -end - -function state = onSourceFolderChosen(state, ~, services) - [folder, cancelled] = services.dialogs.inputFolder( ... - "Choose focus image folder", sourceFolder(state)); - if cancelled - state = services.workflow.log(state, ... - "Focus image folder selection cancelled."); - return; - end - try - paths = focus_stack.sourceFiles.findImages(folder); - catch ME - state = reportFailure(state, services, ... - "Could not load focus image folder", ME); - return; - end - state = registerPaths(state, paths, folder, services, sprintf( ... - 'Loaded %d focus image file(s) from folder.', numel(paths))); -end - -function state = onOpenFilesChosen(state, event, services) - paths = services.events.paths(event, "files"); - if isempty(paths) - paths = services.events.paths(event, "addedFiles"); - end - if isempty(paths) - state = services.workflow.log(state, "Image selection cancelled."); - return; - end - state = registerPaths(state, paths, fileparts(paths(1)), services, ... - sprintf('Loaded %d focus image file(s).', numel(paths))); -end - -function state = registerPaths(state, paths, folder, services, message) - oldSources = state.project.inputs.sources; - sources = services.project.reconcileSources( ... - oldSources, paths, "focus-image", "image", true); - try - images = focus_stack.sourceFiles.readImages( ... - labkit.ui.runtime.sourcePaths(sources)); - catch ME - state = reportFailure(state, services, "Could not load focus stack", ME); - return; - end - state.project.inputs.sources = sources; - state.project.parameters.outputFolder = string( ... - services.dialogs.defaultOutputFolder(string(folder), ... - "focus_stack", state.project.parameters.outputFolder)); - state.session.cache.images = images; - state = invalidateRun(state); - state = services.workflow.log(state, message); -end - -function state = onRemoveImages(state, event, services) - sources = state.project.inputs.sources; - indices = services.events.indices(event, "removedFiles", numel(sources)); - if isempty(indices) - return; - end - sources(indices) = []; - if numel(sources) < 2 - state = clearSources(state); - state = services.workflow.log(state, ... - "At least two focus image files are required; cleared the stack."); - return; - end - paths = labkit.ui.runtime.sourcePaths(sources); - state = registerPaths(state, paths, ... - fileparts(paths(1)), services, sprintf( ... - 'Removed image file(s); %d remaining.', numel(sources))); -end - -function state = onClearImages(state, ~, services) - state = clearSources(state); - state = services.workflow.log(state, ... - "Cleared loaded focus images and results."); -end - -function state = clearSources(state) - state.project.inputs.sources = state.project.inputs.sources([]); - state.session.cache.images = {}; - state = invalidateRun(state); -end - -function state = onFusionPresetChanged(state, ~, services) - preset = state.project.parameters.fusionPreset; - settings = focus_stack.analysisRun.fusionPresetSettings(preset); - state.project.parameters.focusWindow = settings.focusWindow; - state.project.parameters.smoothRadius = settings.smoothRadius; - state.project.parameters.uncertainBlend = settings.minConfidencePercent; - state = invalidateRun(state); - state = services.workflow.log(state, "Fusion preset set to " + preset + "."); -end - -function state = onFusionOptionsChanged(state, ~, ~) - state.project.parameters = normalizeParameters(state.project.parameters); - state = invalidateRun(state); -end - -function state = onRunFocusStack(state, ~, services) - images = state.session.cache.images; - if numel(images) < 2 - services.dialogs.alert( ... - "Load at least two images before running focus stacking.", ... - "Not enough images"); - return; - end - p = normalizeParameters(state.project.parameters); - state.project.parameters = p; - opts = fusionOptions(p); - paths = labkit.ui.runtime.sourcePaths(state.project.inputs.sources); - task = focus_stack.analysisRun.runTask( ... - paths, images, opts, p.autoRegister); - if state.session.cache.result.ok && ... - state.project.results.lastRunFingerprint == task.fingerprint - state = services.workflow.log(state, ... - "Focus stack result is already up to date; skipped duplicate run."); - return; - end - try - [imagesForFusion, registrationLines] = prepareImages( ... - images, p.autoRegister); - result = focus_stack.analysisRun.computeFocusStack(imagesForFusion, opts); - catch ME - state = reportFailure(state, services, "Focus stacking failed", ME); - return; - end - state.session.cache.alignedImages = imagesForFusion; - state.session.cache.result = result; - state.session.workflow.registrationLines = string(registrationLines(:)); - state.project.results.lastRun = compactResult(result); - state.project.results.lastRunFingerprint = task.fingerprint; - state.project.results.registrationLines = string(registrationLines(:)); - state.project.results.lastExport = []; - state.project.results.resultManifestPath = ""; - state = services.workflow.log(state, sprintf( ... - 'Focus stack complete: %d images fused with %s.', ... - result.inputCount, result.method)); - for line = string(registrationLines(:)).' - state = services.workflow.log(state, line); - end -end - -function [images, lines] = prepareImages(images, autoRegister) - lines = strings(0, 1); - if autoRegister - [images, rawLines] = focus_stack.analysisRun.alignImages(images); - lines = string(rawLines(:)); - end -end - -function state = onExportFused(state, ~, services) - state = exportResult(state, services, "fused", ... - "Export fused PNG", "focus_stack_fused.png"); -end - -function state = onExportFocusMap(state, ~, services) - state = exportResult(state, services, "focus-map", ... - "Export focus map PNG", "focus_stack_map.png"); -end - -function state = onExportSummary(state, ~, services) - state = exportResult(state, services, "summary", ... - "Export summary CSV", "focus_stack_summary.csv"); -end - -function state = exportResult(state, services, kind, titleText, defaultName) - result = state.session.cache.result; - if ~result.ok - services.dialogs.alert( ... - "Run focus stack before exporting results.", "No result"); - return; - end - defaultPath = fullfile(char(defaultOutputFolder(state, services)), defaultName); - [filepath, cancelled] = services.dialogs.outputFile( ... - {'*.png;*.csv', 'Export files'}, titleText, defaultPath); - if cancelled - state = services.workflow.log(state, titleText + " cancelled."); - return; - end - try - mediaType = writeOutput(kind, result, ... - labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources), filepath); - [~, outputName, outputExtension] = fileparts(filepath); - output = services.results.output(kind, kind, ... - string(outputName) + string(outputExtension), mediaType); - folder = string(fileparts(filepath)); - spec = struct( ... - "Outputs", output, ... - "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, ... - "Summary", compactResult(result), ... - "ManifestName", "focus_stack.labkit.json"); - [manifestPath, ~] = services.results.writeManifest(folder, spec); - catch ME - state = reportFailure(state, services, "Could not export result", ME); - return; - end - state.project.results.lastExport = struct( ... - "kind", string(kind), "outputPath", string(filepath), ... - "manifestPath", string(manifestPath)); - state.project.results.resultManifestPath = string(manifestPath); - state = services.workflow.log(state, ... - "Exported " + kind + ": " + string(filepath)); -end - -function mediaType = writeOutput(kind, result, paths, filepath) - switch kind - case "fused" - labkit.image.writeFile(result.fused, filepath); - mediaType = "image/png"; - case "focus-map" - imageData = focus_stack.userInterface.focusIndexRgb( ... - result.focusIndex, result.inputCount); - labkit.image.writeFile(imageData, filepath); - mediaType = "image/png"; - otherwise - writetable(focus_stack.resultFiles.buildSummaryTable( ... - result, paths), filepath); - mediaType = "text/csv"; - end -end - -function state = invalidateRun(state) - state.session.cache.alignedImages = {}; - state.session.cache.result = focus_stack.analysisRun.emptyResult(); - state.session.workflow.registrationLines = strings(0, 1); - state.project.results.lastRun = focus_stack.analysisRun.emptyResult(); - state.project.results.lastRunFingerprint = ""; - state.project.results.registrationLines = strings(0, 1); - state.project.results.lastExport = []; - state.project.results.resultManifestPath = ""; -end - -function result = compactResult(result) - for field = ["fused", "focusIndex", "confidence"] - name = char(field); - if isfield(result, name) - result = rmfield(result, name); - end - end -end - -function opts = fusionOptions(parameters) - opts = struct( ... - "focusWindow", parameters.focusWindow, ... - "smoothRadius", parameters.smoothRadius, ... - "minConfidence", parameters.uncertainBlend / 100); -end - -function parameters = normalizeParameters(parameters) - parameters.focusWindow = finiteScalar( ... - parameters.focusWindow, 31, 3, 99, true); - parameters.smoothRadius = finiteScalar( ... - parameters.smoothRadius, 4, 0, 50, true); - parameters.uncertainBlend = finiteScalar( ... - parameters.uncertainBlend, 5, 0, 100, false); - parameters.autoRegister = logical(parameters.autoRegister); -end - -function value = finiteScalar(value, fallback, minValue, maxValue, roundValue) - value = double(value); - if isempty(value) || ~isscalar(value) || ~isfinite(value) - value = fallback; - end - value = min(max(value, minValue), maxValue); - if roundValue - value = round(value); - end -end - -function folder = sourceFolder(state) - paths = labkit.ui.runtime.sourcePaths(state.project.inputs.sources); - folder = ""; - if ~isempty(paths) - folder = string(fileparts(paths(1))); - end -end - -function folder = defaultOutputFolder(state, services) - folder = string(state.project.parameters.outputFolder); - if strlength(folder) == 0 || exist(char(folder), 'dir') ~= 7 - folder = services.dialogs.defaultFolder("output"); - end -end - -function state = reportFailure(state, services, context, exception) - services.diagnostics.report(context, exception); - services.dialogs.alert(exception.message, context); - state = services.workflow.log(state, context + ": " + exception.message); -end diff --git a/apps/image_measurement/focus_stack/+focus_stack/projectSpec.m b/apps/image_measurement/focus_stack/+focus_stack/projectSpec.m index bde5d831a..f8d7f79cb 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/projectSpec.m +++ b/apps/image_measurement/focus_stack/+focus_stack/projectSpec.m @@ -1,16 +1,13 @@ -% App-owned durable Focus Stack contract. Runtime V2 calls this single entry +% App-owned durable Focus Stack contract. App SDK runtime calls this single entry % for current project creation and validation; version 1 needs no migration. function spec = projectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", []); + spec = labkit.app.project.Schema(Version=1, ... + Create=@createProject, Validate=@validateProject); end function project = createProject() project = struct(); project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + labkit.app.project.emptySourceRecords()); project.parameters = struct( ... "fusionPreset", "Balanced", ... "autoRegister", false, ... diff --git a/apps/image_measurement/focus_stack/labkit_FocusStack_app.m b/apps/image_measurement/focus_stack/labkit_FocusStack_app.m index 987bf2431..56ff546e8 100644 --- a/apps/image_measurement/focus_stack/labkit_FocusStack_app.m +++ b/apps/image_measurement/focus_stack/labkit_FocusStack_app.m @@ -1,6 +1,5 @@ function varargout = labkit_FocusStack_app(varargin) %LABKIT_FOCUSSTACK_APP Fuse a focus image stack into one all-in-focus image. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @focus_stack.definition, varargin{:}); + [varargout{1:nargout}] = focus_stack.definition().launch(varargin{:}); end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/activeSteps.m b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/activeSteps.m index 3084f2392..da7a50a33 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/activeSteps.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/activeSteps.m @@ -1,10 +1,16 @@ % Return the shared or selected per-image enhancement history from App state. function steps = activeSteps(state) - if state.project.parameters.batchMode || ... - isempty(state.project.annotations.items) + if state.project.parameters.batchMode steps = state.project.annotations.sharedSteps; - else - index = state.session.selection.currentIndex; - steps = state.project.annotations.items(index).steps; + return; end + index = state.session.selection.currentIndex; + sources = state.project.inputs.sources; + if index < 1 || index > numel(sources) + steps = repmat(image_enhance.analysisRun.emptyStep(), 0, 1); + return; + end + annotation = image_enhance.sourceLibrary.annotationForSource( ... + state.project.annotations.items, sources(index).id); + steps = annotation.steps; end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyPipeline.m b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyPipeline.m index b6d7f4f17..6c33a3f5e 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyPipeline.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyPipeline.m @@ -24,7 +24,7 @@ % it. Default: one empty context per image. % % Step Fields: -% kind - One label returned by image_enhance.userInterface.toolKinds: +% kind - One label returned by image_enhance.imagePreview.presentationData.toolKinds: % "Brightness/contrast", "Local contrast", "Sharpen", % "Hue/saturation", "White balance", "White ROI calibration", or % "Subject-preserving enhance". diff --git a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/setActiveSteps.m b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/setActiveSteps.m index 48b923058..c3f9a3a3b 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/setActiveSteps.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/setActiveSteps.m @@ -1,10 +1,18 @@ % Replace the shared or selected per-image enhancement history in App state. function state = setActiveSteps(state, steps) - if state.project.parameters.batchMode || ... - isempty(state.project.annotations.items) + if state.project.parameters.batchMode state.project.annotations.sharedSteps = steps; - else - index = state.session.selection.currentIndex; - state.project.annotations.items(index).steps = steps; + return; end + index = state.session.selection.currentIndex; + sources = state.project.inputs.sources; + if index < 1 || index > numel(sources) + return; + end + annotation = image_enhance.sourceLibrary.annotationForSource( ... + state.project.annotations.items, sources(index).id); + annotation.steps = steps; + state.project.annotations.items = ... + image_enhance.sourceLibrary.storeAnnotation( ... + state.project.annotations.items, annotation); end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+debug/writeAndLogSamplePack.m b/apps/image_measurement/image_enhance/+image_enhance/+debug/writeAndLogSamplePack.m index 3775112c0..85303ebae 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+debug/writeAndLogSamplePack.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+debug/writeAndLogSamplePack.m @@ -1,4 +1,4 @@ -% Expected caller: image_enhance.definitionActions during debug launch. Inputs are a debug +% Expected caller: image_enhance.definition during debug launch. Inputs are a debug % context and app log callback. Side effects: writes debug samples and logs % their artifact locations without mutating app state. function writeAndLogSamplePack(debugLog, addLog) diff --git a/apps/image_measurement/image_enhance/+image_enhance/+debug/writeSamplePack.m b/apps/image_measurement/image_enhance/+image_enhance/+debug/writeSamplePack.m index 2fda33a02..347a18146 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+debug/writeSamplePack.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+debug/writeSamplePack.m @@ -1,41 +1,37 @@ -% Expected caller: image_enhance.definitionActions during debug launch and unit tests. +% Expected caller: image_enhance.definition during debug launch and unit tests. % Input is a LabKit debug context. Output is a deterministic synthetic image % enhancement sample pack. Side effects: writes anonymous debug images and % records a session manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Image Enhance debug image files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "image_enhance"); - imageFolder = fullfile(char(folders.sampleFolder), "images"); - ensureFolder(imageFolder); - - sourceA = string(fullfile(imageFolder, "enhance_source_uneven_illumination.png")); - sourceB = string(fullfile(imageFolder, "enhance_source_color_cast.png")); - edgePath = string(fullfile(imageFolder, "enhance_valid_low_contrast_16bit.tif")); - malformedPath = string(fullfile(imageFolder, "enhance_malformed_not_image.png")); + sourceA = sampleContext.samplePath("image_enhance/uneven.png"); + sourceB = sampleContext.samplePath("image_enhance/color_cast.png"); + edgePath = sampleContext.samplePath("image_enhance/low_contrast.tif"); + malformedPath = sampleContext.samplePath("image_enhance/malformed.png"); imwrite(toUint8(sceneImage("uneven")), char(sourceA)); imwrite(toUint8(sceneImage("colorCast")), char(sourceB)); imwrite(uint16(round(65535 .* lowContrastScene())), char(edgePath)); writeTextFile(malformedPath, "not an image payload" + newline); - representativeFiles = [sourceA; sourceB]; - pack = struct( ... - "sampleFolder", folders.sampleFolder, ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", representativeFiles, ... - "boundaryFiles", struct("validEdge", edgePath, "malformed", malformedPath)); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_ImageEnhance_app", ... - "description", "Anonymous detailed image-enhancement boundary pack.", ... - "inputs", struct( ... - "representativeImages", representativeFiles, ... - "validEdgeImage", edgePath, ... - "malformedImage", malformedPath), ... - "outputFolder", folders.outputFolder); - pack.manifest = manifest; - recordManifest(debugLog, manifest); + project = image_enhance.projectSpec().Create(); + project.inputs.sources = [ ... + sampleContext.sourceRecord( ... + "image1", "source-image", sourceA, true), ... + sampleContext.sourceRecord( ... + "image2", "source-image", sourceB, true)]; + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-enhancement", InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact("uneven", "source-image", sourceA), ... + sampleContext.artifact("colorCast", "source-image", sourceB), ... + sampleContext.artifact("lowContrast", "boundaryInput", edgePath), ... + sampleContext.artifact("malformed", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function image = sceneImage(kind) @@ -68,30 +64,6 @@ image = uint8(round(255 .* min(max(image, 0), 1))); end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 @@ -101,9 +73,3 @@ function writeTextFile(filepath, text) cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); end - -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/applyDraft.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/applyDraft.m new file mode 100644 index 000000000..8eb3c1434 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/applyDraft.m @@ -0,0 +1,24 @@ +% App-owned implementation for image_enhance.enhancementPipeline.applyDraft within the image_enhance product workflow. +function state=applyDraft(state,context) +%APPLYDRAFT Commit the selected enhancement draft to the active history. +availability = ... + image_enhance.imagePreview.presentationData.toolAvailability( ... + state, state.session.view.toolKind); +if ~availability.canApply + context.alert(availability.status, "Tool unavailable"); + return; +end +step=image_enhance.analysisRun.makeStep(state.session.view.toolKind, ... + state.session.view.toolAmount,state.session.view.toolSecondary,0); +steps = image_enhance.analysisRun.activeSteps(state); +steps = steps(:); +steps(end + 1, 1) = step; +state = image_enhance.analysisRun.setActiveSteps(state, steps); +state.session.workflow.pendingDirty=false; +state.session.view.roiEditing = false; +state = image_enhance.enhancementPipeline.invalidateResults(state); +state.session.cache.previewResult = []; +state.session.cache.previewResultKey = ""; +state = image_enhance.enhancementPipeline.rebuildPreview(state); +context.appendStatus("Applied tool: " + string(step.label)); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeAmount.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeAmount.m new file mode 100644 index 000000000..146e9ed87 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeAmount.m @@ -0,0 +1,28 @@ +% App-owned implementation for image_enhance.enhancementPipeline.changeAmount within the image_enhance product workflow. +function applicationState = changeAmount( ... + applicationState, value, callbackContext) +%CHANGEAMOUNT Update and preview the primary tool parameter. +defaults = image_enhance.analysisRun.defaultStepValues( ... + applicationState.session.view.toolKind); +applicationState.session.view.toolAmount = finiteClampedValue( ... + value, defaults.amount, defaults.amountLimits); +applicationState = draftChanged(applicationState); +end + +function applicationState = draftChanged(applicationState) +applicationState.session.workflow.pendingDirty = true; +applicationState = ... + image_enhance.enhancementPipeline.invalidateResults(applicationState); +applicationState.session.cache.previewResult = []; +applicationState.session.cache.previewResultKey = ""; +applicationState = ... + image_enhance.enhancementPipeline.rebuildPreview(applicationState); +end + +function value = finiteClampedValue(value, fallback, limits) +value = double(value); +if isempty(value) || ~isscalar(value) || ~isfinite(value) + value = fallback; +end +value = min(max(value, limits(1)), limits(2)); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeBatchMode.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeBatchMode.m new file mode 100644 index 000000000..47db8e456 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeBatchMode.m @@ -0,0 +1,21 @@ +% App-owned implementation for image_enhance.enhancementPipeline.changeBatchMode within the image_enhance product workflow. +function applicationState = changeBatchMode( ... + applicationState, batchMode, callbackContext) +%CHANGEBATCHMODE Switch between shared and per-image history. +applicationState.project.parameters.batchMode = logical(batchMode); +applicationState.session.workflow.pendingDirty = false; +applicationState.session.view.roiEditing = false; +applicationState = ... + image_enhance.enhancementPipeline.invalidateResults(applicationState); +applicationState.session.cache.previewResult = []; +applicationState.session.cache.previewResultKey = ""; +applicationState = ... + image_enhance.enhancementPipeline.rebuildPreview(applicationState); +if applicationState.project.parameters.batchMode + callbackContext.appendStatus( ... + "Enabled shared batch enhancement history."); +else + callbackContext.appendStatus( ... + "Enabled per-image enhancement history."); +end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeSecondary.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeSecondary.m new file mode 100644 index 000000000..e38456d35 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeSecondary.m @@ -0,0 +1,24 @@ +% App-owned implementation for image_enhance.enhancementPipeline.changeSecondary within the image_enhance product workflow. +function applicationState = changeSecondary( ... + applicationState, value, callbackContext) +%CHANGESECONDARY Update and preview the secondary tool parameter. +defaults = image_enhance.analysisRun.defaultStepValues( ... + applicationState.session.view.toolKind); +applicationState.session.view.toolSecondary = finiteClampedValue( ... + value, defaults.secondary, defaults.secondaryLimits); +applicationState.session.workflow.pendingDirty = true; +applicationState = ... + image_enhance.enhancementPipeline.invalidateResults(applicationState); +applicationState.session.cache.previewResult = []; +applicationState.session.cache.previewResultKey = ""; +applicationState = ... + image_enhance.enhancementPipeline.rebuildPreview(applicationState); +end + +function value = finiteClampedValue(value, fallback, limits) +value = double(value); +if isempty(value) || ~isscalar(value) || ~isfinite(value) + value = fallback; +end +value = min(max(value, limits(1)), limits(2)); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeTool.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeTool.m new file mode 100644 index 000000000..4068b39f9 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeTool.m @@ -0,0 +1,24 @@ +% App-owned implementation for image_enhance.enhancementPipeline.changeTool within the image_enhance product workflow. +function applicationState = changeTool( ... + applicationState, toolKind, callbackContext) +%CHANGETOOL Select one enhancement draft and its default values. +toolKind = string(toolKind); +if ~isscalar(toolKind) || ... + ~any(toolKind == ... + string(image_enhance.enhancementPipeline.toolKinds())) + callbackContext.appendStatus("Ignored an unsupported enhancement tool."); + return; +end +defaults = image_enhance.analysisRun.defaultStepValues(toolKind); +applicationState.session.view.toolKind = toolKind; +applicationState.session.view.toolAmount = defaults.amount; +applicationState.session.view.toolSecondary = defaults.secondary; +applicationState.session.view.roiEditing = false; +applicationState.session.workflow.pendingDirty = true; +applicationState = ... + image_enhance.enhancementPipeline.invalidateResults(applicationState); +applicationState.session.cache.previewResult = []; +applicationState.session.cache.previewResultKey = ""; +applicationState = ... + image_enhance.enhancementPipeline.rebuildPreview(applicationState); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/invalidateResults.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/invalidateResults.m new file mode 100644 index 000000000..ecb6a7993 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/invalidateResults.m @@ -0,0 +1,7 @@ +% App-owned implementation for image_enhance.enhancementPipeline.invalidateResults within the image_enhance product workflow. +function applicationState = invalidateResults(applicationState) +%INVALIDATERESULTS Clear export identity after inputs or settings change. +applicationState.project.results.lastExport = []; +applicationState.project.results.lastExportFingerprint = ""; +applicationState.project.results.resultManifestPath = ""; +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/rebuildPreview.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/rebuildPreview.m new file mode 100644 index 000000000..51349c1cf --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/rebuildPreview.m @@ -0,0 +1,57 @@ +% App-owned implementation for image_enhance.enhancementPipeline.rebuildPreview within the image_enhance product workflow. +function applicationState = rebuildPreview(applicationState) +%REBUILDPREVIEW Recompute the selected display-resolution enhancement. +cache = applicationState.session.cache; +if isempty(cache.previewSource) + applicationState.session.cache.previewResult = []; + applicationState.session.cache.previewResultKey = ""; + return; +end +steps = image_enhance.analysisRun.activeSteps(applicationState); +availability = ... + image_enhance.imagePreview.presentationData.toolAvailability( ... + applicationState, applicationState.session.view.toolKind); +includePending = ... + applicationState.session.workflow.pendingDirty && ... + availability.canPreviewPending; +previewSteps = steps; +if includePending + previewSteps(end + 1, 1) = image_enhance.analysisRun.makeStep( ... + applicationState.session.view.toolKind, ... + applicationState.session.view.toolAmount, ... + applicationState.session.view.toolSecondary, 0); +end +key = previewKey(previewSteps, includePending); +if cache.previewResultKey == key && ~isempty(cache.previewResult) + return; +end +roi = currentWhiteRoi(applicationState); +applicationState.session.cache.previewResult = ... + image_enhance.analysisRun.previewResult( ... + cache.previewSource, previewSteps, roi, cache.previewScale); +applicationState.session.cache.previewResultKey = key; +end + +function key = previewKey(steps, includePending) +if isempty(steps) + key = "steps=0#pending=" + string(includePending); + return; +end +labels = string({steps.label}); +key = strjoin(labels, "|") + ... + "#steps=" + string(numel(steps)) + ... + "#pending=" + string(includePending); +end + +function roi = currentWhiteRoi(applicationState) +roi = []; +index = applicationState.session.selection.currentIndex; +sources = applicationState.project.inputs.sources; +if applicationState.project.parameters.batchMode || ... + index < 1 || index > numel(sources) + return; +end +annotation = image_enhance.sourceLibrary.annotationForSource( ... + applicationState.project.annotations.items, sources(index).id); +roi = annotation.whiteRoi; +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/reset.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/reset.m new file mode 100644 index 000000000..125aec48b --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/reset.m @@ -0,0 +1,19 @@ +% App-owned implementation for image_enhance.enhancementPipeline.reset within the image_enhance product workflow. +function applicationState = reset(applicationState, callbackContext) +%RESET Clear the shared or selected per-image history. +if isempty(image_enhance.analysisRun.activeSteps(applicationState)) + return; +end +applicationState = image_enhance.analysisRun.setActiveSteps( ... + applicationState, ... + repmat(image_enhance.analysisRun.emptyStep(), 0, 1)); +applicationState.session.workflow.pendingDirty = false; +applicationState.session.view.roiEditing = false; +applicationState = ... + image_enhance.enhancementPipeline.invalidateResults(applicationState); +applicationState.session.cache.previewResult = []; +applicationState.session.cache.previewResultKey = ""; +applicationState = ... + image_enhance.enhancementPipeline.rebuildPreview(applicationState); +callbackContext.appendStatus("Reset enhancement history."); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/setWhiteRoi.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/setWhiteRoi.m new file mode 100644 index 000000000..ac1684347 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/setWhiteRoi.m @@ -0,0 +1,34 @@ +% App-owned implementation for image_enhance.enhancementPipeline.setWhiteRoi within the image_enhance product workflow. +function applicationState = setWhiteRoi( ... + applicationState, callbackContext) +%SETWHITEROI Begin managed per-image white-reference ROI editing. +index = applicationState.session.selection.currentIndex; +sources = applicationState.project.inputs.sources; +if applicationState.project.parameters.batchMode || ... + index < 1 || index > numel(sources) || ... + isempty(applicationState.session.cache.item) + callbackContext.alert( ... + "White ROI calibration uses per-image mode only.", ... + "White ROI unavailable"); + return; +end +sourceId = sources(index).id; +annotation = image_enhance.sourceLibrary.annotationForSource( ... + applicationState.project.annotations.items, sourceId); +roi = double(annotation.whiteRoi); +if numel(roi) ~= 4 || any(~isfinite(roi)) || any(roi(3:4) <= 0) + annotation.whiteRoi = ... + image_enhance.imagePreview.presentationData.defaultWhiteRoi( ... + size(applicationState.session.cache.item.image)); + applicationState.project.annotations.items = ... + image_enhance.sourceLibrary.storeAnnotation( ... + applicationState.project.annotations.items, annotation); +end +applicationState.session.view.roiEditing = true; +applicationState.session.view.previewMode = "Original"; +applicationState.session.workflow.pendingDirty = true; +applicationState = ... + image_enhance.enhancementPipeline.invalidateResults(applicationState); +applicationState.session.cache.previewResult = []; +applicationState.session.cache.previewResultKey = ""; +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/toolKinds.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/toolKinds.m new file mode 100644 index 000000000..669327ec7 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/toolKinds.m @@ -0,0 +1,4 @@ +% App-owned implementation for image_enhance.enhancementPipeline.toolKinds within the image_enhance product workflow. +function values=toolKinds() +values=["Brightness/contrast" "Local contrast" "Sharpen" "Hue/saturation" "White balance" "White ROI calibration" "Subject-preserving enhance"]; +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/undo.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/undo.m new file mode 100644 index 000000000..2b9bbf126 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/undo.m @@ -0,0 +1,23 @@ +% App-owned implementation for image_enhance.enhancementPipeline.undo within the image_enhance product workflow. +function applicationState = undo(applicationState, callbackContext) +%UNDO Remove the newest shared or selected per-image history step. +steps = image_enhance.analysisRun.activeSteps(applicationState); +if isempty(steps) + return; +end +removed = steps(end); +steps(end) = []; +steps = steps(:); +applicationState = image_enhance.analysisRun.setActiveSteps( ... + applicationState, steps); +applicationState.session.workflow.pendingDirty = false; +applicationState.session.view.roiEditing = false; +applicationState = ... + image_enhance.enhancementPipeline.invalidateResults(applicationState); +applicationState.session.cache.previewResult = []; +applicationState.session.cache.previewResultKey = ""; +applicationState = ... + image_enhance.enhancementPipeline.rebuildPreview(applicationState); +callbackContext.appendStatus( ... + "Undid history step: " + string(removed.label)); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/beforeAfterImage.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/beforeAfterImage.m similarity index 100% rename from apps/image_measurement/image_enhance/+image_enhance/+userInterface/beforeAfterImage.m rename to apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/beforeAfterImage.m diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/clampValue.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/clampValue.m similarity index 100% rename from apps/image_measurement/image_enhance/+image_enhance/+userInterface/clampValue.m rename to apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/clampValue.m diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/defaultWhiteRoi.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/defaultWhiteRoi.m similarity index 100% rename from apps/image_measurement/image_enhance/+image_enhance/+userInterface/defaultWhiteRoi.m rename to apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/defaultWhiteRoi.m diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/detailLines.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/detailLines.m similarity index 100% rename from apps/image_measurement/image_enhance/+image_enhance/+userInterface/detailLines.m rename to apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/detailLines.m diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/historyTableData.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/historyTableData.m similarity index 100% rename from apps/image_measurement/image_enhance/+image_enhance/+userInterface/historyTableData.m rename to apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/historyTableData.m diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/onOff.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/onOff.m similarity index 100% rename from apps/image_measurement/image_enhance/+image_enhance/+userInterface/onOff.m rename to apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/onOff.m diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/previewImage.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/previewImage.m similarity index 100% rename from apps/image_measurement/image_enhance/+image_enhance/+userInterface/previewImage.m rename to apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/previewImage.m diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/resultTableData.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/resultTableData.m similarity index 100% rename from apps/image_measurement/image_enhance/+image_enhance/+userInterface/resultTableData.m rename to apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/resultTableData.m diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/toolAvailability.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/toolAvailability.m similarity index 87% rename from apps/image_measurement/image_enhance/+image_enhance/+userInterface/toolAvailability.m rename to apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/toolAvailability.m index 22844c629..ed61717df 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/toolAvailability.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/toolAvailability.m @@ -6,8 +6,10 @@ batchMode = S.project.parameters.batchMode; current = image_enhance.enhancementAnnotations.empty(); if hasImages - current = S.project.annotations.items( ... - S.session.selection.currentIndex); + index = S.session.selection.currentIndex; + sourceId = S.project.inputs.sources(index).id; + current = image_enhance.sourceLibrary.annotationForSource( ... + S.project.annotations.items, sourceId); end isWhiteRoi = strcmpi(regexprep(char(string(toolKind)), ... '[^a-zA-Z0-9]', ''), 'whiteroicalibration'); diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/toolKinds.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/toolKinds.m similarity index 100% rename from apps/image_measurement/image_enhance/+image_enhance/+userInterface/toolKinds.m rename to apps/image_measurement/image_enhance/+image_enhance/+imagePreview/+presentationData/toolKinds.m diff --git a/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeMode.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeMode.m new file mode 100644 index 000000000..efd93a1cc --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeMode.m @@ -0,0 +1,14 @@ +% App-owned implementation for image_enhance.imagePreview.changeMode within the image_enhance product workflow. +function applicationState = changeMode( ... + applicationState, mode, callbackContext) +%CHANGEMODE Select the original, enhanced, or before/after preview. +mode = string(mode); +if isscalar(mode) && ... + any(mode == ["Enhanced" "Original" "Before | After"]) + applicationState.session.view.previewMode = mode; + applicationState.session.view.roiEditing = false; +else + callbackContext.appendStatus( ... + "Ignored an unsupported image preview mode."); +end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeWhiteRoi.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeWhiteRoi.m new file mode 100644 index 000000000..5e83a4d12 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeWhiteRoi.m @@ -0,0 +1,27 @@ +% App-owned implementation for image_enhance.imagePreview.changeWhiteRoi within the image_enhance product workflow. +function applicationState = changeWhiteRoi( ... + applicationState, position, callbackContext) +%CHANGEWHITEROI Store one managed ROI in source-image coordinates. +index = applicationState.session.selection.currentIndex; +sources = applicationState.project.inputs.sources; +position = double(position); +if index < 1 || index > numel(sources) || ... + numel(position) ~= 4 || any(~isfinite(position)) || ... + any(position(3:4) <= 0) + callbackContext.appendStatus("Ignored an invalid white ROI edit."); + return; +end +sourceId = sources(index).id; +annotation = image_enhance.sourceLibrary.annotationForSource( ... + applicationState.project.annotations.items, sourceId); +annotation.whiteRoi = position ./ ... + max(eps, applicationState.session.cache.previewScale); +applicationState.project.annotations.items = ... + image_enhance.sourceLibrary.storeAnnotation( ... + applicationState.project.annotations.items, annotation); +applicationState.session.workflow.pendingDirty = true; +applicationState = ... + image_enhance.enhancementPipeline.invalidateResults(applicationState); +applicationState.session.cache.previewResult = []; +applicationState.session.cache.previewResultKey = ""; +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/draw.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/draw.m new file mode 100644 index 000000000..21a028063 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/draw.m @@ -0,0 +1,20 @@ +% App-owned implementation for image_enhance.imagePreview.draw within the image_enhance product workflow. +function draw(axesById, model) +%DRAW Render the selected Image Enhance preview and display-only ROI. +axesHandle = axesById.image; +labkit.app.plot.clearAxes(axesHandle); +title(axesHandle, model.title); +if isempty(model.imageData) + return; +end +image(axesHandle, model.imageData); +axis(axesHandle, "image"); +axis(axesHandle, "off"); +title(axesHandle, model.title); +if numel(model.whiteRoi) == 4 && ... + all(isfinite(model.whiteRoi)) && all(model.whiteRoi(3:4) > 0) + rectangle(axesHandle, Position=model.whiteRoi, ... + EdgeColor=[1 1 1], LineWidth=1.5, ... + HitTest="off", PickableParts="none"); +end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/changeFormat.m b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/changeFormat.m new file mode 100644 index 000000000..1087503a4 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/changeFormat.m @@ -0,0 +1,13 @@ +% App-owned implementation for image_enhance.resultFiles.changeFormat within the image_enhance product workflow. +function applicationState = changeFormat( ... + applicationState, format, callbackContext) +%CHANGEFORMAT Select the batch image format and invalidate prior export. +format = upper(string(format)); +if ~isscalar(format) || ~any(format == ["PNG" "TIFF" "JPEG"]) + callbackContext.appendStatus("Ignored an unsupported export format."); + return; +end +applicationState.project.parameters.exportFormat = format; +applicationState = ... + image_enhance.enhancementPipeline.invalidateResults(applicationState); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/chooseOutputFolder.m b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/chooseOutputFolder.m new file mode 100644 index 000000000..3795fa0d7 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/chooseOutputFolder.m @@ -0,0 +1,15 @@ +% App-owned implementation for image_enhance.resultFiles.chooseOutputFolder within the image_enhance product workflow. +function applicationState = chooseOutputFolder( ... + applicationState, callbackContext) +%CHOOSEOUTPUTFOLDER Select the batch export destination. +choice = callbackContext.chooseOutputFolder( ... + applicationState.project.parameters.outputFolder); +if choice.Cancelled + callbackContext.appendStatus( ... + "Export folder selection cancelled."); + return; +end +applicationState.project.parameters.outputFolder = string(choice.Value); +applicationState = ... + image_enhance.enhancementPipeline.invalidateResults(applicationState); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/exportImages.m b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/exportImages.m new file mode 100644 index 000000000..ef5306e63 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/exportImages.m @@ -0,0 +1,118 @@ +% App-owned implementation for image_enhance.resultFiles.exportImages within the image_enhance product workflow. +function applicationState = exportImages( ... + applicationState, callbackContext) +%EXPORTIMAGES Render every source and write CSV and LabKit manifests. +sources = applicationState.project.inputs.sources; +if isempty(sources) + callbackContext.alert( ... + "Load images before exporting.", "No images loaded"); + return; +end +folder = string(applicationState.project.parameters.outputFolder); +if strlength(folder) == 0 + choice = callbackContext.chooseOutputFolder(""); + if choice.Cancelled + callbackContext.appendStatus("Image export cancelled."); + return; + end + folder = string(choice.Value); + applicationState.project.parameters.outputFolder = folder; +end +try + items = image_enhance.sourceFiles.readImages( ... + callbackContext.resolveSourcePaths(sources)); + [items, steps, itemSteps] = exportSteps( ... + applicationState, items); + options = struct( ... + "outputFolder", folder, ... + "format", applicationState.project.parameters.exportFormat, ... + "itemSteps", {itemSteps}); + task = image_enhance.resultFiles.exportTask( ... + items, steps, options); + if ~isempty(applicationState.project.results.lastExport) && ... + applicationState.project.results.lastExportFingerprint == ... + task.fingerprint + callbackContext.appendStatus( ... + "Enhanced export is already up to date."); + return; + end + payload = image_enhance.resultFiles.writeOutputs( ... + items, steps, options); + package = labkit.app.result.Package( ... + Outputs=packageOutputs(payload), ... + Inputs=struct("sources", sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct( ... + "imageCount", numel(items), ... + "savedCount", sum( ... + string({payload.results.status}) == "saved")), ... + ManifestName="image_enhance.labkit.json"); + written = callbackContext.writeResultPackage(folder, package); +catch ME + callbackContext.reportError("Export enhanced images", ME); + callbackContext.alert(ME.message, "Export failed"); + callbackContext.appendStatus( ... + "Image export failed: " + string(ME.message)); + return; +end +payload.sourceIds = string({sources.id}); +payload.resultManifestPath = string(written.Value); +applicationState.project.results.lastExport = payload; +applicationState.project.results.lastExportFingerprint = task.fingerprint; +applicationState.project.results.resultManifestPath = string(written.Value); +statuses = string({payload.results.status}); +callbackContext.appendStatus(sprintf( ... + "Exported %d image(s), %d failed. Manifest: %s", ... + sum(statuses == "saved"), sum(statuses == "failed"), ... + char(payload.manifestPath))); +end + +function [items, steps, itemSteps] = exportSteps( ... + applicationState, items) +if applicationState.project.parameters.batchMode + steps = applicationState.project.annotations.sharedSteps; + itemSteps = {}; + return; +end +steps = repmat(image_enhance.analysisRun.emptyStep(), 0, 1); +itemSteps = cell(numel(items), 1); +for index = 1:numel(items) + sourceId = applicationState.project.inputs.sources(index).id; + annotation = image_enhance.sourceLibrary.annotationForSource( ... + applicationState.project.annotations.items, sourceId); + itemSteps{index} = annotation.steps; + items(index).whiteRoi = annotation.whiteRoi; +end +end + +function outputs = packageOutputs(payload) +outputs = cell(1, numel(payload.results) + 1); +for index = 1:numel(payload.results) + result = payload.results(index); + [~, name, extension] = fileparts(result.outputPath); + status = "failed"; + if result.status == "saved" + status = "success"; + end + outputs{index} = labkit.app.result.File( ... + "enhanced_" + compose("%03d", index), ... + "enhanced-image", string(name) + string(extension), ... + MediaType=mediaType(extension), Status=status, ... + Message=result.message); +end +[~, name, extension] = fileparts(payload.manifestPath); +outputs{end} = labkit.app.result.File( ... + "batch_manifest", "batch-summary", ... + string(name) + string(extension), MediaType="text/csv"); +end + +function value = mediaType(extension) +switch lower(string(extension)) + case {".jpg", ".jpeg"} + value = "image/jpeg"; + case {".tif", ".tiff"} + value = "image/tiff"; + otherwise + value = "image/png"; +end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+sourceFiles/ensureCurrentPreview.m b/apps/image_measurement/image_enhance/+image_enhance/+sourceFiles/ensureCurrentPreview.m deleted file mode 100644 index 274bd0898..000000000 --- a/apps/image_measurement/image_enhance/+image_enhance/+sourceFiles/ensureCurrentPreview.m +++ /dev/null @@ -1,41 +0,0 @@ -% Expected caller: Image Enhance actions. Inputs are canonical -% state and runtime services. Output lazily owns only the selected decoded -% image and its downsampled preview; failures are logged and reported. -function state = ensureCurrentPreview(state, services) - sources = state.project.inputs.sources; - index = state.session.selection.currentIndex; - if isempty(sources) || index < 1 || index > numel(sources) - state.session.cache = emptyCache(); - return; - end - source = sources(index); - if state.session.cache.sourceId == string(source.id) && ... - ~isempty(state.session.cache.item) - return; - end - state.session.cache = emptyCache(); - try - loaded = image_enhance.sourceFiles.readImages( ... - labkit.ui.runtime.sourcePaths(source)); - if isempty(loaded) - return; - end - [preview, scale] = image_enhance.userInterface.previewImage( ... - loaded(1).image); - state.session.cache.sourceId = string(source.id); - state.session.cache.item = loaded(1); - state.session.cache.previewSource = preview; - state.session.cache.previewScale = scale; - catch ME - services.diagnostics.report("Could not load image", ME); - services.dialogs.alert(ME.message, "Could not load image"); - state = services.workflow.log(state, ... - "Could not load selected image: " + string(ME.message)); - end -end - -function cache = emptyCache() - cache = struct("sourceId", "", "item", [], ... - "previewSource", [], "previewScale", 1, ... - "previewResult", [], "previewResultKey", ""); -end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/annotationForSource.m b/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/annotationForSource.m new file mode 100644 index 000000000..a962224b8 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/annotationForSource.m @@ -0,0 +1,11 @@ +% App-owned implementation for image_enhance.sourceLibrary.annotationForSource within the image_enhance product workflow. +function annotation = annotationForSource(items, sourceId) +%ANNOTATIONFORSOURCE Return a sparse per-image annotation or its default. +index=find(string({items.sourceId})==string(sourceId),1); +if isempty(index) + annotation=image_enhance.enhancementAnnotations.empty(); + annotation.sourceId=string(sourceId); +else + annotation=items(index); +end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/selectPreview.m b/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/selectPreview.m new file mode 100644 index 000000000..abc16e29e --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/selectPreview.m @@ -0,0 +1,82 @@ +% App-owned implementation for image_enhance.sourceLibrary.selectPreview within the image_enhance product workflow. +function applicationState = selectPreview( ... + applicationState, listSelection, callbackContext) +%SELECTPREVIEW Lazily decode and present the selected portable source. +applicationState.project.annotations.items = reconcileAnnotations( ... + applicationState.project.annotations.items, ... + applicationState.project.inputs.sources); +applicationState = invalidateChangedSourceExport(applicationState); +if isempty(listSelection.Indices) + applicationState.session.selection.currentIndex = 0; + applicationState.session.cache = emptySelectedCache( ... + applicationState.session.cache); + return; +end +index = listSelection.Indices(1); +sources = applicationState.project.inputs.sources; +if index > numel(sources) + return; +end +source = sources(index); +try + paths = callbackContext.resolveSourcePaths(source); + items = image_enhance.sourceFiles.readImages(paths); +catch ME + callbackContext.reportError("Load image preview", ME); + callbackContext.alert(ME.message, "Could not load image preview"); + return; +end +if isempty(items) + return; +end +if strlength( ... + applicationState.project.parameters.outputFolder) == 0 + applicationState.project.parameters.outputFolder = string(fullfile( ... + fileparts(paths(1)), "image_enhance")); +end +[preview, scale] = ... + image_enhance.imagePreview.presentationData.previewImage( ... + items(1).image); +applicationState.session.selection.currentIndex = index; +applicationState.session.workflow.pendingDirty = false; +applicationState.session.view.roiEditing = false; +applicationState.session.cache.sourceId = string(source.id); +applicationState.session.cache.item = items(1); +applicationState.session.cache.previewSource = preview; +applicationState.session.cache.previewScale = scale; +applicationState.session.cache.previewResult = []; +applicationState.session.cache.previewResultKey = ""; +applicationState = ... + image_enhance.enhancementPipeline.rebuildPreview(applicationState); +end + +function items = reconcileAnnotations(items, sources) +current = items; +items = repmat( ... + image_enhance.enhancementAnnotations.empty(), numel(sources), 1); +for index = 1:numel(sources) + items(index) = image_enhance.sourceLibrary.annotationForSource( ... + current, sources(index).id); +end +end + +function applicationState = invalidateChangedSourceExport(applicationState) +lastExport = applicationState.project.results.lastExport; +if isempty(lastExport) || ~isfield(lastExport, "sourceIds") + return; +end +currentIds = string({applicationState.project.inputs.sources.id}); +if ~isequal(currentIds(:), string(lastExport.sourceIds(:))) + applicationState = ... + image_enhance.enhancementPipeline.invalidateResults(applicationState); +end +end + +function cache = emptySelectedCache(cache) +cache.sourceId = ""; +cache.item = []; +cache.previewSource = []; +cache.previewScale = 1; +cache.previewResult = []; +cache.previewResultKey = ""; +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/storeAnnotation.m b/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/storeAnnotation.m new file mode 100644 index 000000000..aab0311f5 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/storeAnnotation.m @@ -0,0 +1,6 @@ +% App-owned implementation for image_enhance.sourceLibrary.storeAnnotation within the image_enhance product workflow. +function items = storeAnnotation(items, annotation) +%STOREANNOTATION Materialize or replace one source-ID keyed annotation. +index=find(string({items.sourceId})==string(annotation.sourceId),1); +if isempty(index),items(end+1,1)=annotation;else,items(index)=annotation;end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m b/apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index e0ff53aaa..000000000 --- a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,151 +0,0 @@ -% Expected caller: labkit_ImageEnhance_app. Inputs are tool labels, initial -% export folder text, and callback handles. Output is a data-only UI 5 -% workbench layout for the Image Enhance app. -function layout = buildWorkbenchLayout(callbacks, state) - stepKinds = image_enhance.userInterface.toolKinds(); - outputFolder = state.project.parameters.outputFolder; - - layout = labkit.ui.layout.workbench('imageEnhanceApp', 'Paper Image Enhance', ... - 'controlTabs', controlTabs(stepKinds, outputFolder, callbacks), ... - 'workspace', previewWorkspace(callbacks)); -end - -function tabs = controlTabs(stepKinds, outputFolder, callbacks) - tabs = { ... - libraryExportTab(outputFolder, callbacks), ... - toolsHistoryTab(stepKinds, callbacks), ... - logTab()}; -end - -function tab = libraryExportTab(outputFolder, callbacks) - tab = labkit.ui.layout.tab('libraryExport', 'Library + Export', { ... - librarySection(callbacks), ... - exportSection(outputFolder, callbacks), ... - exportDetailsSection()}); -end - -function tab = toolsHistoryTab(stepKinds, callbacks) - tab = labkit.ui.layout.tab('toolsHistory', 'Tools + History', { ... - toolSection(stepKinds, callbacks), ... - historySection(callbacks), ... - currentImageSection()}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab('log', 'Log', { ... - labkit.ui.layout.section('logSection', 'Log', { ... - labkit.ui.layout.logPanel('logPanel', 'Log')})}); -end - -function section = librarySection(callbacks) - section = labkit.ui.layout.section('librarySection', 'Library', { ... - labkit.ui.layout.filePanel('sourceImages', 'Source images', ... - 'selectionMode', 'single', ... - 'chooseLabel', 'Add images or folder', ... - 'clearLabel', 'Clear images', ... - 'filters', labkit.image.fileDialogFilter("IncludeAll", true), ... - 'status', 'No images loaded', ... - 'emptyText', 'No images loaded', ... - 'onChoose', callbacks.sourceImagesChosen, ... - 'onRemove', callbacks.removeImages, ... - 'onSelectionChange', callbacks.imageSelectionChanged, ... - 'onClear', callbacks.clearImages), ... - labkit.ui.layout.field('imageStatus', 'Status', ... - 'kind', 'readonly', ... - 'value', 'Images: 0')}); -end - -function section = exportSection(outputFolder, callbacks) - section = labkit.ui.layout.section('exportSection', 'Batch Export', { ... - labkit.ui.layout.field('outputFolder', 'Output folder', ... - 'kind', 'readonly', ... - 'value', outputFolder), ... - labkit.ui.layout.field('exportFormat', 'Format', ... - 'kind', 'dropdown', ... - 'items', {'PNG', 'TIFF', 'JPEG'}, ... - 'value', 'PNG', ... - 'onChange', callbacks.exportSettingChanged), ... - labkit.ui.layout.group('exportActions', "", { ... - labkit.ui.layout.action('chooseOutputFolder', 'Choose folder', ... - callbacks.chooseOutputFolder), ... - labkit.ui.layout.action('exportImages', 'Export enhanced images', ... - callbacks.exportImages, ... - 'enabled', false)})}); -end - -function section = exportDetailsSection() - section = labkit.ui.layout.section('exportDetailsSection', 'Export Details', { ... - labkit.ui.layout.statusPanel('exportDetails', 'Export Details', ... - 'value', image_enhance.userInterface.detailLines( ... - repmat(image_enhance.sourceFiles.emptyItem(), 0, 1), 1, ... - repmat(image_enhance.analysisRun.emptyStep(), 0, 1), []))}); -end - -function section = toolSection(stepKinds, callbacks) - section = labkit.ui.layout.section('toolSection', 'Toolbox', { ... - labkit.ui.layout.field('batchMode', 'Batch shared processing', ... - 'kind', 'checkbox', ... - 'value', true, ... - 'onChange', callbacks.batchModeChanged), ... - labkit.ui.layout.field('batchModeStatus', 'Mode', ... - 'kind', 'readonly', ... - 'value', 'Images: 0'), ... - labkit.ui.layout.field('toolKind', 'Interaction', ... - 'kind', 'dropdown', ... - 'items', stepKinds, ... - 'value', stepKinds{1}, ... - 'onChange', callbacks.toolChanged), ... - labkit.ui.layout.panner('toolAmount', 'Brightness (%)', ... - 'value', 0, ... - 'limits', [-100 100], ... - 'step', 1, ... - 'onChange', callbacks.toolSettingChanged), ... - labkit.ui.layout.panner('toolSecondary', 'Contrast (%)', ... - 'value', 15, ... - 'limits', [-100 100], ... - 'step', 1, ... - 'onChange', callbacks.toolSettingChanged), ... - labkit.ui.layout.field('toolStatus', 'Status', ... - 'kind', 'readonly', ... - 'value', 'Select an image, choose a tool, then apply it to history.'), ... - labkit.ui.layout.action('setWhiteRoi', 'Set white ROI', ... - callbacks.setWhiteRoi, ... - 'enabled', false), ... - labkit.ui.layout.action('applyTool', 'Apply tool', ... - callbacks.applyTool, ... - 'enabled', false)}); -end - -function section = historySection(callbacks) - section = labkit.ui.layout.section('historySection', 'Step History', { ... - labkit.ui.layout.group('historyActions', "", { ... - labkit.ui.layout.action('undoHistory', 'Undo history', ... - callbacks.undoHistory, ... - 'enabled', false), ... - labkit.ui.layout.action('resetHistory', 'Reset history', ... - callbacks.resetHistory, ... - 'enabled', false)}), ... - labkit.ui.layout.resultTable('historyTable', 'Step History', ... - 'columns', {'#', 'Step', 'Settings'}, ... - 'data', image_enhance.userInterface.historyTableData( ... - repmat(image_enhance.analysisRun.emptyStep(), 0, 1))), ... - labkit.ui.layout.field('historyStatus', 'Status', ... - 'kind', 'readonly', ... - 'value', 'History steps: 0')}); -end - -function section = currentImageSection() - section = labkit.ui.layout.section('currentImageSection', 'Current Image', { ... - labkit.ui.layout.resultTable('metricsTable', 'Current Image', ... - 'columns', {'Metric', 'Value'}, ... - 'data', image_enhance.userInterface.resultTableData([], [], 0))}); -end - -function workspace = previewWorkspace(callbacks) - workspace = labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.previewArea('preview', 'Preview', ... - 'layout', 'single', ... - 'axisTitles', {'Enhanced Preview'}, ... - 'viewModes', {'Enhanced', 'Original', 'Before | After'}, ... - 'onModeChange', callbacks.previewModeChanged)}); -end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m b/apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m deleted file mode 100644 index 07c047557..000000000 --- a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,193 +0,0 @@ -% Expected caller: the LabKit V2 runtime. Input is canonical Image Enhance -% state. Output is a deterministic control, preview, and controlled ROI view. -function view = presentWorkbench(state) - sources = state.project.inputs.sources; - index = state.session.selection.currentIndex; - steps = image_enhance.analysisRun.activeSteps(state); - hasImage = ~isempty(state.session.cache.item); - availability = image_enhance.userInterface.toolAvailability( ... - state, state.session.view.toolKind); - defaults = image_enhance.analysisRun.defaultStepValues( ... - state.session.view.toolKind); - - view = struct(); - view.controls.sourceImages = sourceSpec(sources, index); - view.controls.imageStatus = valueSpec(sprintf( ... - 'Images: %d | current steps: %d', numel(sources), numel(steps))); - view.controls.batchMode = valueSpec(state.project.parameters.batchMode); - view.controls.batchModeStatus = valueSpec(modeStatus(state)); - view.controls.toolKind = valueSpec(state.session.view.toolKind); - view.controls.toolAmount = struct("Value", state.session.view.toolAmount, ... - "Limits", defaults.amountLimits); - view.controls.toolSecondary = struct( ... - "Value", state.session.view.toolSecondary, ... - "Limits", defaults.secondaryLimits); - view.controls.toolStatus = valueSpec(toolStatus(state, availability)); - view.controls.setWhiteRoi = enabledSpec(availability.canSetWhiteRoi); - view.controls.applyTool = enabledSpec(availability.canApply); - view.controls.undoHistory = enabledSpec(~isempty(steps)); - view.controls.resetHistory = enabledSpec(~isempty(steps)); - view.controls.historyTable = dataSpec( ... - image_enhance.userInterface.historyTableData(steps)); - view.controls.historyStatus = valueSpec(sprintf( ... - 'History steps: %d', numel(steps))); - view.controls.metricsTable = dataSpec(metricData(state, steps)); - view.controls.outputFolder = valueSpec( ... - state.project.parameters.outputFolder); - view.controls.exportFormat = valueSpec( ... - state.project.parameters.exportFormat); - view.controls.exportImages = enabledSpec(hasImage); - view.controls.exportDetails = valueSpec(detailLines(state, steps)); - view.controls.preview = valueSpec(state.session.view.previewMode); - - model = previewModel(state); - view.previews.preview = struct("Renderer", "imagePreview", "Model", model); - if roiInteractionVisible(state) - annotation = state.project.annotations.items(index); - view.interactions.whiteRoi = struct( ... - "Kind", "rectangle", ... - "Targets", "preview", ... - "Value", annotation.whiteRoi .* state.session.cache.previewScale, ... - "Event", "whiteRoiEdited", ... - "ImageSize", size(state.session.cache.previewSource), ... - "ChangePolicy", "commit", ... - "Options", struct("color", [1 1 1], "lineWidth", 1.5)); - end -end - -function spec = sourceSpec(sources, index) - files = repmat(struct("id", "", "path", "", "status", "ready"), ... - numel(sources), 1); - paths = labkit.ui.runtime.sourcePaths(sources); - for k = 1:numel(sources) - files(k).id = string(sources(k).id); - files(k).path = paths(k); - end - selection = strings(0, 1); - if index >= 1 && index <= numel(sources) - selection = string(sources(index).id); - end - if isempty(sources) - status = "No images loaded"; - else - status = sprintf('%d image(s)', numel(sources)); - end - spec = struct(); - spec.Files = files; - spec.Selection = selection; - spec.Status = status; -end - -function text = modeStatus(state) - if state.project.parameters.batchMode - text = "Batch mode: all images share the same parameters and history."; - else - text = "Per-image mode: each image keeps its own parameters and history."; - end -end - -function text = toolStatus(state, availability) - if availability.isWhiteRoi - text = availability.status; - elseif state.session.workflow.pendingDirty - text = "Previewing: " + string(currentStep(state).label) + ... - " | " + string(availability.status); - else - text = "Ready: " + string(currentStep(state).label) + ... - " | " + string(availability.status); - end -end - -function step = currentStep(state) - step = image_enhance.analysisRun.makeStep( ... - state.session.view.toolKind, state.session.view.toolAmount, ... - state.session.view.toolSecondary, 0); -end - -function data = metricData(state, steps) - if isempty(state.session.cache.item) || ... - isempty(state.session.cache.previewResult) - data = image_enhance.userInterface.resultTableData([], [], 0); - return; - end - data = image_enhance.userInterface.resultTableData( ... - state.session.cache.item, state.session.cache.previewResult, numel(steps)); -end - -function lines = detailLines(state, steps) - sources = state.project.inputs.sources; - index = state.session.selection.currentIndex; - if isempty(sources) || index < 1 || index > numel(sources) - lines = {'Load one or more images to begin enhancement.'}; - return; - end - path = labkit.ui.runtime.sourcePaths(sources(index)); - [~, name, extension] = fileparts(path); - lines = {sprintf('Selected: %s', char(string(name) + string(extension))), ... - sprintf('Images registered: %d', numel(sources)), ... - sprintf('History steps: %d', numel(steps))}; - if ~isempty(steps) - lines{end + 1} = sprintf('Last step: %s', ... - char(image_enhance.analysisRun.describeStep(steps(end)))); - end - if strlength(state.project.results.resultManifestPath) > 0 - lines{end + 1} = sprintf('Last manifest: %s', ... - char(state.project.results.resultManifestPath)); - elseif ~isempty(state.project.results.lastExport) && ... - isfield(state.project.results.lastExport, 'manifestPath') - lines{end + 1} = sprintf('Last manifest: %s', ... - char(state.project.results.lastExport.manifestPath)); - end -end - -function model = previewModel(state) - original = state.session.cache.previewSource; - enhanced = state.session.cache.previewResult; - mode = state.session.view.previewMode; - switch mode - case "Original" - imageData = original; - titleText = "Original Preview"; - case "Before | After" - imageData = image_enhance.userInterface.beforeAfterImage( ... - original, enhanced); - titleText = "Before | After"; - otherwise - imageData = enhanced; - titleText = "Enhanced Preview"; - end - roi = []; - index = state.session.selection.currentIndex; - if ~isempty(original) && mode ~= "Before | After" && ... - index >= 1 && index <= numel(state.project.annotations.items) - candidate = state.project.annotations.items(index).whiteRoi; - if numel(candidate) == 4 && all(isfinite(candidate)) && ... - all(candidate(3:4) > 0) && ~state.session.view.roiEditing - roi = candidate .* state.session.cache.previewScale; - end - end - model = struct("imageData", imageData, "title", titleText, ... - "whiteRoi", roi); -end - -function tf = roiInteractionVisible(state) - index = state.session.selection.currentIndex; - tf = state.session.view.roiEditing && ... - state.session.view.previewMode ~= "Before | After" && ... - ~isempty(state.session.cache.previewSource) && ... - index >= 1 && index <= numel(state.project.annotations.items); -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = dataSpec(value) - spec = struct(); - spec.Data = value; -end - -function spec = enabledSpec(value) - spec = struct("Enabled", logical(value)); -end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/renderImagePreview.m b/apps/image_measurement/image_enhance/+image_enhance/+userInterface/renderImagePreview.m deleted file mode 100644 index 2374fa067..000000000 --- a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/renderImagePreview.m +++ /dev/null @@ -1,29 +0,0 @@ -% Expected caller: the registered Image Enhance V2 renderer. Inputs are a -% target axes and prepared image/ROI model. Side effects touch only the axes. -function renderImagePreview(ax, model) - labkit.ui.plot.clear(ax, "ResetScale", true); - if isempty(model.imageData) - title(ax, char(model.title)); - box(ax, 'on'); - return; - end - if ndims(model.imageData) == 2 - imagesc(ax, model.imageData); - colormap(ax, gray(256)); - else - image(ax, model.imageData); - end - axis(ax, 'image'); - ax.YDir = 'reverse'; - if ~isempty(model.whiteRoi) - hold(ax, 'on'); - rectangle(ax, 'Position', model.whiteRoi, ... - 'EdgeColor', [1 1 1], 'LineWidth', 1.5, ... - 'HitTest', 'off', 'PickableParts', 'none'); - hold(ax, 'off'); - end - title(ax, char(model.title)); - xlabel(ax, ''); - ylabel(ax, ''); - box(ax, 'on'); -end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m b/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m new file mode 100644 index 000000000..578f7f3a8 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m @@ -0,0 +1,115 @@ +% App-owned implementation for image_enhance.workbench.buildLayout within the image_enhance product workflow. +function layout = buildLayout() +%BUILDLAYOUT Assemble the Image Enhance library, tools, history, and preview. +files = labkit.app.layout.fileList("sourceImages", ... + Label="Source images", ... + Filters=labkit.image.fileDialogFilter("IncludeAll", true), ... + SelectionMode="multiple", ... + Bind="project.inputs.sources", ... + SelectionBind="session.selection.sourceImages", ... + OnSelectionChanged=@image_enhance.sourceLibrary.selectPreview, ... + SourceRole="source-image", SourceIdPrefix="image", ... + ChooseLabel="Add images or folder", FolderLabel="Add folder", ... + ChooseTooltip="Add source images whose pixel values will be processed through a reproducible enhancement history.", ... + RecursiveFolderLabel="Add folder tree", ... + RemoveLabel="Remove selected", ClearLabel="Clear images", ... + EmptyText="No images loaded"); +libraryTab = labkit.app.layout.tab( ... + "libraryExport", "Library + Export", { ... + labkit.app.layout.section("librarySection", "Library", { ... + files, ... + labkit.app.layout.field("imageStatus", ... + Label="Status", Kind="readonly", Value="Images: 0")}), ... + labkit.app.layout.section("exportSection", "Batch Export", { ... + labkit.app.layout.field("outputFolder", ... + Label="Output folder", Kind="readonly", Value=""), ... + labkit.app.layout.field("exportFormat", ... + Label="Format", Kind="choice", ... + Choices=["PNG" "TIFF" "JPEG"], ... + Bind="project.parameters.exportFormat", ... + OnValueChanged=@image_enhance.resultFiles.changeFormat), ... + labkit.app.layout.group("exportActions", { ... + labkit.app.layout.button("chooseOutputFolder", ... + "Choose folder", ... + @image_enhance.resultFiles.chooseOutputFolder, ... + Tooltip="Choose the destination for enhanced images and their processing manifest."), ... + labkit.app.layout.button("exportImages", ... + "Export enhanced images", ... + @image_enhance.resultFiles.exportImages, ... + Tooltip="Replay the committed enhancement history and export every selected image in the chosen format.")}, ... + Layout="horizontal")}), ... + labkit.app.layout.section( ... + "exportDetailsSection", "Export Details", { ... + labkit.app.layout.statusPanel( ... + "exportDetails", Title="Export Details")})}); +toolsTab = labkit.app.layout.tab( ... + "toolsHistory", "Tools + History", { ... + labkit.app.layout.section("toolSection", "Toolbox", { ... + labkit.app.layout.field("batchMode", ... + Label="Batch shared processing", Kind="logical", ... + Bind="project.parameters.batchMode", ... + OnValueChanged= ... + @image_enhance.enhancementPipeline.changeBatchMode), ... + labkit.app.layout.field("batchModeStatus", ... + Label="Mode", Kind="readonly", ... + Value="Batch mode: all images share the same parameters and history."), ... + labkit.app.layout.field("toolKind", ... + Label="Interaction", Kind="choice", ... + Choices=image_enhance.enhancementPipeline.toolKinds(), ... + OnValueChanged= ... + @image_enhance.enhancementPipeline.changeTool), ... + labkit.app.layout.slider("toolAmount", ... + Label="Brightness (%)", Limits=[-100 100], Step=1, ... + OnValueChanged= ... + @image_enhance.enhancementPipeline.changeAmount), ... + labkit.app.layout.slider("toolSecondary", ... + Label="Contrast (%)", Limits=[-100 100], Step=1, ... + OnValueChanged= ... + @image_enhance.enhancementPipeline.changeSecondary), ... + labkit.app.layout.field("toolStatus", ... + Label="Status", Kind="readonly", ... + Value="Select an image, choose a tool, then apply it to history."), ... + labkit.app.layout.button("setWhiteRoi", ... + "Set white ROI", ... + @image_enhance.enhancementPipeline.setWhiteRoi, ... + Tooltip="Use the selected neutral ROI to estimate white balance without changing source pixels."), ... + labkit.app.layout.button("applyTool", ... + "Apply tool", ... + @image_enhance.enhancementPipeline.applyDraft, ... + Tooltip="Commit the current brightness, contrast, color, or white-balance operation to the enhancement history.")}), ... + labkit.app.layout.section("historySection", "Step History", { ... + labkit.app.layout.group("historyActions", { ... + labkit.app.layout.button("undoHistory", ... + "Undo history", ... + @image_enhance.enhancementPipeline.undo, ... + Tooltip="Remove the most recent committed enhancement step and recompute the preview."), ... + labkit.app.layout.button("resetHistory", ... + "Reset history", ... + @image_enhance.enhancementPipeline.reset, ... + Tooltip="Remove all enhancement steps and return the preview to original source pixels.")}, ... + Layout="horizontal"), ... + labkit.app.layout.dataTable("historyTable", ... + Title="Step History", Columns=["#" "Step" "Settings"]), ... + labkit.app.layout.field("historyStatus", ... + Label="Status", Kind="readonly", ... + Value="History steps: 0")}), ... + labkit.app.layout.section("currentImageSection", "Current Image", { ... + labkit.app.layout.dataTable("metricsTable", ... + Title="Current Image", Columns=["Metric" "Value"])})}); +logTab = labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("logPanel", Title="Log")})}); +interactions = {labkit.app.interaction.rectangle( ... + "whiteRoi", @image_enhance.imagePreview.changeWhiteRoi, ... + Axis="image", ViewportPolicy="preserve")}; +preview = labkit.app.layout.plotArea( ... + "preview", @image_enhance.imagePreview.draw, ... + Title="Preview", Layout="single", ... + AxisIds="image", AxisTitles="Enhanced Preview", ... + ViewModes=["Enhanced" "Original" "Before | After"], ... + OnValueChanged=@image_enhance.imagePreview.changeMode, ... + Interactions=interactions); +workspace = labkit.app.layout.workspace(preview, Title="Preview"); +layout = labkit.app.layout.workbench( ... + {libraryTab, toolsTab, logTab}, Workspace=workspace); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+workbench/present.m b/apps/image_measurement/image_enhance/+image_enhance/+workbench/present.m new file mode 100644 index 000000000..744cd69be --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+workbench/present.m @@ -0,0 +1,155 @@ +% App-owned implementation for image_enhance.workbench.present within the image_enhance product workflow. +function view = present(applicationState) +%PRESENT Build one complete Image Enhance workbench snapshot. +project = applicationState.project; +session = applicationState.session; +steps = image_enhance.analysisRun.activeSteps(applicationState); +hasImage = ~isempty(session.cache.item); +availability = ... + image_enhance.imagePreview.presentationData.toolAvailability( ... + applicationState, session.view.toolKind); +defaults = image_enhance.analysisRun.defaultStepValues( ... + session.view.toolKind); +view = labkit.app.view.Snapshot(); +view = view.value("imageStatus", sprintf( ... + "Images: %d | current steps: %d", ... + numel(project.inputs.sources), numel(steps))); +view = view.value("batchMode", project.parameters.batchMode); +view = view.value("batchModeStatus", modeStatus(project.parameters.batchMode)); +view = view.value("toolKind", session.view.toolKind); +view = view.value("toolAmount", session.view.toolAmount); +view = view.limits("toolAmount", defaults.amountLimits); +view = view.value("toolSecondary", session.view.toolSecondary); +view = view.limits("toolSecondary", defaults.secondaryLimits); +view = view.value("toolStatus", toolStatus( ... + applicationState, availability)); +view = view.enabled("setWhiteRoi", availability.canSetWhiteRoi); +view = view.enabled("applyTool", availability.canApply); +view = view.enabled("undoHistory", ~isempty(steps)); +view = view.enabled("resetHistory", ~isempty(steps)); +view = view.tableData("historyTable", ... + image_enhance.imagePreview.presentationData.historyTableData(steps), ... + Columns=["#" "Step" "Settings"]); +view = view.value("historyStatus", ... + sprintf("History steps: %d", numel(steps))); +view = view.tableData("metricsTable", metricData( ... + session.cache.item, session.cache.previewResult, numel(steps)), ... + Columns=["Metric" "Value"]); +view = view.value("outputFolder", project.parameters.outputFolder); +view = view.value("exportFormat", project.parameters.exportFormat); +view = view.enabled("exportImages", hasImage); +view = view.text("exportDetails", strjoin(string( ... + detailLines(applicationState, steps)), newline)); +view = view.value("preview", session.view.previewMode); +view = view.renderPlot("preview", previewModel(applicationState)); +if roiInteractionVisible(applicationState) + annotation = currentAnnotation(applicationState); + view = view.rectangle("whiteRoi", ... + annotation.whiteRoi .* session.cache.previewScale, ... + ImageSize=size(session.cache.previewSource), Enabled=true); +else + view = view.rectangle("whiteRoi", [0 0 0 0], ... + ImageSize=[], Enabled=false); +end +end + +function text = modeStatus(batchMode) +if batchMode + text = "Batch mode: all images share the same parameters and history."; +else + text = "Per-image mode: each image keeps its own parameters and history."; +end +end + +function text = toolStatus(applicationState, availability) +step = image_enhance.analysisRun.makeStep( ... + applicationState.session.view.toolKind, ... + applicationState.session.view.toolAmount, ... + applicationState.session.view.toolSecondary, 0); +if availability.isWhiteRoi + text = availability.status; +elseif applicationState.session.workflow.pendingDirty + text = "Previewing: " + string(step.label) + ... + " | " + string(availability.status); +else + text = "Ready: " + string(step.label) + ... + " | " + string(availability.status); +end +end + +function data = metricData(item, previewResult, stepCount) +data = image_enhance.imagePreview.presentationData.resultTableData( ... + item, previewResult, stepCount); +end + +function lines = detailLines(applicationState, steps) +sources = applicationState.project.inputs.sources; +item = applicationState.session.cache.item; +if isempty(sources) || isempty(item) + lines = {"Load one or more images to begin enhancement."}; + return; +end +lines = { ... + sprintf("Selected: %s", char(item.name)), ... + sprintf("Images registered: %d", numel(sources)), ... + sprintf("History steps: %d", numel(steps))}; +if ~isempty(steps) + lines{end + 1} = sprintf("Last step: %s", ... + char(image_enhance.analysisRun.describeStep(steps(end)))); +end +manifest = applicationState.project.results.resultManifestPath; +if strlength(manifest) > 0 + lines{end + 1} = "Last manifest: " + manifest; +end +end + +function model = previewModel(applicationState) +session = applicationState.session; +original = session.cache.previewSource; +enhanced = session.cache.previewResult; +switch session.view.previewMode + case "Original" + imageData = original; + titleText = "Original Preview"; + case "Before | After" + imageData = ... + image_enhance.imagePreview.presentationData.beforeAfterImage( ... + original, enhanced); + titleText = "Before | After"; + otherwise + imageData = enhanced; + titleText = "Enhanced Preview"; +end +roi = []; +if ~isempty(original) && session.view.previewMode ~= "Before | After" && ... + ~session.view.roiEditing + annotation = currentAnnotation(applicationState); + candidate = double(annotation.whiteRoi); + if numel(candidate) == 4 && all(isfinite(candidate)) && ... + all(candidate(3:4) > 0) + roi = candidate .* session.cache.previewScale; + end +end +model = struct("imageData", imageData, ... + "title", titleText, "whiteRoi", roi); +end + +function annotation = currentAnnotation(applicationState) +annotation = image_enhance.enhancementAnnotations.empty(); +index = applicationState.session.selection.currentIndex; +sources = applicationState.project.inputs.sources; +if index < 1 || index > numel(sources) + return; +end +annotation = image_enhance.sourceLibrary.annotationForSource( ... + applicationState.project.annotations.items, sources(index).id); +end + +function value = roiInteractionVisible(applicationState) +index = applicationState.session.selection.currentIndex; +value = applicationState.session.view.roiEditing && ... + applicationState.session.view.previewMode ~= "Before | After" && ... + ~isempty(applicationState.session.cache.previewSource) && ... + index >= 1 && ... + index <= numel(applicationState.project.inputs.sources); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/createSession.m b/apps/image_measurement/image_enhance/+image_enhance/createSession.m index 4971349db..511608e56 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/createSession.m +++ b/apps/image_measurement/image_enhance/+image_enhance/createSession.m @@ -1,16 +1,17 @@ % Rebuild transient selection, draft controls, and the selected preview from -% one validated Image Enhance project after Runtime V2 resolves sources. -function session = createSession(project) +% one validated Image Enhance project after App SDK runtime resolves sources. +function session = createSession(project, context) index = double(~isempty(project.inputs.sources)); - kinds = image_enhance.userInterface.toolKinds(); + kinds = image_enhance.imagePreview.presentationData.toolKinds(); defaults = image_enhance.analysisRun.defaultStepValues(kinds{1}); cache = emptyCache(); if index > 0 - cache = loadSelectedCache(project.inputs.sources(index), cache); + cache = loadSelectedCache(project.inputs.sources(index), cache, context); cache = rebuildSelectedResult(project, index, cache); end session = struct( ... - "selection", struct("currentIndex", index), ... + "selection", struct("currentIndex", index, ... + "sourceImages", labkit.app.event.ListSelection()), ... "workflow", struct("pendingDirty", false), ... "view", struct("previewMode", "Enhanced", ... "toolKind", string(kinds{1}), ... @@ -26,22 +27,26 @@ end if project.parameters.batchMode steps = project.annotations.sharedSteps; + whiteRoi = []; else - steps = project.annotations.items(index).steps; + annotation = image_enhance.sourceLibrary.annotationForSource( ... + project.annotations.items, project.inputs.sources(index).id); + steps = annotation.steps; + whiteRoi = annotation.whiteRoi; end cache.previewResult = image_enhance.analysisRun.previewResult( ... cache.previewSource, steps, ... - project.annotations.items(index).whiteRoi, cache.previewScale); + whiteRoi, cache.previewScale); cache.previewResultKey = "restored"; end -function cache = loadSelectedCache(source, cache) +function cache = loadSelectedCache(source, cache, context) loaded = image_enhance.sourceFiles.readImages( ... - labkit.ui.runtime.sourcePaths(source)); + context.resolveSourcePaths(source)); if isempty(loaded) return; end - [preview, scale] = image_enhance.userInterface.previewImage( ... + [preview, scale] = image_enhance.imagePreview.presentationData.previewImage( ... loaded(1).image); cache.sourceId = string(source.id); cache.item = loaded(1); diff --git a/apps/image_measurement/image_enhance/+image_enhance/definition.m b/apps/image_measurement/image_enhance/+image_enhance/definition.m index 728aef005..36c864f65 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/definition.m +++ b/apps/image_measurement/image_enhance/+image_enhance/definition.m @@ -1,23 +1,12 @@ % App-owned runtime definition for labkit_ImageEnhance_app. Expected caller: % the public app entrypoint. Output is a declarative LabKit app definition; % side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_ImageEnhance_app", ... - "Id", "image_enhance", ... - "Title", "Paper Image Enhance", ... - "DisplayName", "Image Enhance", ... - "Family", "Image Measurement", ... - "AppVersion", "1.6.7", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "image", ">=2.0 <3"), ... - "Project", image_enhance.projectSpec(), ... - "CreateSession", @image_enhance.createSession, ... - "Layout", @image_enhance.userInterface.buildWorkbenchLayout, ... - "Actions", image_enhance.definitionActions(), ... - "Present", @image_enhance.userInterface.presentWorkbench, ... - "Renderers", struct( ... - "imagePreview", @image_enhance.userInterface.renderImagePreview), ... - "DebugSample", @image_enhance.debug.writeSamplePack); +function app = definition() + app = labkit.app.Definition(Entrypoint="labkit_ImageEnhance_app", ... + AppId="image_enhance", Title="Paper Image Enhance", DisplayName="Image Enhance", ... + Family="Image Measurement", AppVersion="1.7.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2", "image", ">=2.0 <3"), ... + ProjectSchema=image_enhance.projectSpec(), CreateSession=@image_enhance.createSession, ... + Workbench=image_enhance.workbench.buildLayout(), PresentWorkbench=@image_enhance.workbench.present, ... + BuildDebugSample=@image_enhance.debug.writeSamplePack); end diff --git a/apps/image_measurement/image_enhance/+image_enhance/definitionActions.m b/apps/image_measurement/image_enhance/+image_enhance/definitionActions.m deleted file mode 100644 index 81f0694e6..000000000 --- a/apps/image_measurement/image_enhance/+image_enhance/definitionActions.m +++ /dev/null @@ -1,457 +0,0 @@ -% App-owned V2 action registry for Image Enhance. Handlers receive canonical -% state/events/services and own sources, histories, ROI annotations, previews, -% and exports without reading UI controls or retaining graphics handles. -function actions = definitionActions() - actions = struct( ... - "sourceImagesChosen", @onSourceImagesChosen, ... - "removeImages", @onRemoveImages, ... - "clearImages", @onClearImages, ... - "imageSelectionChanged", @onImageSelectionChanged, ... - "batchModeChanged", @onBatchModeChanged, ... - "previewModeChanged", @onPreviewModeChanged, ... - "toolChanged", @onToolChanged, ... - "toolSettingChanged", @onToolSettingChanged, ... - "setWhiteRoi", @onSetWhiteRoi, ... - "whiteRoiEdited", @onWhiteRoiEdited, ... - "applyTool", @onApplyTool, ... - "undoHistory", @onUndoHistory, ... - "resetHistory", @onResetHistory, ... - "exportSettingChanged", @onExportSettingChanged, ... - "chooseOutputFolder", @onChooseOutputFolder, ... - "exportImages", @onExportImages); -end - -function state = onSourceImagesChosen(state, event, services) - paths = services.events.paths(event, "files"); - added = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = added; - end - if isempty(paths) - state = services.workflow.log(state, "Image selection cancelled."); - return; - end - [sources, annotations] = reconcileSources(state, paths, services); - state.project.inputs.sources = sources; - state.project.annotations.items = annotations; - state.session.selection.currentIndex = selectedIndex(sources, added); - state.project.parameters.outputFolder = string( ... - services.dialogs.defaultOutputFolder(paths, "image_enhance", ... - state.project.parameters.outputFolder)); - state.session.workflow.pendingDirty = false; - state.session.view.roiEditing = false; - state = invalidateResultsAndPreview(state); - state = image_enhance.sourceFiles.ensureCurrentPreview(state, services); - state = rebuildPreview(state); - state = services.workflow.log(state, sprintf( ... - 'Registered %d image source(s); loaded the selected preview only.', ... - numel(sources))); -end - -function state = onRemoveImages(state, event, services) - sources = state.project.inputs.sources; - indices = services.events.indices(event, "removedFiles", numel(sources)); - if isempty(indices) - return; - end - removedIds = string({sources(indices).id}); - sources(indices) = []; - annotations = state.project.annotations.items; - annotations(ismember(string({annotations.sourceId}), removedIds)) = []; - state.project.inputs.sources = sources; - state.project.annotations.items = annotations; - state.session.selection.currentIndex = min( ... - state.session.selection.currentIndex, numel(sources)); - if isempty(sources) - state.session.selection.currentIndex = 0; - end - state.session.workflow.pendingDirty = false; - state.session.view.roiEditing = false; - state = invalidateResultsAndPreview(state); - state = image_enhance.sourceFiles.ensureCurrentPreview(state, services); - state = rebuildPreview(state); - state = services.workflow.log(state, sprintf( ... - 'Removed image source(s); %d remaining.', numel(sources))); -end - -function state = onClearImages(state, ~, services) - state.project.inputs.sources = labkit.ui.runtime.emptySourceRecords(); - state.project.annotations.items = repmat( ... - image_enhance.enhancementAnnotations.empty(), 0, 1); - state.project.annotations.sharedSteps = repmat( ... - image_enhance.analysisRun.emptyStep(), 0, 1); - state.session.selection.currentIndex = 0; - state.session.workflow.pendingDirty = false; - state.session.view.roiEditing = false; - state = invalidateResultsAndPreview(state); - state = services.workflow.log(state, ... - "Cleared image sources and enhancement history."); -end - -function state = onImageSelectionChanged(state, event, services) - indices = services.events.indices(event, "selectedFiles", ... - numel(state.project.inputs.sources)); - if isempty(indices) - return; - end - state.session.selection.currentIndex = indices(1); - state.session.workflow.pendingDirty = false; - state.session.view.roiEditing = false; - state = invalidatePreview(state); - state = image_enhance.sourceFiles.ensureCurrentPreview(state, services); - state = rebuildPreview(state); -end - -function state = onBatchModeChanged(state, event, ~) - state.project.parameters.batchMode = logical(event.value); - state.session.workflow.pendingDirty = false; - state.session.view.roiEditing = false; - state = invalidateResultsAndProcessedPreview(state); - state = rebuildPreview(state); -end - -function state = onPreviewModeChanged(state, event, ~) - value = string(event.value); - if isscalar(value) && any(value == ["Enhanced", "Original", "Before | After"]) - state.session.view.previewMode = value; - end -end - -function state = onToolChanged(state, event, ~) - value = string(event.value); - if ~isscalar(value) || ~any(value == string(image_enhance.userInterface.toolKinds())) - return; - end - defaults = image_enhance.analysisRun.defaultStepValues(value); - state.session.view.toolKind = value; - state.session.view.toolAmount = defaults.amount; - state.session.view.toolSecondary = defaults.secondary; - state.session.view.roiEditing = false; - state.session.workflow.pendingDirty = true; - state = invalidateResultsAndProcessedPreview(state); - state = rebuildPreview(state); -end - -function state = onToolSettingChanged(state, event, ~) - defaults = image_enhance.analysisRun.defaultStepValues( ... - state.session.view.toolKind); - value = finiteScalar(event.value, 0); - if string(event.id) == "toolSecondary" - state.session.view.toolSecondary = image_enhance.userInterface.clampValue( ... - value, defaults.secondaryLimits); - else - state.session.view.toolAmount = image_enhance.userInterface.clampValue( ... - value, defaults.amountLimits); - end - state.session.workflow.pendingDirty = true; - state = invalidateResultsAndProcessedPreview(state); - state = rebuildPreview(state); -end - -function state = onSetWhiteRoi(state, ~, services) - if ~hasCurrentSource(state) || state.project.parameters.batchMode - services.dialogs.alert( ... - "White ROI calibration uses per-image mode only.", ... - "White ROI unavailable"); - return; - end - index = currentIndex(state); - annotation = state.project.annotations.items(index); - if ~hasWhiteRoi(annotation) - annotation.whiteRoi = image_enhance.userInterface.defaultWhiteRoi( ... - size(state.session.cache.item.image)); - state.project.annotations.items(index) = annotation; - end - state.session.view.roiEditing = true; - state.session.view.previewMode = "Original"; - state.session.workflow.pendingDirty = true; - state = invalidateResultsAndProcessedPreview(state); -end - -function state = onWhiteRoiEdited(state, event, ~) - if ~hasCurrentSource(state) - return; - end - position = double(event.value); - if numel(position) ~= 4 || any(~isfinite(position)) || ... - any(position(3:4) <= 0) - return; - end - scale = max(eps, state.session.cache.previewScale); - state.project.annotations.items(currentIndex(state)).whiteRoi = ... - position ./ scale; - state.session.workflow.pendingDirty = true; - state = invalidateResultsAndProcessedPreview(state); -end - -function state = onApplyTool(state, ~, services) - availability = image_enhance.userInterface.toolAvailability( ... - state, state.session.view.toolKind); - if ~availability.canApply - services.dialogs.alert(availability.status, "Tool unavailable"); - return; - end - step = currentToolStep(state); - steps = image_enhance.analysisRun.activeSteps(state); - steps(end + 1, 1) = step; - state = image_enhance.analysisRun.setActiveSteps(state, steps); - state.session.workflow.pendingDirty = false; - state.session.view.roiEditing = false; - state = invalidateResultsAndProcessedPreview(state); - state = rebuildPreview(state); - state = services.workflow.log(state, "Applied tool: " + string(step.label)); -end - -function state = onUndoHistory(state, ~, services) - steps = image_enhance.analysisRun.activeSteps(state); - if isempty(steps) - return; - end - removed = steps(end); - steps(end) = []; - state = image_enhance.analysisRun.setActiveSteps(state, steps); - state.session.workflow.pendingDirty = false; - state = invalidateResultsAndProcessedPreview(state); - state = rebuildPreview(state); - state = services.workflow.log(state, "Undid history step: " + string(removed.label)); -end - -function state = onResetHistory(state, ~, services) - if isempty(image_enhance.analysisRun.activeSteps(state)) - return; - end - state = image_enhance.analysisRun.setActiveSteps(state, repmat( ... - image_enhance.analysisRun.emptyStep(), 0, 1)); - state.session.workflow.pendingDirty = false; - state = invalidateResultsAndProcessedPreview(state); - state = rebuildPreview(state); - state = services.workflow.log(state, "Reset enhancement history."); -end - -function state = onExportSettingChanged(state, event, ~) - value = upper(string(event.value)); - if isscalar(value) && any(value == ["PNG", "TIFF", "JPEG"]) - state.project.parameters.exportFormat = value; - state = invalidateResults(state); - end -end - -function state = onChooseOutputFolder(state, ~, services) - [folder, cancelled] = services.dialogs.outputFolder( ... - "Select image enhancement export folder", ... - state.project.parameters.outputFolder); - if cancelled - state = services.workflow.log(state, "Export folder selection cancelled."); - return; - end - state.project.parameters.outputFolder = string(folder); - state = invalidateResults(state); -end - -function state = onExportImages(state, ~, services) - if isempty(state.project.inputs.sources) - services.dialogs.alert("Load images before exporting.", "No images loaded"); - return; - end - try - items = loadExportItems(state); - [steps, itemSteps] = exportSteps(state); - opts = struct("outputFolder", state.project.parameters.outputFolder, ... - "format", state.project.parameters.exportFormat, ... - "itemSteps", {itemSteps}); - task = image_enhance.resultFiles.exportTask(items, steps, opts); - if ~isempty(state.project.results.lastExport) && ... - state.project.results.lastExportFingerprint == task.fingerprint - state = services.workflow.log(state, ... - "Enhanced export is already up to date; skipped duplicate write."); - return; - end - payload = image_enhance.resultFiles.writeOutputs(items, steps, opts); - outputs = resultOutputs(payload.results, services); - spec = struct("Outputs", outputs, ... - "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, ... - "Summary", struct("imageCount", numel(items), ... - "savedCount", sum(string({payload.results.status}) == "saved")), ... - "ManifestName", "image_enhance.labkit.json"); - [manifestPath, ~] = services.results.writeManifest( ... - state.project.parameters.outputFolder, spec); - catch ME - services.diagnostics.report("Export failed", ME); - services.dialogs.alert(ME.message, "Export failed"); - return; - end - payload.resultManifestPath = string(manifestPath); - state.project.results.lastExport = payload; - state.project.results.lastExportFingerprint = task.fingerprint; - state.project.results.resultManifestPath = string(manifestPath); - statuses = string({payload.results.status}); - state = services.workflow.log(state, sprintf( ... - 'Exported %d image(s), %d failed. Manifest: %s', ... - sum(statuses == "saved"), sum(statuses == "failed"), ... - char(payload.manifestPath))); -end - -function [sources, annotations] = reconcileSources(state, paths, services) - paths = labkit.image.normalizePaths(paths); - oldSources = state.project.inputs.sources; - oldAnnotations = state.project.annotations.items; - sources = services.project.reconcileSources( ... - oldSources, paths, "source-image", "image", true); - annotations = repmat(image_enhance.enhancementAnnotations.empty(), ... - numel(paths), 1); - for k = 1:numel(paths) - source = sources(k); - annotationIndex = find(string({oldAnnotations.sourceId}) == ... - string(source.id), 1); - annotation = image_enhance.enhancementAnnotations.empty(); - annotation.sourceId = string(source.id); - if ~isempty(annotationIndex) - annotation = oldAnnotations(annotationIndex); - end - annotations(k) = annotation; - end -end - -function index = sourceIndexForPath(sources, path) - index = []; - if ~isempty(sources) - index = find(labkit.ui.runtime.sourcePaths(sources) == ... - string(path), 1); - end -end - -function index = selectedIndex(sources, added) - index = 1; - if isempty(added) - return; - end - match = sourceIndexForPath(sources, added(1)); - if ~isempty(match) - index = match; - end -end - -function state = rebuildPreview(state) - if isempty(state.session.cache.previewSource) - state.session.cache.previewResult = []; - state.session.cache.previewResultKey = ""; - return; - end - steps = image_enhance.analysisRun.activeSteps(state); - availability = image_enhance.userInterface.toolAvailability( ... - state, state.session.view.toolKind); - includePending = state.session.workflow.pendingDirty && ... - availability.canPreviewPending; - previewSteps = steps; - if includePending - previewSteps(end + 1, 1) = currentToolStep(state); - end - key = strjoin(string({previewSteps.label}), "|") + ... - "#" + string(numel(previewSteps)) + ... - "#" + string(includePending); - if state.session.cache.previewResultKey == key && ... - ~isempty(state.session.cache.previewResult) - return; - end - roi = state.project.annotations.items(currentIndex(state)).whiteRoi; - state.session.cache.previewResult = image_enhance.analysisRun.previewResult( ... - state.session.cache.previewSource, previewSteps, roi, ... - state.session.cache.previewScale); - state.session.cache.previewResultKey = key; -end - -function step = currentToolStep(state) - step = image_enhance.analysisRun.makeStep( ... - state.session.view.toolKind, state.session.view.toolAmount, ... - state.session.view.toolSecondary, 0); -end - -function items = loadExportItems(state) - sources = state.project.inputs.sources; - paths = labkit.ui.runtime.sourcePaths(sources); - items = image_enhance.sourceFiles.readImages(paths); - for k = 1:numel(items) - items(k).whiteRoi = state.project.annotations.items(k).whiteRoi; - end -end - -function [steps, itemSteps] = exportSteps(state) - if state.project.parameters.batchMode - steps = state.project.annotations.sharedSteps; - itemSteps = {}; - else - steps = repmat(image_enhance.analysisRun.emptyStep(), 0, 1); - itemSteps = {state.project.annotations.items.steps}.'; - end -end - -function outputs = resultOutputs(results, services) - outputs = services.results.emptyOutputs(); - for k = 1:numel(results) - [~, name, extension] = fileparts(results(k).outputPath); - outputs(end + 1, 1) = services.results.output("enhanced-" + string(k), ... - "enhanced-image", string(name) + string(extension), ... - mediaType(extension), results(k).status, results(k).message); - end -end - -function value = mediaType(extension) - switch lower(string(extension)) - case {".jpg", ".jpeg"} - value = "image/jpeg"; - case {".tif", ".tiff"} - value = "image/tiff"; - otherwise - value = "image/png"; - end -end - -function state = invalidateResultsAndPreview(state) - state = invalidateResults(state); - state = invalidatePreview(state); -end - -function state = invalidateResultsAndProcessedPreview(state) - state = invalidateResults(state); - state.session.cache.previewResult = []; - state.session.cache.previewResultKey = ""; -end - -function state = invalidateResults(state) - state.project.results.lastExport = []; - state.project.results.lastExportFingerprint = ""; - state.project.results.resultManifestPath = ""; -end - -function state = invalidatePreview(state) - state.session.cache.sourceId = ""; - state.session.cache.item = []; - state.session.cache.previewSource = []; - state.session.cache.previewScale = 1; - state.session.cache.previewResult = []; - state.session.cache.previewResultKey = ""; -end - -function tf = hasCurrentSource(state) - index = currentIndex(state); - tf = index >= 1 && index <= numel(state.project.inputs.sources) && ... - index <= numel(state.project.annotations.items) && ... - ~isempty(state.session.cache.item); -end - -function index = currentIndex(state) - index = state.session.selection.currentIndex; -end - -function tf = hasWhiteRoi(annotation) - roi = double(annotation.whiteRoi); - tf = numel(roi) == 4 && all(isfinite(roi)) && all(roi(3:4) > 0); -end - -function value = finiteScalar(value, fallback) - value = double(value); - if isempty(value) || ~isscalar(value) || ~isfinite(value) - value = fallback; - end -end diff --git a/apps/image_measurement/image_enhance/+image_enhance/projectSpec.m b/apps/image_measurement/image_enhance/+image_enhance/projectSpec.m index b6ff55540..ade758967 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/projectSpec.m +++ b/apps/image_measurement/image_enhance/+image_enhance/projectSpec.m @@ -1,17 +1,13 @@ -% App-owned durable Image Enhance contract. Runtime V2 uses this one entry +% App-owned durable Image Enhance contract. App SDK runtime uses this one entry % for version-1 project creation and validation; no migration is required. function spec = projectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", []); + spec = labkit.app.project.Schema(Version=1,Create=@createProject,Validate=@validateProject); end function project = createProject() project = struct(); project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + labkit.app.project.emptySourceRecords()); project.parameters = struct( ... "batchMode", true, ... "exportFormat", "PNG", ... diff --git a/apps/image_measurement/image_enhance/labkit_ImageEnhance_app.m b/apps/image_measurement/image_enhance/labkit_ImageEnhance_app.m index f8bb6831d..8bb4c9376 100644 --- a/apps/image_measurement/image_enhance/labkit_ImageEnhance_app.m +++ b/apps/image_measurement/image_enhance/labkit_ImageEnhance_app.m @@ -1,6 +1,5 @@ function varargout = labkit_ImageEnhance_app(varargin) %LABKIT_IMAGEENHANCE_APP Image enhancement and color matching app for figures. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @image_enhance.definition, varargin{:}); + [varargout{1:nargout}] = image_enhance.definition().launch(varargin{:}); end diff --git a/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m b/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m index 66b519129..2923f0bfb 100644 --- a/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m +++ b/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m @@ -21,7 +21,7 @@ % step - Scalar structure created by image_match.analysisRun.makeStep. % % Step Fields: -% matchMethod - One label returned by image_match.userInterface.matchMethods. +% matchMethod - One label returned by image_match.imagePreview.presentationData.matchMethods. % Punctuation and spaces are ignored when matching labels. An unknown or % empty value uses "Balanced". % amount - Overall blend percentage. Zero returns the source; 100 returns the @@ -58,7 +58,7 @@ % assert(isequal(size(outputImage), [8 8 3])) % % See also image_match.analysisRun.applyPipeline, -% image_match.analysisRun.makeStep, image_match.userInterface.matchMethods +% image_match.analysisRun.makeStep, image_match.imagePreview.presentationData.matchMethods inputImage = labkit.image.ensureRgb(labkit.image.im2double(inputImage)); inputImage = min(max(inputImage, 0), 1); diff --git a/apps/image_measurement/image_match/+image_match/+analysisRun/applyPipeline.m b/apps/image_measurement/image_match/+image_match/+analysisRun/applyPipeline.m index a44a3b1dd..77ab46b2b 100644 --- a/apps/image_measurement/image_match/+image_match/+analysisRun/applyPipeline.m +++ b/apps/image_measurement/image_match/+image_match/+analysisRun/applyPipeline.m @@ -53,7 +53,7 @@ % assert(numel(processed) == 2 && isequal(size(processed{2}), [4 5 3])) % % See also image_match.analysisRun.applyMatch, -% image_match.analysisRun.makeStep, image_match.userInterface.matchMethods +% image_match.analysisRun.makeStep, image_match.imagePreview.presentationData.matchMethods images = normalizeImages(images); if nargin < 3 diff --git a/apps/image_measurement/image_match/+image_match/+debug/writeSamplePack.m b/apps/image_measurement/image_match/+image_match/+debug/writeSamplePack.m index 78e4d01cb..7dd9d993d 100644 --- a/apps/image_measurement/image_match/+image_match/+debug/writeSamplePack.m +++ b/apps/image_measurement/image_match/+image_match/+debug/writeSamplePack.m @@ -1,19 +1,18 @@ -% Expected caller: image_match.definitionActions during debug launch and unit tests. Input +% Expected caller: image_match.definition during debug launch and unit tests. Input % is a LabKit debug context. Output is a deterministic synthetic reference % matching sample pack. Side effects: writes anonymous debug images and % records a session manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Image Match debug image files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "image_match"); - imageFolder = fullfile(char(folders.sampleFolder), "images"); - ensureFolder(imageFolder); - - referencePath = string(fullfile(imageFolder, "match_reference.png")); - sourceWarmPath = string(fullfile(imageFolder, "match_source_warm_shift.png")); - sourceDimPath = string(fullfile(imageFolder, "match_source_dim_shift.png")); - edgePath = string(fullfile(imageFolder, "match_valid_size_mismatch.png")); - malformedPath = string(fullfile(imageFolder, "match_malformed_not_image.png")); + referencePath = sampleContext.samplePath("image_match/reference.png"); + sourceWarmPath = sampleContext.samplePath("image_match/warm.png"); + sourceDimPath = sampleContext.samplePath("image_match/dim.png"); + edgePath = sampleContext.samplePath("image_match/size_mismatch.png"); + malformedPath = sampleContext.samplePath("image_match/malformed.png"); reference = sceneImage("reference", [240 320]); imwrite(toUint8(reference), char(referencePath)); @@ -22,25 +21,25 @@ imwrite(toUint8(sceneImage("warm", [180 260])), char(edgePath)); writeTextFile(malformedPath, "not an image payload" + newline); - representativeFiles = [sourceWarmPath; sourceDimPath]; - pack = struct( ... - "sampleFolder", folders.sampleFolder, ... - "outputFolder", folders.outputFolder, ... - "referenceFile", referencePath, ... - "representativeFiles", representativeFiles, ... - "boundaryFiles", struct("validEdge", edgePath, "malformed", malformedPath)); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_ImageMatch_app", ... - "description", "Anonymous reference-match boundary image pack.", ... - "inputs", struct( ... - "referenceImage", referencePath, ... - "representativeSourceImages", representativeFiles, ... - "validEdgeImage", edgePath, ... - "malformedImage", malformedPath), ... - "outputFolder", folders.outputFolder); - pack.manifest = manifest; - recordManifest(debugLog, manifest); + project = image_match.projectSpec().Create(); + project.inputs.reference = sampleContext.sourceRecord( ... + "reference1", "reference-image", referencePath, true); + project.inputs.sources = [ ... + sampleContext.sourceRecord( ... + "image1", "source-image", sourceWarmPath, true), ... + sampleContext.sourceRecord( ... + "image2", "source-image", sourceDimPath, true)]; + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-image-match", InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact( ... + "reference", "reference-image", referencePath), ... + sampleContext.artifact("warm", "source-image", sourceWarmPath), ... + sampleContext.artifact("dim", "source-image", sourceDimPath), ... + sampleContext.artifact( ... + "sizeMismatch", "boundaryInput", edgePath), ... + sampleContext.artifact("malformed", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function image = sceneImage(kind, dims) @@ -69,30 +68,6 @@ image = uint8(round(255 .* min(max(image, 0), 1))); end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 @@ -102,9 +77,3 @@ function writeTextFile(filepath, text) cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); end - -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/apps/image_measurement/image_match/+image_match/+userInterface/beforeAfterImage.m b/apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/beforeAfterImage.m similarity index 100% rename from apps/image_measurement/image_match/+image_match/+userInterface/beforeAfterImage.m rename to apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/beforeAfterImage.m diff --git a/apps/image_measurement/image_match/+image_match/+userInterface/detailLines.m b/apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/detailLines.m similarity index 100% rename from apps/image_measurement/image_match/+image_match/+userInterface/detailLines.m rename to apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/detailLines.m diff --git a/apps/image_measurement/image_match/+image_match/+userInterface/historyTableData.m b/apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/historyTableData.m similarity index 100% rename from apps/image_measurement/image_match/+image_match/+userInterface/historyTableData.m rename to apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/historyTableData.m diff --git a/apps/image_measurement/image_match/+image_match/+userInterface/matchFlowLines.m b/apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/matchFlowLines.m similarity index 100% rename from apps/image_measurement/image_match/+image_match/+userInterface/matchFlowLines.m rename to apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/matchFlowLines.m diff --git a/apps/image_measurement/image_match/+image_match/+userInterface/matchMethods.m b/apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/matchMethods.m similarity index 100% rename from apps/image_measurement/image_match/+image_match/+userInterface/matchMethods.m rename to apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/matchMethods.m diff --git a/apps/image_measurement/image_match/+image_match/+userInterface/previewImage.m b/apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/previewImage.m similarity index 100% rename from apps/image_measurement/image_match/+image_match/+userInterface/previewImage.m rename to apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/previewImage.m diff --git a/apps/image_measurement/image_match/+image_match/+userInterface/resultTableData.m b/apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/resultTableData.m similarity index 100% rename from apps/image_measurement/image_match/+image_match/+userInterface/resultTableData.m rename to apps/image_measurement/image_match/+image_match/+imagePreview/+presentationData/resultTableData.m diff --git a/apps/image_measurement/image_match/+image_match/+imagePreview/changeMode.m b/apps/image_measurement/image_match/+image_match/+imagePreview/changeMode.m new file mode 100644 index 000000000..0c549176b --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+imagePreview/changeMode.m @@ -0,0 +1,13 @@ +% App-owned implementation for image_match.imagePreview.changeMode within the image_match product workflow. +function applicationState = changeMode( ... + applicationState, previewMode, callbackContext) +%CHANGEMODE Store the selected preview mode declared by the plot area. +previewMode = string(previewMode); +if isscalar(previewMode) && ... + any(previewMode == ["Matched" "Original" "Before | After"]) + applicationState.session.view.previewMode = previewMode; +else + callbackContext.appendStatus( ... + "Ignored an unsupported image-match preview mode."); +end +end diff --git a/apps/image_measurement/image_match/+image_match/+imagePreview/draw.m b/apps/image_measurement/image_match/+image_match/+imagePreview/draw.m new file mode 100644 index 000000000..3457608b1 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+imagePreview/draw.m @@ -0,0 +1,14 @@ +% App-owned implementation for image_match.imagePreview.draw within the image_match product workflow. +function draw(axesById, model) +%DRAW Render one selected Image Match preview. +axesHandle = axesById.image; +labkit.app.plot.clearAxes(axesHandle); +title(axesHandle, model.title); +if isempty(model.imageData) + return; +end +image(axesHandle, model.imageData); +axis(axesHandle, "image"); +axis(axesHandle, "off"); +title(axesHandle, model.title); +end diff --git a/apps/image_measurement/image_match/+image_match/+imagePreview/present.m b/apps/image_measurement/image_match/+image_match/+imagePreview/present.m new file mode 100644 index 000000000..3f509303c --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+imagePreview/present.m @@ -0,0 +1,28 @@ +% App-owned implementation for image_match.imagePreview.present within the image_match product workflow. +function view = present( ... + steps, currentItem, previewSource, previewResult, previewMode) +%PRESENT Build the image-preview snapshot from explicit display inputs. +view = labkit.app.view.Snapshot(); +view = view.tableData("historyTable", ... + image_match.imagePreview.presentationData.historyTableData(steps), ... + Columns=["#" "Step" "Settings"]); +view = view.tableData("metricsTable", ... + image_match.imagePreview.presentationData.resultTableData( ... + currentItem, previewResult, numel(steps)), ... + Columns=["Metric" "Value"]); +switch previewMode + case "Original" + imageData = previewSource; + titleText = "Original Preview"; + case "Before | After" + imageData = ... + image_match.imagePreview.presentationData.beforeAfterImage( ... + previewSource, previewResult); + titleText = "Before | After"; + otherwise + imageData = previewResult; + titleText = "Matched Preview"; +end +view = view.renderPlot("preview", struct( ... + "imageData", imageData, "title", titleText)); +end diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/apply.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/apply.m new file mode 100644 index 000000000..c0d77e783 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/apply.m @@ -0,0 +1,28 @@ +% App-owned implementation for image_match.matchPipeline.apply within the image_match product workflow. +function applicationState = apply(applicationState, callbackContext) +%APPLY Append the selected semantic match step to this project's pipeline. +cache = applicationState.session.cache; +if isempty(cache.referenceItem) || isempty(cache.currentItem) + callbackContext.alert( ... + "Load source and reference images before applying a match.", ... + "Match unavailable"); + return +end +parameters = applicationState.project.parameters; +step = image_match.analysisRun.makeStep( ... + parameters.matchMethod, ... + parameters.matchStrength, ... + parameters.toneStrength, ... + parameters.colorStrength); +steps = applicationState.project.annotations.steps; +steps = steps(:); +steps(end + 1, 1) = step; + +applicationState.project.annotations.steps = steps; +applicationState.session.workflow.pendingDirty = false; +applicationState = ... + image_match.matchPipeline.invalidateResults(applicationState); +applicationState.session.cache = ... + image_match.matchPipeline.refreshPreview(cache, steps); +callbackContext.appendStatus("Applied match: " + string(step.label)); +end diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/invalidateResults.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/invalidateResults.m new file mode 100644 index 000000000..519fec894 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/invalidateResults.m @@ -0,0 +1,7 @@ +% App-owned implementation for image_match.matchPipeline.invalidateResults within the image_match product workflow. +function applicationState = invalidateResults(applicationState) +%INVALIDATERESULTS Clear export identity after match inputs change. +applicationState.project.results.lastExport = []; +applicationState.project.results.lastExportFingerprint = ""; +applicationState.project.results.resultManifestPath = ""; +end diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/methods.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/methods.m new file mode 100644 index 000000000..053feabd1 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/methods.m @@ -0,0 +1,4 @@ +% App-owned implementation for image_match.matchPipeline.methods within the image_match product workflow. +function values = methods() +values = ["Balanced" "Histogram match" "Color transfer"]; +end diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/rebuildPreview.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/rebuildPreview.m new file mode 100644 index 000000000..1c3a4ad25 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/rebuildPreview.m @@ -0,0 +1,15 @@ +% App-owned implementation for image_match.matchPipeline.rebuildPreview within the image_match product workflow. +function applicationState = rebuildPreview(applicationState) +%REBUILDPREVIEW Replay committed history plus the current pending draft. +steps = applicationState.project.annotations.steps; +steps = steps(:); +if applicationState.session.workflow.pendingDirty + parameters = applicationState.project.parameters; + steps(end + 1, 1) = image_match.analysisRun.makeStep( ... + parameters.matchMethod, parameters.matchStrength, ... + parameters.toneStrength, parameters.colorStrength); +end +applicationState.session.cache = ... + image_match.matchPipeline.refreshPreview( ... + applicationState.session.cache, steps); +end diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/refreshPreview.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/refreshPreview.m new file mode 100644 index 000000000..4fab65a63 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/refreshPreview.m @@ -0,0 +1,22 @@ +% App-owned implementation for image_match.matchPipeline.refreshPreview within the image_match product workflow. +function cache = refreshPreview(cache, steps) +%REFRESHPREVIEW Replay explicit match steps into the transient preview cache. +% +% Expected callers: matchPipeline transaction callbacks. Inputs contain only +% the transient image cache and ordered semantic steps. The returned cache +% preserves unrelated fields and replaces preview images when both current +% and reference inputs are available. +if isempty(cache.currentItem) || isempty(cache.referenceItem) + return +end + +cache.previewSource = ... + image_match.imagePreview.presentationData.previewImage( ... + cache.currentItem.image); +cache.previewReference = ... + image_match.imagePreview.presentationData.previewImage( ... + cache.referenceItem.image); +processed = image_match.analysisRun.applyPipeline( ... + {cache.previewSource}, steps, cache.previewReference); +cache.previewResult = processed{1}; +end diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/reset.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/reset.m new file mode 100644 index 000000000..4721bc6c2 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/reset.m @@ -0,0 +1,12 @@ +% App-owned implementation for image_match.matchPipeline.reset within the image_match product workflow. +function applicationState = reset(applicationState, callbackContext) +%RESET Clear every saved match step. +steps = repmat(image_match.analysisRun.emptyStep(), 0, 1); +applicationState.project.annotations.steps = steps; +applicationState.session.workflow.pendingDirty = false; +applicationState = ... + image_match.matchPipeline.invalidateResults(applicationState); +applicationState.session.cache = image_match.matchPipeline.refreshPreview( ... + applicationState.session.cache, steps); +callbackContext.appendStatus("Reset match history."); +end diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/settingsChanged.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/settingsChanged.m new file mode 100644 index 000000000..d6a1f6aff --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/settingsChanged.m @@ -0,0 +1,34 @@ +% App-owned implementation for image_match.matchPipeline.settingsChanged within the image_match product workflow. +function applicationState = settingsChanged( ... + applicationState, changedValue, callbackContext) +%SETTINGSCHANGED Preview one bounded draft without committing history. +parameters = applicationState.project.parameters; +method = string(parameters.matchMethod); +if ~isscalar(method) || ... + ~any(method == string(image_match.matchPipeline.methods())) + method = "Balanced"; + callbackContext.appendStatus( ... + "Reset an unsupported match method to Balanced."); +end +applicationState.project.parameters.matchMethod = method; +for name = ["matchStrength" "toneStrength" "colorStrength"] + applicationState.project.parameters.(name) = boundedPercent( ... + parameters.(name), 100); +end +applicationState.session.workflow.pendingDirty = true; +applicationState = ... + image_match.matchPipeline.invalidateResults(applicationState); +applicationState = ... + image_match.matchPipeline.rebuildPreview(applicationState); +end + +function value = boundedPercent(value, fallback) +value = double(value); +if isempty(value) || ~isscalar(value) || ~isfinite(value) + value = double(fallback); +end +if isempty(value) || ~isscalar(value) || ~isfinite(value) + value = 100; +end +value = min(max(value, 0), 100); +end diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/undo.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/undo.m new file mode 100644 index 000000000..30e0644ba --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/undo.m @@ -0,0 +1,20 @@ +% App-owned implementation for image_match.matchPipeline.undo within the image_match product workflow. +function applicationState = undo(applicationState, callbackContext) +%UNDO Remove the most recent match step. +steps = applicationState.project.annotations.steps; +if isempty(steps) + return +end +steps = steps(:); +removed = steps(end); +steps(end) = []; +steps = steps(:); +applicationState.project.annotations.steps = steps; +applicationState.session.workflow.pendingDirty = false; +applicationState = ... + image_match.matchPipeline.invalidateResults(applicationState); +applicationState.session.cache = image_match.matchPipeline.refreshPreview( ... + applicationState.session.cache, steps); +callbackContext.appendStatus( ... + "Undid match step: " + string(removed.label)); +end diff --git a/apps/image_measurement/image_match/+image_match/+resultFiles/changeFormat.m b/apps/image_measurement/image_match/+image_match/+resultFiles/changeFormat.m new file mode 100644 index 000000000..ef03cdca3 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+resultFiles/changeFormat.m @@ -0,0 +1,13 @@ +% App-owned implementation for image_match.resultFiles.changeFormat within the image_match product workflow. +function applicationState = changeFormat( ... + applicationState, format, callbackContext) +%CHANGEFORMAT Select the matched-image batch format. +format = upper(string(format)); +if ~isscalar(format) || ~any(format == ["PNG" "TIFF" "JPEG"]) + callbackContext.appendStatus("Ignored an unsupported export format."); + return; +end +applicationState.project.parameters.exportFormat = format; +applicationState = ... + image_match.matchPipeline.invalidateResults(applicationState); +end diff --git a/apps/image_measurement/image_match/+image_match/+resultFiles/chooseOutputFolder.m b/apps/image_measurement/image_match/+image_match/+resultFiles/chooseOutputFolder.m new file mode 100644 index 000000000..1084f5b69 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+resultFiles/chooseOutputFolder.m @@ -0,0 +1,14 @@ +% App-owned implementation for image_match.resultFiles.chooseOutputFolder within the image_match product workflow. +function applicationState = chooseOutputFolder( ... + applicationState, callbackContext) +%CHOOSEOUTPUTFOLDER Select the matched-image batch destination. +choice = callbackContext.chooseOutputFolder( ... + applicationState.project.parameters.outputFolder); +if choice.Cancelled + callbackContext.appendStatus("Export folder selection cancelled."); + return; +end +applicationState.project.parameters.outputFolder = string(choice.Value); +applicationState = ... + image_match.matchPipeline.invalidateResults(applicationState); +end diff --git a/apps/image_measurement/image_match/+image_match/+resultFiles/exportImages.m b/apps/image_measurement/image_match/+image_match/+resultFiles/exportImages.m new file mode 100644 index 000000000..e7af0fd91 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+resultFiles/exportImages.m @@ -0,0 +1,99 @@ +% App-owned implementation for image_match.resultFiles.exportImages within the image_match product workflow. +function applicationState = exportImages( ... + applicationState, callbackContext) +%EXPORTIMAGES Apply committed matches and write CSV and LabKit manifests. +sources = applicationState.project.inputs.sources; +referenceSource = applicationState.project.inputs.reference; +if isempty(referenceSource) || isempty(sources) + callbackContext.alert( ... + "Load source and reference images before exporting.", ... + "Export unavailable"); + return; +end +folder = string(applicationState.project.parameters.outputFolder); +if strlength(folder) == 0 + choice = callbackContext.chooseOutputFolder(""); + if choice.Cancelled + callbackContext.appendStatus("Image-match export cancelled."); + return; + end + folder = string(choice.Value); + applicationState.project.parameters.outputFolder = folder; +end +try + items = image_match.sourceFiles.readImages( ... + callbackContext.resolveSourcePaths(sources)); + referenceItems = image_match.sourceFiles.readImages( ... + callbackContext.resolveSourcePaths(referenceSource(1))); + referenceItem = referenceItems(1); + options = struct( ... + "outputFolder", folder, ... + "format", applicationState.project.parameters.exportFormat); + task = image_match.resultFiles.exportTask( ... + items, referenceItem, ... + applicationState.project.annotations.steps, options); + if ~isempty(applicationState.project.results.lastExport) && ... + applicationState.project.results.lastExportFingerprint == ... + task.fingerprint + callbackContext.appendStatus( ... + "Matched export is already up to date."); + return; + end + payload = image_match.resultFiles.writeOutputs( ... + items, referenceItem, ... + applicationState.project.annotations.steps, options); + package = labkit.app.result.Package( ... + Outputs=packageOutputs(payload), ... + Inputs=struct( ... + "reference", referenceSource, "sources", sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct("imageCount", numel(items)), ... + ManifestName="image_match.labkit.json"); + written = callbackContext.writeResultPackage(folder, package); +catch ME + callbackContext.reportError("Export matched images", ME); + callbackContext.alert(ME.message, "Export failed"); + callbackContext.appendStatus( ... + "Image-match export failed: " + string(ME.message)); + return; +end +payload.sourceIds = string({sources.id}); +payload.referenceId = string(referenceSource(1).id); +payload.resultManifestPath = string(written.Value); +applicationState.project.results.lastExport = payload; +applicationState.project.results.lastExportFingerprint = task.fingerprint; +applicationState.project.results.resultManifestPath = string(written.Value); +callbackContext.appendStatus( ... + "Exported matched images: " + payload.manifestPath); +end + +function outputs = packageOutputs(payload) +outputs = cell(1, numel(payload.results) + 1); +for index = 1:numel(payload.results) + result = payload.results(index); + [~, name, extension] = fileparts(result.outputPath); + status = "failed"; + if result.status == "saved" + status = "success"; + end + outputs{index} = labkit.app.result.File( ... + "matched_" + compose("%03d", index), ... + "matched-image", string(name) + string(extension), ... + MediaType=mediaType(extension), Status=status, ... + Message=result.message); +end +[~, name, extension] = fileparts(payload.manifestPath); +outputs{end} = labkit.app.result.File( ... + "batch_manifest", "batch-summary", ... + string(name) + string(extension), MediaType="text/csv"); +end + +function value = mediaType(extension) +if any(lower(string(extension)) == [".jpg" ".jpeg"]) + value = "image/jpeg"; +elseif any(lower(string(extension)) == [".tif" ".tiff"]) + value = "image/tiff"; +else + value = "image/png"; +end +end diff --git a/apps/image_measurement/image_match/+image_match/+sourceFiles/referenceSelected.m b/apps/image_measurement/image_match/+image_match/+sourceFiles/referenceSelected.m new file mode 100644 index 000000000..2acf63009 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+sourceFiles/referenceSelected.m @@ -0,0 +1,23 @@ +% App-owned implementation for image_match.sourceFiles.referenceSelected within the image_match product workflow. +function applicationState = referenceSelected( ... + applicationState, listSelection, callbackContext) +%REFERENCESELECTED Rebuild preview state for the selected reference image. +lastExport = applicationState.project.results.lastExport; +reference = applicationState.project.inputs.reference; +if ~isempty(lastExport) && isfield(lastExport, "referenceId") + currentId = ""; + if ~isempty(reference) + currentId = string(reference(1).id); + end + if string(lastExport.referenceId) ~= currentId + applicationState = ... + image_match.matchPipeline.invalidateResults(applicationState); + end +end +applicationState.session.workflow.pendingDirty = false; +applicationState = ... + image_match.matchPipeline.rebuildPreview(applicationState); +if isempty(listSelection.Indices) && isempty(reference) + callbackContext.appendStatus("Reference image cleared."); +end +end diff --git a/apps/image_measurement/image_match/+image_match/+sourceFiles/selectPreview.m b/apps/image_measurement/image_match/+image_match/+sourceFiles/selectPreview.m new file mode 100644 index 000000000..7ec427fbf --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+sourceFiles/selectPreview.m @@ -0,0 +1,57 @@ +% App-owned implementation for image_match.sourceFiles.selectPreview within the image_match product workflow. +function applicationState = selectPreview( ... + applicationState, listSelection, callbackContext) +%SELECTPREVIEW Lazily load and preview the selected source image. +sources = applicationState.project.inputs.sources; +applicationState = invalidateChangedSources(applicationState); +if isempty(listSelection.Indices) + if isempty(sources) + applicationState.project.annotations.steps = repmat( ... + image_match.analysisRun.emptyStep(), 0, 1); + applicationState.session.selection.currentIndex = 0; + applicationState.session.cache.currentItem = []; + applicationState.session.cache.previewSource = []; + applicationState.session.cache.previewResult = []; + applicationState = ... + image_match.matchPipeline.invalidateResults(applicationState); + end + return; +end +index = listSelection.Indices(1); +if index > numel(sources) + return; +end +try + paths = callbackContext.resolveSourcePaths(sources(index)); + items = image_match.sourceFiles.readImages(paths); +catch ME + callbackContext.reportError("Load image-match preview", ME); + callbackContext.alert(ME.message, "Could not load source image"); + return; +end +if isempty(items) + return; +end +if strlength( ... + applicationState.project.parameters.outputFolder) == 0 + applicationState.project.parameters.outputFolder = string(fullfile( ... + fileparts(paths(1)), "image_match")); +end +applicationState.session.selection.currentIndex = index; +applicationState.session.workflow.pendingDirty = false; +applicationState.session.cache.currentItem = items(1); +applicationState = ... + image_match.matchPipeline.rebuildPreview(applicationState); +end + +function applicationState = invalidateChangedSources(applicationState) +lastExport = applicationState.project.results.lastExport; +if isempty(lastExport) || ~isfield(lastExport, "sourceIds") + return; +end +ids = string({applicationState.project.inputs.sources.id}); +if ~isequal(ids(:), string(lastExport.sourceIds(:))) + applicationState = ... + image_match.matchPipeline.invalidateResults(applicationState); +end +end diff --git a/apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m b/apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 44d84b231..000000000 --- a/apps/image_measurement/image_match/+image_match/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,154 +0,0 @@ -% 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 layout for the Image Match app. -function layout = buildWorkbenchLayout(callbacks, state) - methods = image_match.userInterface.matchMethods(); - outputFolder = state.project.parameters.outputFolder; - - layout = labkit.ui.layout.workbench('imageMatchApp', 'Paper Image Match', ... - 'controlTabs', controlTabs(methods, outputFolder, callbacks), ... - 'workspace', previewWorkspace(callbacks)); -end - -function tabs = controlTabs(methods, outputFolder, callbacks) - tabs = { ... - libraryExportTab(outputFolder, callbacks), ... - matchHistoryTab(methods, callbacks), ... - logTab()}; -end - -function tab = libraryExportTab(outputFolder, callbacks) - tab = labkit.ui.layout.tab('libraryExport', 'Library + Export', { ... - librarySection(callbacks), ... - exportSection(outputFolder, callbacks), ... - exportDetailsSection()}); -end - -function tab = matchHistoryTab(methods, callbacks) - tab = labkit.ui.layout.tab('matchHistory', 'Match + History', { ... - matchSection(methods, callbacks), ... - matchFlowSection(methods), ... - historySection(callbacks), ... - currentImageSection()}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab('log', 'Log', { ... - labkit.ui.layout.section('logSection', 'Log', { ... - labkit.ui.layout.logPanel('logPanel', 'Log')})}); -end - -function section = librarySection(callbacks) - section = labkit.ui.layout.section('librarySection', 'Library', { ... - labkit.ui.layout.filePanel('referenceImage', 'Reference image', ... - 'mode', 'single', ... - 'chooseLabel', 'Choose reference', ... - 'filters', labkit.image.fileDialogFilter("IncludeAll", true), ... - 'status', 'No reference loaded', ... - 'emptyText', 'No reference loaded', ... - 'onChoose', callbacks.referenceImageChosen), ... - labkit.ui.layout.filePanel('sourceImages', 'Source images', ... - 'selectionMode', 'single', ... - 'chooseLabel', 'Add images or folder', ... - 'clearLabel', 'Clear images', ... - 'filters', labkit.image.fileDialogFilter("IncludeAll", true), ... - 'status', 'No images loaded', ... - 'emptyText', 'No images loaded', ... - 'onChoose', callbacks.sourceImagesChosen, ... - 'onRemove', callbacks.removeImages, ... - 'onSelectionChange', callbacks.imageSelectionChanged, ... - 'onClear', callbacks.clearImages), ... - labkit.ui.layout.field('imageStatus', 'Status', ... - 'kind', 'readonly', ... - 'value', 'Images: 0')}); -end - -function section = exportSection(outputFolder, callbacks) - section = labkit.ui.layout.section('exportSection', 'Batch Export', { ... - labkit.ui.layout.field('outputFolder', 'Output folder', ... - 'kind', 'readonly', ... - 'value', outputFolder), ... - labkit.ui.layout.field('exportFormat', 'Format', ... - 'kind', 'dropdown', ... - 'items', {'PNG', 'TIFF', 'JPEG'}, ... - 'value', 'PNG', ... - 'onChange', callbacks.exportSettingChanged), ... - labkit.ui.layout.group('exportActions', "", { ... - labkit.ui.layout.action('chooseOutputFolder', 'Choose folder', ... - callbacks.chooseOutputFolder), ... - labkit.ui.layout.action('exportImages', 'Export matched images', ... - callbacks.exportImages, ... - 'enabled', false)})}); -end - -function section = exportDetailsSection() - section = labkit.ui.layout.section('exportDetailsSection', 'Export Details', { ... - labkit.ui.layout.statusPanel('exportDetails', 'Export Details', ... - 'value', image_match.userInterface.detailLines( ... - repmat(image_match.sourceFiles.emptyItem(), 0, 1), 1, ... - [], ... - repmat(image_match.analysisRun.emptyStep(), 0, 1), []))}); -end - -function section = matchSection(methods, callbacks) - section = labkit.ui.layout.section('matchSection', 'Reference Match', { ... - labkit.ui.layout.field('matchMethod', 'Method', ... - 'kind', 'dropdown', ... - 'items', methods, ... - 'value', methods{1}, ... - 'onChange', callbacks.matchSettingChanged), ... - matchPanner('matchStrength', 'Strength (%)', callbacks), ... - matchPanner('toneStrength', 'Tone (%)', callbacks), ... - matchPanner('colorStrength', 'Color (%)', callbacks), ... - labkit.ui.layout.action('applyMatch', 'Apply match', ... - callbacks.applyMatch, ... - 'enabled', false)}); -end - -function section = matchFlowSection(methods) - section = labkit.ui.layout.section('matchFlowSection', 'Match Flow', { ... - labkit.ui.layout.statusPanel('matchFlow', 'Match Flow', ... - 'value', image_match.userInterface.matchFlowLines(methods{1}))}); -end - -function section = historySection(callbacks) - section = labkit.ui.layout.section('historySection', 'Match History', { ... - labkit.ui.layout.group('historyActions', "", { ... - labkit.ui.layout.action('undoHistory', 'Undo history', ... - callbacks.undoHistory, ... - 'enabled', false), ... - labkit.ui.layout.action('resetHistory', 'Reset history', ... - callbacks.resetHistory, ... - 'enabled', false)}), ... - labkit.ui.layout.resultTable('historyTable', 'Match History', ... - 'columns', {'#', 'Step', 'Settings'}, ... - 'data', image_match.userInterface.historyTableData( ... - repmat(image_match.analysisRun.emptyStep(), 0, 1))), ... - labkit.ui.layout.field('historyStatus', 'Status', ... - 'kind', 'readonly', ... - 'value', 'History steps: 0')}); -end - -function section = currentImageSection() - section = labkit.ui.layout.section('currentImageSection', 'Current Image', { ... - labkit.ui.layout.resultTable('metricsTable', 'Current Image', ... - 'columns', {'Metric', 'Value'}, ... - 'data', image_match.userInterface.resultTableData([], [], 0))}); -end - -function workspace = previewWorkspace(callbacks) - workspace = labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.previewArea('preview', 'Preview', ... - 'layout', 'single', ... - 'axisTitles', {'Matched Preview'}, ... - 'viewModes', {'Matched', 'Original', 'Before | After'}, ... - 'onModeChange', callbacks.previewModeChanged)}); -end - -function layoutNode = matchPanner(id, labelText, callbacks) - layoutNode = labkit.ui.layout.panner(id, labelText, ... - 'value', 100, ... - 'limits', [0 100], ... - 'step', 1, ... - 'onChange', callbacks.matchSettingChanged); -end diff --git a/apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m b/apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m deleted file mode 100644 index ffbb277c5..000000000 --- a/apps/image_measurement/image_match/+image_match/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,144 +0,0 @@ -% Expected caller: the LabKit V2 runtime. Input is canonical Image Match -% state. Output is deterministic controls and one registered image preview. -function view = presentWorkbench(state) - [reference, sources] = sourceGroups(state.project.inputs.sources); - p = state.project.parameters; - steps = state.project.annotations.steps; - ready = ~isempty(state.session.cache.currentItem) && ... - ~isempty(state.session.cache.referenceItem); - view = struct(); - view.controls.referenceImage = fileSpec(reference, 0, "No reference loaded"); - view.controls.sourceImages = fileSpec( ... - sources, state.session.selection.currentIndex, sourceStatus(sources)); - view.controls.imageStatus = valueSpec(sprintf( ... - 'Images: %d | match steps: %d', numel(sources), numel(steps))); - view.controls.matchMethod = valueSpec(p.matchMethod); - view.controls.matchStrength = valueSpec(p.matchStrength); - view.controls.toneStrength = valueSpec(p.toneStrength); - view.controls.colorStrength = valueSpec(p.colorStrength); - view.controls.matchFlow = valueSpec( ... - image_match.userInterface.matchFlowLines(p.matchMethod)); - view.controls.applyMatch = enabledSpec(ready); - view.controls.undoHistory = enabledSpec(~isempty(steps)); - view.controls.resetHistory = enabledSpec(~isempty(steps)); - view.controls.historyTable = dataSpec( ... - image_match.userInterface.historyTableData(steps)); - view.controls.historyStatus = valueSpec(sprintf( ... - 'History steps: %d', numel(steps))); - view.controls.metricsTable = dataSpec(metricData(state, steps)); - view.controls.outputFolder = valueSpec(p.outputFolder); - view.controls.exportFormat = valueSpec(p.exportFormat); - view.controls.exportImages = enabledSpec(ready); - view.controls.exportDetails = valueSpec(detailLines(state, sources, reference)); - view.controls.preview = valueSpec(state.session.view.previewMode); - view.previews.preview = struct("Renderer", "imagePreview", ... - "Model", previewModel(state)); -end - -function spec = fileSpec(sources, index, status) - files = repmat(struct("id", "", "path", "", "status", "ready"), ... - numel(sources), 1); - paths = labkit.ui.runtime.sourcePaths(sources); - for k = 1:numel(sources) - files(k).id = string(sources(k).id); - files(k).path = paths(k); - end - selection = strings(0, 1); - if index >= 1 && index <= numel(sources) - selection = string(sources(index).id); - end - spec = struct(); - spec.Files = files; - spec.Selection = selection; - spec.Status = status; -end - -function value = sourceStatus(sources) - if isempty(sources) - value = "No images loaded"; - else - value = sprintf('%d image(s)', numel(sources)); - end -end - -function data = metricData(state, steps) - if isempty(state.session.cache.currentItem) || ... - isempty(state.session.cache.previewResult) - data = image_match.userInterface.resultTableData([], [], 0); - else - data = image_match.userInterface.resultTableData( ... - state.session.cache.currentItem, ... - state.session.cache.previewResult, numel(steps)); - end -end - -function lines = detailLines(state, sources, reference) - if isempty(sources) - lines = {'Load a reference and one or more source images.'}; - return; - end - index = min(max(state.session.selection.currentIndex, 1), numel(sources)); - lines = {"Selected: " + displayName(sources(index)), ... - "Source images: " + string(numel(sources)), ... - "Reference: " + referenceName(reference), ... - "History steps: " + string(numel(state.project.annotations.steps))}; - if strlength(state.project.results.resultManifestPath) > 0 - lines{end + 1} = "Last manifest: " + ... - state.project.results.resultManifestPath; - elseif ~isempty(state.project.results.lastExport) - lines{end + 1} = "Last manifest: " + ... - string(state.project.results.lastExport.manifestPath); - end - lines = cellstr(string(lines)); -end - -function model = previewModel(state) - cache = state.session.cache; - switch state.session.view.previewMode - case "Original" - imageData = cache.previewSource; - titleText = "Original Preview"; - case "Before | After" - imageData = image_match.userInterface.beforeAfterImage( ... - cache.previewSource, cache.previewResult); - titleText = "Before | After"; - otherwise - imageData = cache.previewResult; - titleText = "Matched Preview"; - end - model = struct("imageData", imageData, "title", titleText); -end - -function [reference, sources] = sourceGroups(allSources) - roles = string({allSources.role}); - reference = allSources(roles == "reference-image"); - sources = allSources(roles == "source-image"); -end - -function value = referenceName(reference) - if isempty(reference) - value = "-"; - else - value = displayName(reference(1)); - end -end - -function value = displayName(source) - [~, stem, extension] = fileparts( ... - labkit.ui.runtime.sourcePaths(source)); - value = string(stem) + string(extension); -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = dataSpec(value) - spec = struct(); - spec.Data = value; -end - -function spec = enabledSpec(value) - spec = struct("Enabled", logical(value)); -end diff --git a/apps/image_measurement/image_match/+image_match/+userInterface/renderImagePreview.m b/apps/image_measurement/image_match/+image_match/+userInterface/renderImagePreview.m deleted file mode 100644 index 03cf09e20..000000000 --- a/apps/image_measurement/image_match/+image_match/+userInterface/renderImagePreview.m +++ /dev/null @@ -1,22 +0,0 @@ -% Expected caller: the registered Image Match V2 renderer. Inputs are target -% axes and prepared image/title model. Side effects are limited to the axes. -function renderImagePreview(ax, model) - labkit.ui.plot.clear(ax, "ResetScale", true); - if isempty(model.imageData) - title(ax, char(model.title)); - box(ax, 'on'); - return; - end - if ndims(model.imageData) == 2 - imagesc(ax, model.imageData); - colormap(ax, gray(256)); - else - image(ax, model.imageData); - end - axis(ax, 'image'); - ax.YDir = 'reverse'; - title(ax, char(model.title)); - xlabel(ax, ''); - ylabel(ax, ''); - box(ax, 'on'); -end diff --git a/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m b/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m new file mode 100644 index 000000000..167ae4b52 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m @@ -0,0 +1,112 @@ +% App-owned implementation for image_match.workbench.buildLayout within the image_match product workflow. +function layout = buildLayout() +%BUILDLAYOUT Assemble the Image Match library, match flow, and preview. +reference = labkit.app.layout.fileList( ... + "referenceImage", Label="Reference image", ... + SelectionMode="single", MaxFiles=1, ... + Filters=labkit.image.fileDialogFilter("IncludeAll", true), ... + Bind="project.inputs.reference", ... + SelectionBind="session.selection.referenceImage", ... + OnSelectionChanged=@image_match.sourceFiles.referenceSelected, ... + SourceRole="reference-image", SourceIdPrefix="reference", ... + ChooseLabel="Choose reference", EmptyText="No reference loaded", ... + ChooseTooltip="Choose the image whose intensity, tone, or color distribution defines the matching target."); +sources = labkit.app.layout.fileList( ... + "sourceImages", Label="Source images", ... + SelectionMode="multiple", ... + Filters=labkit.image.fileDialogFilter("IncludeAll", true), ... + Bind="project.inputs.sources", ... + SelectionBind="session.selection.sourceImages", ... + OnSelectionChanged=@image_match.sourceFiles.selectPreview, ... + SourceRole="source-image", SourceIdPrefix="image", ... + ChooseLabel="Add images or folder", FolderLabel="Add folder", ... + ChooseTooltip="Add source images that will be matched to the selected reference without modifying the originals.", ... + RecursiveFolderLabel="Add folder tree", ... + RemoveLabel="Remove selected", ClearLabel="Clear images", ... + EmptyText="No images loaded"); +libraryTab = labkit.app.layout.tab( ... + "libraryExport", "Library + Export", { ... + labkit.app.layout.section("librarySection", "Library", { ... + reference, sources, ... + labkit.app.layout.field("imageStatus", ... + Label="Status", Kind="readonly", ... + Value="Images: 0 | match steps: 0")}), ... + labkit.app.layout.section("exportSection", "Batch Export", { ... + labkit.app.layout.field("outputFolder", ... + Label="Output folder", Kind="readonly", Value=""), ... + labkit.app.layout.field("exportFormat", ... + Label="Format", Kind="choice", ... + Choices=["PNG" "TIFF" "JPEG"], ... + Bind="project.parameters.exportFormat", ... + OnValueChanged=@image_match.resultFiles.changeFormat), ... + labkit.app.layout.group("exportActions", { ... + labkit.app.layout.button("chooseOutputFolder", ... + "Choose folder", ... + @image_match.resultFiles.chooseOutputFolder, ... + Tooltip="Choose the destination for matched images and their reproducible match history."), ... + labkit.app.layout.button("exportImages", ... + "Export matched images", ... + @image_match.resultFiles.exportImages, ... + Tooltip="Apply the committed reference-matching history to every source image and export the results.")}, ... + Layout="horizontal")}), ... + labkit.app.layout.section( ... + "exportDetailsSection", "Export Details", { ... + labkit.app.layout.statusPanel( ... + "exportDetails", Title="Export Details")})}); +matchTab = labkit.app.layout.tab( ... + "matchHistory", "Match + History", { ... + labkit.app.layout.section( ... + "matchSection", "Reference Match", { ... + labkit.app.layout.field("matchMethod", ... + Label="Method", Kind="choice", ... + Choices=image_match.matchPipeline.methods(), ... + Bind="project.parameters.matchMethod", ... + OnValueChanged= ... + @image_match.matchPipeline.settingsChanged), ... + strength("matchStrength", "Strength (%)"), ... + strength("toneStrength", "Tone (%)"), ... + strength("colorStrength", "Color (%)"), ... + labkit.app.layout.button("applyMatch", ... + "Apply match", @image_match.matchPipeline.apply, ... + Tooltip="Estimate the selected reference-to-source distribution match and commit it at the configured strengths.")}), ... + labkit.app.layout.section("matchFlowSection", "Match Flow", { ... + labkit.app.layout.statusPanel( ... + "matchFlow", Title="Match Flow")}), ... + labkit.app.layout.section("historySection", "Match History", { ... + labkit.app.layout.group("historyActions", { ... + labkit.app.layout.button("undoHistory", ... + "Undo history", @image_match.matchPipeline.undo, ... + Tooltip="Remove the latest committed reference-matching step and recompute the preview."), ... + labkit.app.layout.button("resetHistory", ... + "Reset history", @image_match.matchPipeline.reset, ... + Tooltip="Remove all match steps and restore each source image to its original pixels.")}, ... + Layout="horizontal"), ... + labkit.app.layout.dataTable("historyTable", ... + Title="Match History", Columns=["#" "Step" "Settings"]), ... + labkit.app.layout.field("historyStatus", ... + Label="Status", Kind="readonly", ... + Value="History steps: 0")}), ... + labkit.app.layout.section( ... + "currentImageSection", "Current Image", { ... + labkit.app.layout.dataTable("metricsTable", ... + Title="Current Image", Columns=["Metric" "Value"])})}); +logTab = labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("logPanel", Title="Log")})}); +preview = labkit.app.layout.plotArea( ... + "preview", @image_match.imagePreview.draw, ... + Title="Preview", Layout="single", ... + AxisIds="image", AxisTitles="Matched Preview", ... + ViewModes=["Matched" "Original" "Before | After"], ... + OnValueChanged=@image_match.imagePreview.changeMode); +workspace = labkit.app.layout.workspace(preview, Title="Preview"); +layout = labkit.app.layout.workbench( ... + {libraryTab, matchTab, logTab}, Workspace=workspace); +end + +function node = strength(id, label) +node = labkit.app.layout.slider( ... + id, Label=label, Limits=[0 100], Step=1, ... + Bind="project.parameters." + id, ... + OnValueChanged=@image_match.matchPipeline.settingsChanged); +end diff --git a/apps/image_measurement/image_match/+image_match/+workbench/present.m b/apps/image_measurement/image_match/+image_match/+workbench/present.m new file mode 100644 index 000000000..71b01c876 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+workbench/present.m @@ -0,0 +1,61 @@ +% App-owned implementation for image_match.workbench.present within the image_match product workflow. +function view = present(applicationState) +%PRESENT Build one complete Image Match workbench snapshot. +project = applicationState.project; +session = applicationState.session; +steps = project.annotations.steps; +ready = ~isempty(session.cache.referenceItem) && ... + ~isempty(session.cache.currentItem); +view = labkit.app.view.Snapshot(); +view = view.value("imageStatus", sprintf( ... + "Images: %d | match steps: %d", ... + numel(project.inputs.sources), numel(steps))); +view = view.value("matchMethod", project.parameters.matchMethod); +view = view.value("matchStrength", project.parameters.matchStrength); +view = view.value("toneStrength", project.parameters.toneStrength); +view = view.value("colorStrength", project.parameters.colorStrength); +view = view.text("matchFlow", strjoin(string( ... + image_match.imagePreview.presentationData.matchFlowLines( ... + project.parameters.matchMethod)), newline)); +view = view.enabled("applyMatch", ready); +view = view.enabled("undoHistory", ~isempty(steps)); +view = view.enabled("resetHistory", ~isempty(steps)); +view = view.value("historyStatus", ... + sprintf("History steps: %d", numel(steps))); +view = view.value("outputFolder", project.parameters.outputFolder); +view = view.value("exportFormat", project.parameters.exportFormat); +view = view.enabled("exportImages", ready); +view = view.text("exportDetails", strjoin(string( ... + detailLines(applicationState)), newline)); +view = view.value("preview", session.view.previewMode); +view = view.include(image_match.imagePreview.present( ... + steps, session.cache.currentItem, ... + session.cache.previewSource, session.cache.previewResult, ... + session.view.previewMode)); +end + +function lines = detailLines(applicationState) +project = applicationState.project; +cache = applicationState.session.cache; +if isempty(project.inputs.sources) + lines = {"Load a reference and one or more source images."}; + return; +end +referenceName = "-"; +if ~isempty(cache.referenceItem) + referenceName = string(cache.referenceItem.name); +end +selectedName = "-"; +if ~isempty(cache.currentItem) + selectedName = string(cache.currentItem.name); +end +lines = { ... + "Selected: " + selectedName, ... + "Source images: " + string(numel(project.inputs.sources)), ... + "Reference: " + referenceName, ... + "History steps: " + string(numel(project.annotations.steps))}; +if strlength(project.results.resultManifestPath) > 0 + lines{end + 1} = ... + "Last manifest: " + project.results.resultManifestPath; +end +end diff --git a/apps/image_measurement/image_match/+image_match/createSession.m b/apps/image_measurement/image_match/+image_match/createSession.m index 99001caf0..78488bd04 100644 --- a/apps/image_measurement/image_match/+image_match/createSession.m +++ b/apps/image_measurement/image_match/+image_match/createSession.m @@ -1,30 +1,29 @@ % Rebuild transient decoded images and preview caches from one validated -% Image Match project. Runtime V2 calls this after source relinking. -function session = createSession(project) - sourceIndices = find(string({project.inputs.sources.role}) == "source-image"); - index = double(~isempty(sourceIndices)); +% Image Match project. App SDK runtime calls this after source relinking. +function session = createSession(project, context) + index = double(~isempty(project.inputs.sources)); cache = emptyCache(); - referenceIndex = find(string({project.inputs.sources.role}) == ... - "reference-image", 1); - if ~isempty(referenceIndex) - cache.referenceItem = loadItem( ... - project.inputs.sources(referenceIndex)); + if ~isempty(project.inputs.reference) + cache.referenceItem = loadItem(project.inputs.reference, context); end if index > 0 - cache.currentItem = loadItem(project.inputs.sources(sourceIndices(1))); + cache.currentItem = loadItem(project.inputs.sources(1), context); end cache = rebuildResult(project, cache); session = struct( ... - "selection", struct("currentIndex", index), ... + "selection", struct( ... + "referenceImage", labkit.app.event.ListSelection(), ... + "sourceImages", labkit.app.event.ListSelection(), ... + "currentIndex", index), ... "workflow", struct("pendingDirty", false), ... "view", struct("previewMode", "Matched"), ... "cache", cache); end -function item = loadItem(source) +function item = loadItem(source, context) item = []; loaded = image_match.sourceFiles.readImages( ... - labkit.ui.runtime.sourcePaths(source)); + context.resolveSourcePaths(source)); if ~isempty(loaded) item = loaded(1); end @@ -34,9 +33,9 @@ if isempty(cache.currentItem) || isempty(cache.referenceItem) return; end - cache.previewSource = image_match.userInterface.previewImage( ... + cache.previewSource = image_match.imagePreview.presentationData.previewImage( ... cache.currentItem.image); - cache.previewReference = image_match.userInterface.previewImage( ... + cache.previewReference = image_match.imagePreview.presentationData.previewImage( ... cache.referenceItem.image); processed = image_match.analysisRun.applyPipeline( ... {cache.previewSource}, project.annotations.steps, ... diff --git a/apps/image_measurement/image_match/+image_match/definition.m b/apps/image_measurement/image_match/+image_match/definition.m index 501bc8368..ad5762c93 100644 --- a/apps/image_measurement/image_match/+image_match/definition.m +++ b/apps/image_measurement/image_match/+image_match/definition.m @@ -1,23 +1,14 @@ % App-owned runtime definition for labkit_ImageMatch_app. Expected caller: % the public app entrypoint. Output is a declarative LabKit app definition; % side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_ImageMatch_app", ... - "Id", "image_match", ... - "Title", "Paper Image Match", ... - "DisplayName", "Image Match", ... - "Family", "Image Measurement", ... - "AppVersion", "1.6.8", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "image", ">=2.0 <3"), ... - "Project", image_match.projectSpec(), ... - "CreateSession", @image_match.createSession, ... - "Layout", @image_match.userInterface.buildWorkbenchLayout, ... - "Actions", image_match.definitionActions(), ... - "Present", @image_match.userInterface.presentWorkbench, ... - "Renderers", struct( ... - "imagePreview", @image_match.userInterface.renderImagePreview), ... - "DebugSample", @image_match.debug.writeSamplePack); +function app = definition() + app = labkit.app.Definition( ... + Entrypoint="labkit_ImageMatch_app", AppId="image_match", ... + Title="Paper Image Match", DisplayName="Image Match", ... + Family="Image Measurement", AppVersion="1.7.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2", "image", ">=2.0 <3"), ... + ProjectSchema=image_match.projectSpec(), CreateSession=@image_match.createSession, ... + Workbench=image_match.workbench.buildLayout(), ... + PresentWorkbench=@image_match.workbench.present, ... + BuildDebugSample=@image_match.debug.writeSamplePack); end diff --git a/apps/image_measurement/image_match/+image_match/definitionActions.m b/apps/image_measurement/image_match/+image_match/definitionActions.m deleted file mode 100644 index 469eb6d5f..000000000 --- a/apps/image_measurement/image_match/+image_match/definitionActions.m +++ /dev/null @@ -1,357 +0,0 @@ -% App-owned V2 actions for Image Match. Handlers own source/reference records, -% durable match history, selected-image caches, and exports without UI access. -function actions = definitionActions() - actions = struct( ... - "referenceImageChosen", @onReferenceChosen, ... - "sourceImagesChosen", @onSourcesChosen, ... - "removeImages", @onRemoveImages, ... - "clearImages", @onClearImages, ... - "imageSelectionChanged", @onSelectionChanged, ... - "previewModeChanged", @onPreviewModeChanged, ... - "matchSettingChanged", @onMatchSettingChanged, ... - "applyMatch", @onApplyMatch, ... - "undoHistory", @onUndoHistory, ... - "resetHistory", @onResetHistory, ... - "exportSettingChanged", @onExportSettingChanged, ... - "chooseOutputFolder", @onChooseOutputFolder, ... - "exportImages", @onExportImages); -end - -function state = onReferenceChosen(state, event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - state = services.workflow.log(state, "Reference selection cancelled."); - return; - end - sources = state.project.inputs.sources; - sources = sources(string({sources.role}) ~= "reference-image"); - reference = services.project.sourceRecord( ... - "reference", "reference-image", paths(1), true); - state.project.inputs.sources = [reference; sources(:)]; - state.session.cache.referenceItem = loadItem(paths(1), services); - state.session.workflow.pendingDirty = false; - state = invalidateResults(state); - state = rebuildPreview(state); - state = services.workflow.log(state, ... - "Loaded reference image: " + displayName(paths(1))); -end - -function state = onSourcesChosen(state, event, services) - paths = services.events.paths(event, "files"); - added = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = added; - end - if isempty(paths) - state = services.workflow.log(state, "Image selection cancelled."); - return; - end - allSources = state.project.inputs.sources; - reference = allSources(string({allSources.role}) == "reference-image"); - oldSources = allSources(string({allSources.role}) == "source-image"); - sources = services.project.reconcileSources( ... - oldSources, paths, "source-image", "image", true); - state.project.inputs.sources = [reference(:); sources(:)]; - state.session.selection.currentIndex = selectedIndex(sources, added); - state.session.cache.currentItem = loadItem( ... - labkit.ui.runtime.sourcePaths( ... - sources(state.session.selection.currentIndex)), services); - state.project.parameters.outputFolder = string( ... - services.dialogs.defaultOutputFolder(paths, "image_match", ... - state.project.parameters.outputFolder)); - state.session.workflow.pendingDirty = false; - state = invalidateResults(state); - state = rebuildPreview(state); - state = services.workflow.log(state, sprintf( ... - 'Registered %d source image(s); loaded the selected preview only.', ... - numel(sources))); -end - -function state = onRemoveImages(state, event, services) - [reference, sources] = sourceGroups(state.project.inputs.sources); - indices = services.events.indices(event, "removedFiles", numel(sources)); - if isempty(indices) - return; - end - sources(indices) = []; - state.project.inputs.sources = [reference(:); sources(:)]; - state.session.selection.currentIndex = min( ... - state.session.selection.currentIndex, numel(sources)); - if isempty(sources) - state.session.selection.currentIndex = 0; - state.session.cache.currentItem = []; - else - path = labkit.ui.runtime.sourcePaths( ... - sources(state.session.selection.currentIndex)); - state.session.cache.currentItem = loadItem(path, services); - end - state.session.workflow.pendingDirty = false; - state = invalidateResults(state); - state = rebuildPreview(state); -end - -function state = onClearImages(state, ~, services) - [reference, ~] = sourceGroups(state.project.inputs.sources); - state.project.inputs.sources = reference; - state.project.annotations.steps = repmat( ... - image_match.analysisRun.emptyStep(), 0, 1); - state.session.selection.currentIndex = 0; - state.session.cache.currentItem = []; - state.session.workflow.pendingDirty = false; - state = invalidateResults(state); - state = rebuildPreview(state); - state = services.workflow.log(state, ... - "Cleared source images and match history."); -end - -function state = onSelectionChanged(state, event, services) - [~, sources] = sourceGroups(state.project.inputs.sources); - indices = services.events.indices(event, "selectedFiles", numel(sources)); - if isempty(indices) - return; - end - state.session.selection.currentIndex = indices(1); - state.session.cache.currentItem = loadItem( ... - labkit.ui.runtime.sourcePaths(sources(indices(1))), services); - state.session.workflow.pendingDirty = false; - state = rebuildPreview(state); -end - -function state = onPreviewModeChanged(state, event, ~) - value = string(event.value); - if isscalar(value) && any(value == ["Matched", "Original", "Before | After"]) - state.session.view.previewMode = value; - end -end - -function state = onMatchSettingChanged(state, event, ~) - id = string(event.id); - if id == "matchMethod" - value = string(event.value); - if any(value == string(image_match.userInterface.matchMethods())) - state.project.parameters.matchMethod = value; - end - elseif any(id == ["matchStrength", "toneStrength", "colorStrength"]) - state.project.parameters.(char(id)) = boundedPercent(event.value, ... - state.project.parameters.(char(id))); - end - state.session.workflow.pendingDirty = true; - state = invalidateResults(state); - state = rebuildPreview(state); -end - -function state = onApplyMatch(state, ~, services) - if ~ready(state) - services.dialogs.alert( ... - "Load source and reference images before applying a match.", ... - "Match unavailable"); - return; - end - step = currentStep(state); - state.project.annotations.steps(end + 1, 1) = step; - state.session.workflow.pendingDirty = false; - state = invalidateResults(state); - state = rebuildPreview(state); - state = services.workflow.log(state, "Applied match: " + string(step.label)); -end - -function state = onUndoHistory(state, ~, services) - steps = state.project.annotations.steps; - if isempty(steps) - return; - end - removed = steps(end); - steps(end) = []; - state.project.annotations.steps = steps; - state.session.workflow.pendingDirty = false; - state = invalidateResults(state); - state = rebuildPreview(state); - state = services.workflow.log(state, "Undid match step: " + string(removed.label)); -end - -function state = onResetHistory(state, ~, services) - state.project.annotations.steps = repmat( ... - image_match.analysisRun.emptyStep(), 0, 1); - state.session.workflow.pendingDirty = false; - state = invalidateResults(state); - state = rebuildPreview(state); - state = services.workflow.log(state, "Reset match history."); -end - -function state = onExportSettingChanged(state, event, ~) - value = upper(string(event.value)); - if any(value == ["PNG", "TIFF", "JPEG"]) - state.project.parameters.exportFormat = value; - state = invalidateResults(state); - end -end - -function state = onChooseOutputFolder(state, ~, services) - [folder, cancelled] = services.dialogs.outputFolder( ... - "Select image match export folder", ... - state.project.parameters.outputFolder); - if cancelled - return; - end - state.project.parameters.outputFolder = string(folder); - state = invalidateResults(state); -end - -function state = onExportImages(state, ~, services) - if ~ready(state) - services.dialogs.alert( ... - "Load source and reference images before exporting.", ... - "Export unavailable"); - return; - end - [referenceSource, sources] = sourceGroups(state.project.inputs.sources); - try - items = loadItems(sources); - reference = image_match.sourceFiles.readImages( ... - labkit.ui.runtime.sourcePaths(referenceSource)); - reference = reference(1); - opts = struct("outputFolder", state.project.parameters.outputFolder, ... - "format", state.project.parameters.exportFormat); - task = image_match.resultFiles.exportTask(items, reference, ... - state.project.annotations.steps, opts); - if ~isempty(state.project.results.lastExport) && ... - state.project.results.lastExportFingerprint == task.fingerprint - state = services.workflow.log(state, ... - "Matched export is already up to date; skipped duplicate write."); - return; - end - payload = image_match.resultFiles.writeOutputs(items, reference, ... - state.project.annotations.steps, opts); - spec = struct("Outputs", resultOutputs(payload.results, services), ... - "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, ... - "Summary", struct("imageCount", numel(items)), ... - "ManifestName", "image_match.labkit.json"); - [manifestPath, ~] = services.results.writeManifest( ... - state.project.parameters.outputFolder, spec); - catch ME - services.diagnostics.report("Export failed", ME); - services.dialogs.alert(ME.message, "Export failed"); - return; - end - payload.resultManifestPath = string(manifestPath); - state.project.results.lastExport = payload; - state.project.results.lastExportFingerprint = task.fingerprint; - state.project.results.resultManifestPath = string(manifestPath); - state = services.workflow.log(state, "Exported matched images: " + ... - string(payload.manifestPath)); -end - -function state = rebuildPreview(state) - cache = state.session.cache; - cache.previewSource = []; - cache.previewReference = []; - cache.previewResult = []; - cache.previewResultKey = ""; - if isempty(cache.currentItem) || isempty(cache.referenceItem) - state.session.cache = cache; - return; - end - cache.previewSource = image_match.userInterface.previewImage( ... - cache.currentItem.image); - cache.previewReference = image_match.userInterface.previewImage( ... - cache.referenceItem.image); - steps = state.project.annotations.steps; - if state.session.workflow.pendingDirty - steps(end + 1, 1) = currentStep(state); - end - processed = image_match.analysisRun.applyPipeline( ... - {cache.previewSource}, steps, cache.previewReference); - cache.previewResult = processed{1}; - cache.previewResultKey = strjoin(string({steps.label}), "|"); - state.session.cache = cache; -end - -function step = currentStep(state) - p = state.project.parameters; - step = image_match.analysisRun.makeStep(p.matchMethod, ... - p.matchStrength, p.toneStrength, p.colorStrength); -end - -function item = loadItem(path, services) - item = []; - try - loaded = image_match.sourceFiles.readImages(path); - if ~isempty(loaded) - item = loaded(1); - end - catch ME - services.diagnostics.report("Could not load image", ME); - services.dialogs.alert(ME.message, "Could not load image"); - end -end - -function items = loadItems(sources) - items = image_match.sourceFiles.readImages( ... - labkit.ui.runtime.sourcePaths(sources)); -end - -function [reference, sources] = sourceGroups(allSources) - roles = string({allSources.role}); - reference = allSources(roles == "reference-image"); - sources = allSources(roles == "source-image"); - if numel(reference) > 1 - reference = reference(1); - end -end - -function index = selectedIndex(sources, added) - index = 1; - if isempty(added) - return; - end - match = find(labkit.ui.runtime.sourcePaths(sources) == ... - string(added(1)), 1, 'first'); - if ~isempty(match) - index = match; - end -end - -function tf = ready(state) - tf = ~isempty(state.session.cache.currentItem) && ... - ~isempty(state.session.cache.referenceItem); -end - -function state = invalidateResults(state) - state.project.results.lastExport = []; - state.project.results.lastExportFingerprint = ""; - state.project.results.resultManifestPath = ""; -end - -function value = boundedPercent(value, fallback) - value = double(value); - if isempty(value) || ~isscalar(value) || ~isfinite(value) - value = fallback; - end - value = min(max(value, 0), 100); -end - -function name = displayName(path) - [~, stem, extension] = fileparts(string(path)); - name = string(stem) + string(extension); -end - -function outputs = resultOutputs(results, services) - outputs = services.results.emptyOutputs(); - for k = 1:numel(results) - [~, name, extension] = fileparts(results(k).outputPath); - outputs(end + 1, 1) = services.results.output("matched-" + string(k), ... - "matched-image", string(name) + string(extension), ... - mediaType(extension), results(k).status, results(k).message); - end -end - -function value = mediaType(extension) - if any(lower(string(extension)) == [".jpg", ".jpeg"]) - value = "image/jpeg"; - elseif any(lower(string(extension)) == [".tif", ".tiff"]) - value = "image/tiff"; - else - value = "image/png"; - end -end diff --git a/apps/image_measurement/image_match/+image_match/projectSpec.m b/apps/image_measurement/image_match/+image_match/projectSpec.m index c443f0317..728b9d8b8 100644 --- a/apps/image_measurement/image_match/+image_match/projectSpec.m +++ b/apps/image_measurement/image_match/+image_match/projectSpec.m @@ -1,17 +1,13 @@ -% App-owned durable Image Match contract. Runtime V2 calls this single entry +% App-owned durable Image Match contract. App SDK runtime calls this single entry % for current project creation and validation; version 1 needs no migration. function spec = projectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", []); + spec = labkit.app.project.Schema(Version=1, ... + Create=@createProject, Validate=@validateProject); end function project = createProject() project = struct(); - project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + project.inputs = struct("reference", emptySources(), "sources", emptySources()); project.parameters = struct( ... "matchMethod", "Balanced", ... "matchStrength", 100, ... @@ -26,6 +22,10 @@ project.extensions = struct(); end +function sources = emptySources() +sources = labkit.app.project.emptySourceRecords(); +end + function accepted = validateProject(project) assert(isfield(project.inputs, 'sources'), ... 'image_match:InvalidProject', 'Project sources are invalid.'); diff --git a/apps/image_measurement/image_match/labkit_ImageMatch_app.m b/apps/image_measurement/image_match/labkit_ImageMatch_app.m index f840b1174..62b87fc9c 100644 --- a/apps/image_measurement/image_match/labkit_ImageMatch_app.m +++ b/apps/image_measurement/image_match/labkit_ImageMatch_app.m @@ -1,6 +1,5 @@ function varargout = labkit_ImageMatch_app(varargin) %LABKIT_IMAGEMATCH_APP Reference image matching app for figure images. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @image_match.definition, varargin{:}); + [varargout{1:nargout}] = image_match.definition().launch(varargin{:}); end diff --git a/apps/image_measurement/video_marker/+video_marker/+debug/writeSamplePack.m b/apps/image_measurement/video_marker/+video_marker/+debug/writeSamplePack.m index 00765a09c..618f59baa 100644 --- a/apps/image_measurement/video_marker/+video_marker/+debug/writeSamplePack.m +++ b/apps/image_measurement/video_marker/+video_marker/+debug/writeSamplePack.m @@ -1,40 +1,61 @@ -%WRITESAMPLEPACK Create synthetic debug assets for labkit_VideoMarker_app. -% Expected caller: debug launch and debug sample-pack tests. Outputs contain -% generated local files only; no lab sample data is copied. -function pack = writeSamplePack(debugLog) - sampleFolder = string(debugLog.sampleFolder()); - outputFolder = string(debugLog.outputFolder()); - if exist(sampleFolder, 'dir') ~= 7 - mkdir(sampleFolder); - end - if exist(outputFolder, 'dir') ~= 7 - mkdir(outputFolder); - end +% App-owned implementation for video_marker.debug.writeSamplePack within the video_marker product workflow. +function pack = writeSamplePack(sampleContext) +%WRITESAMPLEPACK Create a typed synthetic Video Marker reproduction. +% Expected caller: diagnostic startup and focused sample-pack tests. The +% generated video and project contain no user paths, identifiers, or lab data. +arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext +end + +videoPath = sampleContext.samplePath( ... + "video_marker/synthetic_video_marker.avi"); +writeSyntheticVideo(videoPath); +[reader, info] = video_marker.videoSource.openVideo(videoPath); +clear reader - videoPath = fullfile(sampleFolder, "synthetic_video_marker.avi"); - writeSyntheticVideo(videoPath); +preset = video_marker.skeletonSetup.presets(); +project = video_marker.projectSpec().Create(); +project.inputs.sources = sampleContext.sourceRecord( ... + "video", "video", videoPath, true); +project.inputs.videoMetadata = ... + video_marker.videoSource.metadataFromInfo(info); +project.annotations.skeleton = ... + video_marker.skeletonDefinition.fromParts( ... + preset(1).pointNames, preset(1).edges); +project.annotations.frames = ... + video_marker.frameAnnotations.emptyAnnotations( ... + info.frameCount, numel(preset(1).pointNames)); +project.parameters.coordinateEndFrame = info.frameCount; - pack = struct(); - pack.sampleFolder = sampleFolder; - pack.outputFolder = outputFolder; - pack.representativeFiles = videoPath; - pack.boundaryFiles = struct(); - pack.boundaryFiles.project = fullfile(sampleFolder, "empty_video_marker_project.mat"); - pack.boundaryFiles.markerCsv = fullfile(sampleFolder, "empty_video_marker_markers.csv"); +markerOutput = sampleContext.outputPath( ... + "video_marker/video_marker_markers.csv"); +coordinateOutput = sampleContext.outputPath( ... + "video_marker/video_marker_coordinates.csv"); +pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-video-marking", ... + InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact("video", "video", videoPath), ... + sampleContext.artifact("markerCsv", "markerCsv", ... + markerOutput, Expectation="exports"), ... + sampleContext.artifact("coordinateCsv", "coordinateCsv", ... + coordinateOutput, Expectation="exports")}); end function writeSyntheticVideo(videoPath) - writer = VideoWriter(char(videoPath), 'Motion JPEG AVI'); - writer.FrameRate = 10; - open(writer); - cleanup = onCleanup(@() close(writer)); - for k = 1:6 - frame = uint8(zeros(72, 96, 3)); - x = 16 + 8 * k; - y = 42 + round(8 * sin(k / 2)); - frame(:, :, 1) = uint8(30 + 4 * k); - frame(:, :, 2) = uint8(40); - frame(max(1, y-2):min(72, y+2), max(1, x-2):min(96, x+2), :) = 240; - writeVideo(writer, frame); - end +writer = VideoWriter(char(videoPath), "Motion JPEG AVI"); +writer.FrameRate = 10; +open(writer); +cleanup = onCleanup(@() close(writer)); +for k = 1:6 + frame = uint8(zeros(72, 96, 3)); + x = 16 + 8 * k; + y = 42 + round(8 * sin(k / 2)); + frame(:, :, 1) = uint8(30 + 4 * k); + frame(:, :, 2) = uint8(40); + frame(max(1, y-2):min(72, y+2), ... + max(1, x-2):min(96, x+2), :) = 240; + writeVideo(writer, frame); +end +clear cleanup end diff --git a/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m b/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m new file mode 100644 index 000000000..8e6ce958c --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m @@ -0,0 +1,60 @@ +% App-owned implementation for video_marker.frameNavigation.changeFrame within the video_marker product workflow. +function state = changeFrame(state, value, context) +%CHANGEFRAME Decode the requested frame from the portable video source. +info = state.session.cache.videoInfo; +if info.frameCount <= 0 || isempty(state.session.cache.currentImage) + return +end +target = min(max(1, round(double(value))), info.frameCount); +startFrame = state.session.cache.frameIndex; +if target == startFrame + state.session.selection.currentFrame = target; + return +end +paths = context.resolveSourcePaths(state.project.inputs.sources); +if isempty(paths) || ~isfile(paths(1)) + state.session.selection.currentFrame = startFrame; + return +end +try + resource = context.getResource("document", "video"); + if ~isstruct(resource) || ~isscalar(resource) || ... + ~isfield(resource, "path") || resource.path ~= paths(1) + resource = video_marker.videoSource.openResource(paths(1)); + context.setResource("document", "video", resource, []); + end + info = resource.info; + [frames, imageData, report] = ... + video_marker.frameNavigation.loadTargetFrame( ... + resource.cache.readFrame, state.project.annotations.frames, ... + startFrame, target, ... + state.session.cache.currentImage, ... + numel(state.project.annotations.skeleton.pointIds)); +catch cause + context.reportError("Could not read video frame", cause); + context.alert(cause.message, "Could not read frame"); + state.session.selection.currentFrame = startFrame; + return +end +if frames.frameStatus(target) == ... + video_marker.frameAnnotations.statusCode("empty") + frames = video_marker.frameAnnotations.inheritDraft(frames, target); +end +state.project.annotations.frames = frames; +state.session.selection.currentFrame = target; +state.session.cache.currentImage = imageData; +state.session.cache.videoInfo = info; +state.session.cache.videoPath = paths(1); +state.session.cache.frameIndex = target; +state.session.workflow.scaleReferenceEditing = false; +state.session.view.scaleBar = []; +state = video_marker.resultFiles.clearExportState(state); +if report.predictedFrames > 0 + context.appendStatus("Predicted " + string(report.predictedFrames) + ... + " frame(s) through frame " + string(target) + "; " + ... + string(report.fallbackPoints) + ... + " point(s) used motion fallback."); +else + context.appendStatus("Moved to frame " + string(target) + "."); +end +end diff --git a/apps/image_measurement/video_marker/+video_marker/+frameNavigation/next.m b/apps/image_measurement/video_marker/+video_marker/+frameNavigation/next.m new file mode 100644 index 000000000..ce1b4a567 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+frameNavigation/next.m @@ -0,0 +1,6 @@ +% App-owned implementation for video_marker.frameNavigation.next within the video_marker product workflow. +function state = next(state, context) +%NEXT Move to the next video frame. +state = video_marker.frameNavigation.changeFrame( ... + state, state.session.cache.frameIndex + 1, context); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+frameNavigation/previous.m b/apps/image_measurement/video_marker/+video_marker/+frameNavigation/previous.m new file mode 100644 index 000000000..88cfa6adb --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+frameNavigation/previous.m @@ -0,0 +1,6 @@ +% App-owned implementation for video_marker.frameNavigation.previous within the video_marker product workflow. +function state = previous(state, context) +%PREVIOUS Move to the preceding video frame. +state = video_marker.frameNavigation.changeFrame( ... + state, state.session.cache.frameIndex - 1, context); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m new file mode 100644 index 000000000..4329dad8d --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m @@ -0,0 +1,23 @@ +% App-owned implementation for video_marker.markerEditing.changePoints within the video_marker product workflow. +function state = changePoints(state, points, context) +%CHANGEPOINTS Persist all keypoint slots for the active video frame. +if state.session.cache.videoInfo.frameCount <= 0 + return +end +if isstruct(points) && isscalar(points) && isfield(points, "points") + points = points.points; +end +points = double(points); +if isempty(points) + points = zeros(0, 2); +elseif size(points, 2) ~= 2 + return +end +points = points(all(isfinite(points), 2), :); +total = numel(state.project.annotations.skeleton.pointIds); +points = points(1:min(size(points, 1), total), :); +frame = state.session.cache.frameIndex; +state = video_marker.markerEditing.setPoints(state, points); +context.appendStatus("Frame " + string(frame) + " points: " + ... + string(size(points, 1)) + " / " + string(total) + "."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m new file mode 100644 index 000000000..b4079f3bf --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m @@ -0,0 +1,10 @@ +% App-owned implementation for video_marker.markerEditing.clear within the video_marker product workflow. +function state = clear(state, context) +%CLEAR Remove every point on the active frame. +if state.session.cache.videoInfo.frameCount <= 0 + return +end +frame = state.session.cache.frameIndex; +state = video_marker.markerEditing.setPoints(state, zeros(0, 2)); +context.appendStatus("Cleared frame " + string(frame) + " points."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/currentPoints.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/currentPoints.m new file mode 100644 index 000000000..00bbc49f8 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/currentPoints.m @@ -0,0 +1,10 @@ +% App-owned implementation for video_marker.markerEditing.currentPoints within the video_marker product workflow. +function points = currentPoints(state) +%CURRENTPOINTS Return finite placed points for the active frame. +points = zeros(0, 2); +frames = state.project.annotations.frames; +index = state.session.cache.frameIndex; +if ~isempty(frames.coords) && index >= 1 && index <= size(frames.coords, 1) + points = video_marker.frameAnnotations.framePoints(frames, index); +end +end diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/setPoints.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/setPoints.m new file mode 100644 index 000000000..d8aee39f5 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/setPoints.m @@ -0,0 +1,18 @@ +% App-owned implementation for video_marker.markerEditing.setPoints within the video_marker product workflow. +function applicationState = setPoints(applicationState, points) +%SETPOINTS Store one frame's ordered points and invalidate stale exports. +total = numel(applicationState.project.annotations.skeleton.pointIds); +status = "draft"; +if isempty(points) + status = "empty"; +elseif size(points, 1) == total + status = "confirmed"; +end +frame = applicationState.session.cache.frameIndex; +applicationState.project.annotations.frames = ... + video_marker.frameAnnotations.setFramePoints( ... + applicationState.project.annotations.frames, frame, points, status, ... + "manual", ones(size(points, 1), 1)); +applicationState = ... + video_marker.resultFiles.clearExportState(applicationState); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m new file mode 100644 index 000000000..3dbef4ba6 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m @@ -0,0 +1,12 @@ +% App-owned implementation for video_marker.markerEditing.undo within the video_marker product workflow. +function state = undo(state, context) +%UNDO Remove the last point on the active frame. +points = video_marker.markerEditing.currentPoints(state); +if isempty(points) + return +end +points(end, :) = []; +frame = state.session.cache.frameIndex; +state = video_marker.markerEditing.setPoints(state, points); +context.appendStatus("Undid last point on frame " + string(frame) + "."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+resultFiles/clearExportState.m b/apps/image_measurement/video_marker/+video_marker/+resultFiles/clearExportState.m new file mode 100644 index 000000000..d68049d89 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+resultFiles/clearExportState.m @@ -0,0 +1,6 @@ +% App-owned implementation for video_marker.resultFiles.clearExportState within the video_marker product workflow. +function state = clearExportState(state) +%CLEAREXPORTSTATE Invalidate result manifests after an annotation change. +state.project.results.markerManifestPath = ""; +state.project.results.coordinateManifestPath = ""; +end diff --git a/apps/image_measurement/video_marker/+video_marker/+resultFiles/defaultOutputPath.m b/apps/image_measurement/video_marker/+video_marker/+resultFiles/defaultOutputPath.m new file mode 100644 index 000000000..5da25645a --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+resultFiles/defaultOutputPath.m @@ -0,0 +1,27 @@ +% App-owned implementation for video_marker.resultFiles.defaultOutputPath within the video_marker product workflow. +function filepath = defaultOutputPath(videoPath, filename) +%DEFAULTOUTPUTPATH Return the source-adjacent Video Marker export path. +% Expected callers are the marker and coordinate export callbacks. Inputs +% are the resolved source video path and one fixed App-owned output filename. +videoPath = string(videoPath); +filename = string(filename); +if ~isscalar(videoPath) || ~isscalar(filename) || strlength(filename) == 0 + error("video_marker:InvalidOutputPath", ... + "Video Marker export paths require scalar source and file names."); +end +[sourceFolder, ~, ~] = fileparts(videoPath); +if strlength(sourceFolder) == 0 || ~isfolder(sourceFolder) + sourceFolder = string(pwd); +end +folder = fullfile(sourceFolder, "video_marker"); +if ~isfolder(folder) + try + mkdir(folder); + catch + end +end +if ~isfolder(folder) + folder = sourceFolder; +end +filepath = string(fullfile(folder, filename)); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportCoordinates.m b/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportCoordinates.m new file mode 100644 index 000000000..c0e2ae090 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportCoordinates.m @@ -0,0 +1,55 @@ +% App-owned implementation for video_marker.resultFiles.exportCoordinates within the video_marker product workflow. +function state = exportCoordinates(state, context) +%EXPORTCOORDINATES Write coordinate CSV and an App result manifest. +if isempty(state.session.cache.currentImage) + context.alert("Open a video before exporting coordinates.", "No video"); + return +end +paths = context.resolveSourcePaths(state.project.inputs.sources, "video"); +startPath = video_marker.resultFiles.defaultOutputPath( ... + paths(1), "video_marker_coordinates.csv"); +choice = context.chooseOutputFile( ... + ["*.csv", "Coordinate CSV files"], startPath); +if choice.Cancelled + context.appendStatus("Coordinate export cancelled."); + return +end +filepath = string(choice.Value); +parameters = state.project.parameters; +options = video_marker.coordinateExport.options( ... + "startFrame", parameters.coordinateStartFrame, ... + "endFrame", parameters.coordinateEndFrame, ... + "unitMode", parameters.coordinateUnitMode, ... + "originMode", parameters.coordinateOriginMode, ... + "yAxisMode", parameters.coordinateYAxisMode); +try + data = video_marker.coordinateExport.buildTable( ... + state.project.annotations.frames, ... + state.project.annotations.skeleton, ... + state.session.cache.videoInfo, ... + state.project.annotations.calibration, options); + writetable(data, filepath); + written = writeManifest(state, context, filepath, ... + "coordinateCsv", "video_marker_coordinates.labkit.json"); +catch cause + context.reportError("Could not export coordinate CSV", cause); + context.alert(cause.message, "Could not export coordinate CSV"); + return +end +state.project.results.coordinateManifestPath = string(written.Value); +context.appendStatus("Exported coordinate CSV: " + filepath); +end + +function written = writeManifest(state, context, filepath, id, manifestName) +[folder, name, extension] = fileparts(filepath); +output = labkit.app.result.File(id, "primary", ... + string(name) + string(extension), MediaType="text/csv"); +package = labkit.app.result.Package( ... + Outputs={output}, ... + Inputs=struct("sources", state.project.inputs.sources), ... + Parameters=state.project.parameters, ... + Summary=video_marker.frameAnnotations.summary( ... + state.project.annotations.frames), ... + ManifestName=manifestName); +written = context.writeResultPackage(folder, package); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportMarkers.m b/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportMarkers.m new file mode 100644 index 000000000..2c6e140b6 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportMarkers.m @@ -0,0 +1,42 @@ +% App-owned implementation for video_marker.resultFiles.exportMarkers within the video_marker product workflow. +function state = exportMarkers(state, context) +%EXPORTMARKERS Write the self-describing marker CSV and result manifest. +if isempty(state.session.cache.currentImage) + context.alert("Open a video before exporting marker CSV.", "No video"); + return +end +paths = context.resolveSourcePaths(state.project.inputs.sources, "video"); +startPath = video_marker.resultFiles.defaultOutputPath( ... + paths(1), "video_marker_markers.csv"); +choice = context.chooseOutputFile( ... + ["*.csv", "Marker CSV files"], startPath); +if choice.Cancelled + context.appendStatus("Marker export cancelled."); + return +end +filepath = string(choice.Value); +try + video_marker.markerCsv.writeFile(filepath, ... + state.project.annotations.frames, ... + state.project.annotations.skeleton, ... + state.session.cache.videoInfo, ... + state.project.annotations.calibration); + [folder, name, extension] = fileparts(filepath); + output = labkit.app.result.File("markerCsv", "primary", ... + string(name) + string(extension), MediaType="text/csv"); + package = labkit.app.result.Package( ... + Outputs={output}, ... + Inputs=struct("sources", state.project.inputs.sources), ... + Parameters=state.project.parameters, ... + Summary=video_marker.frameAnnotations.summary( ... + state.project.annotations.frames), ... + ManifestName="video_marker_markers.labkit.json"); + written = context.writeResultPackage(folder, package); +catch cause + context.reportError("Could not export marker CSV", cause); + context.alert(cause.message, "Could not export marker CSV"); + return +end +state.project.results.markerManifestPath = string(written.Value); +context.appendStatus("Exported marker CSV: " + filepath); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m b/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m new file mode 100644 index 000000000..856569c47 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m @@ -0,0 +1,48 @@ +% App-owned implementation for video_marker.resultFiles.importMarkers within the video_marker product workflow. +function state = importMarkers(state, context) +%IMPORTMARKERS Replace annotations from a validated round-trip marker CSV. +choice = context.chooseInputFile( ... + ["*.csv", "Marker CSV files"], pwd); +if choice.Cancelled + context.appendStatus("Marker import cancelled."); + return +end +filepath = string(choice.Value); +try + payload = video_marker.markerCsv.readFile(filepath); +catch cause + context.reportError("Could not import marker CSV", cause); + context.alert(cause.message, "Could not import marker CSV"); + return +end +state.project.annotations.skeleton = payload.skeleton; +state.project.annotations.frames = payload.annotations; +state.project.annotations.calibration = payload.calibration; +state.project.inputs.videoMetadata = ... + video_marker.videoSource.metadataFromInfo(payload.videoInfo); +videoPath = string(payload.videoInfo.path); +if strlength(videoPath) > 0 + state.project.inputs.sources = labkit.app.project.sourceRecord( ... + "video", "video", videoPath, isfile(videoPath)); +else + state.project.inputs.sources = struct([]); +end +state = video_marker.skeletonSetup.normalizeSelection(state, true); +state.session = video_marker.createSession(state.project, context); +if ~isempty(state.session.cache.currentImage) + info = state.session.cache.videoInfo; + if size(payload.annotations.coords, 1) ~= info.frameCount || ... + size(payload.annotations.coords, 2) ~= ... + numel(payload.skeleton.pointIds) + context.alert( ... + "Imported annotations do not match the referenced video.", ... + "Marker CSV mismatch"); + state.session.cache.currentImage = []; + end +end +state.project.parameters.coordinateStartFrame = 1; +state.project.parameters.coordinateEndFrame = ... + max(1, payload.videoInfo.frameCount); +state = video_marker.resultFiles.clearExportState(state); +context.appendStatus("Imported marker CSV: " + filepath); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+resultFiles/settingsChanged.m b/apps/image_measurement/video_marker/+video_marker/+resultFiles/settingsChanged.m new file mode 100644 index 000000000..5eb285890 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+resultFiles/settingsChanged.m @@ -0,0 +1,18 @@ +% App-owned implementation for video_marker.resultFiles.settingsChanged within the video_marker product workflow. +function state = settingsChanged(state, ~, ~) +%SETTINGSCHANGED Sanitize coordinate export range and invalidate its manifest. +count = max(1, state.session.cache.videoInfo.frameCount); +state.project.parameters.coordinateStartFrame = boundedFrame( ... + state.project.parameters.coordinateStartFrame, 1, count); +state.project.parameters.coordinateEndFrame = boundedFrame( ... + state.project.parameters.coordinateEndFrame, count, count); +state.project.results.coordinateManifestPath = ""; +end + +function value = boundedFrame(candidate, fallback, count) +value = fallback; +if isnumeric(candidate) && isscalar(candidate) && isfinite(double(candidate)) + value = round(double(candidate)); +end +value = min(max(1, value), count); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/barSettingsChanged.m b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/barSettingsChanged.m new file mode 100644 index 000000000..93ec2b873 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/barSettingsChanged.m @@ -0,0 +1,11 @@ +% App-owned implementation for video_marker.scaleCalibration.barSettingsChanged within the video_marker product workflow. +function state = barSettingsChanged(state, ~, ~) +%BARSETTINGSCHANGED Sanitize scale-bar settings and clear transient geometry. +value = state.project.parameters.scaleBarLength; +if ~(isnumeric(value) && isscalar(value) && ... + isfinite(double(value)) && value >= 0) + state.project.parameters.scaleBarLength = 0; +end +state.session.view.scaleBar = []; +state = video_marker.resultFiles.clearExportState(state); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/changeLength.m b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/changeLength.m new file mode 100644 index 000000000..02136c530 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/changeLength.m @@ -0,0 +1,16 @@ +% App-owned implementation for video_marker.scaleCalibration.changeLength within the video_marker product workflow. +function state = changeLength(state, value, ~) +%CHANGELENGTH Set the physical reference length. +calibration = state.project.annotations.calibration; +lengthValue = calibration.referenceLength; +if isnumeric(value) && isscalar(value) && ... + isfinite(double(value)) && value >= 0 + lengthValue = double(value); +end +state.project.annotations.calibration = ... + labkit.app.interaction.scaleCalibration( ... + calibration.referencePixels, lengthValue, calibration.unit, ... + struct("referenceLine", calibration.referenceLine)); +state.session.view.scaleBar = []; +state = video_marker.resultFiles.clearExportState(state); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/changePixels.m b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/changePixels.m new file mode 100644 index 000000000..d951b1250 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/changePixels.m @@ -0,0 +1,14 @@ +% App-owned implementation for video_marker.scaleCalibration.changePixels within the video_marker product workflow. +function state = changePixels(state, value, ~) +%CHANGEPIXELS Set the reference pixel length directly. +calibration = state.project.annotations.calibration; +pixels = NaN; +if isnumeric(value) && isscalar(value) && isfinite(double(value)) && value > 0 + pixels = double(value); +end +state.project.annotations.calibration = ... + labkit.app.interaction.scaleCalibration( ... + pixels, calibration.referenceLength, calibration.unit); +state.session.view.scaleBar = []; +state = video_marker.resultFiles.clearExportState(state); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/changeReference.m b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/changeReference.m new file mode 100644 index 000000000..0a023014c --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/changeReference.m @@ -0,0 +1,19 @@ +% App-owned implementation for video_marker.scaleCalibration.changeReference within the video_marker product workflow. +function state = changeReference(state, endpoints, ~) +%CHANGEREFERENCE Store a measured pixel reference line. +if state.session.cache.videoInfo.frameCount <= 0 || size(endpoints, 2) ~= 2 + return +end +calibration = state.project.annotations.calibration; +endpoints = double(endpoints); +pixels = NaN; +if size(endpoints, 1) == 2 + pixels = norm(diff(endpoints, 1, 1)); +end +state.project.annotations.calibration = ... + labkit.app.interaction.scaleCalibration( ... + pixels, calibration.referenceLength, calibration.unit, ... + struct("referenceLine", endpoints)); +state.session.view.scaleBar = []; +state = video_marker.resultFiles.clearExportState(state); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/changeUnit.m b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/changeUnit.m new file mode 100644 index 000000000..21f857dd3 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/changeUnit.m @@ -0,0 +1,11 @@ +% App-owned implementation for video_marker.scaleCalibration.changeUnit within the video_marker product workflow. +function state = changeUnit(state, value, ~) +%CHANGEUNIT Set the physical calibration unit. +calibration = state.project.annotations.calibration; +state.project.annotations.calibration = ... + labkit.app.interaction.scaleCalibration( ... + calibration.referencePixels, calibration.referenceLength, string(value), ... + struct("referenceLine", calibration.referenceLine)); +state.session.view.scaleBar = []; +state = video_marker.resultFiles.clearExportState(state); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/layoutSection.m b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/layoutSection.m new file mode 100644 index 000000000..7186fb086 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/layoutSection.m @@ -0,0 +1,42 @@ +% App-owned implementation for video_marker.scaleCalibration.layoutSection within the video_marker product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare calibration and display scale-bar controls. +% Constant: numeric limits and steps preserve the legacy calibration controls. +maximumCalibrationValue = 1e6; +controls = { ... + labkit.app.layout.button("measureScaleReference", ... + "Measure reference pixels", ... + @video_marker.scaleCalibration.toggleReference, ... + Tooltip="Measure a known distance in a video frame to calibrate pixel coordinates to physical units."), ... + labkit.app.layout.slider("scaleReferencePixels", ... + Label="Reference pixels", Limits=[0 5000], Step=1, ... + OnValueChanged=@video_marker.scaleCalibration.changePixels), ... + labkit.app.layout.slider("scaleReferenceLength", ... + Label="Reference length", Value=100, ... + Limits=[0 maximumCalibrationValue], Step=10, ... + OnValueChanged=@video_marker.scaleCalibration.changeLength), ... + labkit.app.layout.field("scaleCalibrationUnit", Label="Scale unit", ... + Kind="choice", Choices=["m" "cm" "mm" "um" "nm"], ... + OnValueChanged=@video_marker.scaleCalibration.changeUnit), ... + labkit.app.layout.slider("scaleBarLength", Label="Scale bar length", ... + Value=100, Limits=[0 maximumCalibrationValue], Step=10, ... + Bind="project.parameters.scaleBarLength", ... + OnValueChanged=@video_marker.scaleCalibration.barSettingsChanged), ... + labkit.app.layout.field("scaleBarPosition", Label="Scale position", ... + Kind="choice", Choices=["Bottom center" "Bottom left" ... + "Bottom right" "Top center" "Top left" "Top right"], ... + Bind="project.parameters.scaleBarPosition", ... + OnValueChanged=@video_marker.scaleCalibration.barSettingsChanged), ... + labkit.app.layout.field("scaleBarColor", Label="Scale color", ... + Kind="choice", Choices=["Black" "White"], ... + Bind="project.parameters.scaleBarColor", ... + OnValueChanged=@video_marker.scaleCalibration.barSettingsChanged), ... + labkit.app.layout.button("placeScaleBar", "Place scale bar", ... + @video_marker.scaleCalibration.placeBar, ... + Tooltip="Place a calibrated display scale bar on the video preview using the current pixels-per-unit value."), ... + labkit.app.layout.field("scaleReferenceReadout", ... + Label="Reference px", Kind="readonly", Enabled=false), ... + labkit.app.layout.field("pixelsPerUnitReadout", ... + Label="Pixels/unit", Kind="readonly", Enabled=false)}; +section = labkit.app.layout.section("scaleSection", "Scale", controls); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/placeBar.m b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/placeBar.m new file mode 100644 index 000000000..3e5848ebd --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/placeBar.m @@ -0,0 +1,22 @@ +% App-owned implementation for video_marker.scaleCalibration.placeBar within the video_marker product workflow. +function state = placeBar(state, context) +%PLACEBAR Build display-only scale-bar geometry for the current frame. +calibration = state.project.annotations.calibration; +if isempty(state.session.cache.currentImage) || ~calibration.isCalibrated + context.alert(["Measure or enter reference pixels, then enter a positive " ... + "reference length and unit."], "Calibration required"); + return +end +try + parameters = state.project.parameters; + state.session.view.scaleBar = ... + labkit.app.interaction.scaleBarGeometry( ... + size(state.session.cache.currentImage), calibration, ... + parameters.scaleBarLength, parameters.scaleBarPosition, ... + parameters.scaleBarColor); + state.session.workflow.scaleReferenceEditing = false; +catch cause + context.reportError("Could not place scale bar", cause); + context.alert(cause.message, "Could not place scale bar"); +end +end diff --git a/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/present.m b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/present.m new file mode 100644 index 000000000..5f7159177 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/present.m @@ -0,0 +1,47 @@ +% App-owned implementation for video_marker.scaleCalibration.present within the video_marker product workflow. +function view = present(state) +%PRESENT Describe calibration controls and the reference interaction. +hasVideo = ~isempty(state.session.cache.currentImage); +calibration = state.project.annotations.calibration; +editing = state.session.workflow.scaleReferenceEditing; +pixels = calibration.referencePixels; +readout = "-"; +if isfinite(pixels) + readout = sprintf("%.6g", pixels); +else + pixels = 0; +end +pixelsPerUnit = "-"; +if calibration.pixelsPerUnit > 0 + pixelsPerUnit = sprintf("%.6g px/%s", ... + calibration.pixelsPerUnit, calibration.unit); +end +imageSize = []; +if hasVideo + imageSize = size(state.session.cache.currentImage); +end +view = labkit.app.view.Snapshot() ... + .enabled("measureScaleReference", hasVideo) ... + .text("measureScaleReference", buttonText(editing)) ... + .enabled("scaleReferencePixels", hasVideo && ~editing) ... + .value("scaleReferencePixels", pixels) ... + .enabled("scaleReferenceLength", hasVideo) ... + .value("scaleReferenceLength", calibration.referenceLength) ... + .enabled("scaleCalibrationUnit", hasVideo) ... + .value("scaleCalibrationUnit", string(calibration.unit)) ... + .enabled("scaleBarLength", hasVideo) ... + .enabled("scaleBarPosition", hasVideo) ... + .enabled("scaleBarColor", hasVideo) ... + .enabled("placeScaleBar", hasVideo && calibration.isCalibrated && ~editing) ... + .value("scaleReferenceReadout", readout) ... + .value("pixelsPerUnitReadout", pixelsPerUnit) ... + .scaleReference("scaleReference", calibration.referenceLine, ... + ImageSize=imageSize, Enabled=hasVideo && editing); +end + +function value = buttonText(editing) +value = "Measure reference pixels"; +if editing + value = "Finish reference edit"; +end +end diff --git a/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/toggleReference.m b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/toggleReference.m new file mode 100644 index 000000000..238761d7a --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/toggleReference.m @@ -0,0 +1,12 @@ +% App-owned implementation for video_marker.scaleCalibration.toggleReference within the video_marker product workflow. +function state = toggleReference(state, context) +%TOGGLEREFERENCE Enable or finish managed reference-line editing. +if state.session.cache.videoInfo.frameCount <= 0 + context.alert("Open a video before measuring reference pixels.", ... + "No video loaded"); + return +end +state.session.workflow.scaleReferenceEditing = ... + ~state.session.workflow.scaleReferenceEditing; +state.session.view.scaleBar = []; +end diff --git a/apps/image_measurement/video_marker/+video_marker/+sessionControl/newSetup.m b/apps/image_measurement/video_marker/+video_marker/+sessionControl/newSetup.m new file mode 100644 index 000000000..aa79e37ee --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+sessionControl/newSetup.m @@ -0,0 +1,38 @@ +% App-owned implementation for video_marker.sessionControl.newSetup within the video_marker product workflow. +function applicationState = newSetup(applicationState, callbackContext) +%NEWSETUP Clear the current video, skeleton, and annotations after confirmation. +choice = callbackContext.chooseOption( ... + "Starting a new setup clears the current video, skeleton, and " + ... + "annotations. Saving the current project first is recommended.", ... + ["Cancel", "Save and start new", "Discard and start new"], ... + Title="Start a new setup?", ... + DefaultChoice="Save and start new", CancelChoice="Cancel"); +answer = string(choice.Value); +if choice.Cancelled || answer == "Cancel" + callbackContext.appendStatus("New setup cancelled."); + return +end +if answer == "Save and start new" + destination = callbackContext.chooseOutputFile( ... + ["*.mat", "LabKit project files"], "video-marker-project.mat"); + if destination.Cancelled + callbackContext.appendStatus( ... + "New setup cancelled because project save was cancelled."); + return + end + saved = callbackContext.saveProjectDocument( ... + applicationState, destination.Value); + if saved.Cancelled + callbackContext.appendStatus( ... + "New setup cancelled because project save was cancelled."); + return + end +elseif answer ~= "Discard and start new" + callbackContext.appendStatus("New setup cancelled."); + return +end +callbackContext.clearResourceScope("document"); +applicationState = callbackContext.newProjectDocument(); +callbackContext.appendStatus( ... + "Started a new skeleton setup and cleared the annotation session."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+sessionControl/openProject.m b/apps/image_measurement/video_marker/+video_marker/+sessionControl/openProject.m new file mode 100644 index 000000000..609c28607 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+sessionControl/openProject.m @@ -0,0 +1,20 @@ +% App-owned implementation for video_marker.sessionControl.openProject within the video_marker product workflow. +function applicationState = openProject(applicationState, callbackContext) +%OPENPROJECT Restore a Video Marker MAT document through the active runtime. +choice = callbackContext.chooseInputFile( ... + ["*.mat", "LabKit project files"], ""); +if choice.Cancelled + callbackContext.appendStatus("Project open cancelled."); + return +end +filepath = string(choice.Value); +try + applicationState = ... + callbackContext.restoreProjectDocument(filepath); +catch cause + callbackContext.reportError("Could not open Video Marker project", cause); + callbackContext.alert(cause.message, "Could not open project"); + return +end +callbackContext.appendStatus("Opened project: " + filepath); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+sessionControl/saveAutosave.m b/apps/image_measurement/video_marker/+video_marker/+sessionControl/saveAutosave.m new file mode 100644 index 000000000..0b5c549d8 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+sessionControl/saveAutosave.m @@ -0,0 +1,28 @@ +% App-owned implementation for video_marker.sessionControl.saveAutosave within the video_marker product workflow. +function applicationState = saveAutosave(applicationState, callbackContext) +%SAVEAUTOSAVE Write the deterministic source-adjacent autosave document. +videoPath = applicationState.session.cache.videoPath; +if strlength(videoPath) == 0 + callbackContext.appendStatus( ... + "Autosave unavailable until a video is open."); + return +end +try + filepath = video_marker.autosave.filePath(videoPath); + applicationState.project.inputs.videoMetadata = ... + video_marker.videoSource.metadataFromInfo( ... + applicationState.session.cache.videoInfo); + [folder, ~, ~] = fileparts(filepath); + if ~isfolder(folder) + mkdir(folder); + end + callbackContext.saveRecoveryDocument(applicationState, filepath); +catch cause + callbackContext.reportError( ... + "Could not save Video Marker autosave", cause); + callbackContext.alert(cause.message, "Could not save autosave"); + callbackContext.appendStatus("Autosave failed: " + cause.message); + return +end +callbackContext.appendStatus("Autosave updated: " + filepath); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addConnection.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addConnection.m new file mode 100644 index 000000000..ff78536f2 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addConnection.m @@ -0,0 +1,21 @@ +% App-owned implementation for video_marker.skeletonSetup.addConnection within the video_marker product workflow. +function state = addConnection(state, context) +%ADDCONNECTION Add the selected editable skeleton edge. +if state.session.cache.videoInfo.frameCount > 0 + return +end +names = string(state.project.annotations.skeleton.pointNames); +a = find(names == state.session.selection.connectionFrom, 1); +b = find(names == state.session.selection.connectionTo, 1); +if isempty(a) || isempty(b) || a == b + context.alert("Choose two different keypoints.", "Invalid connection"); + return +end +state.project.annotations.skeleton = ... + video_marker.skeletonDefinition.addEdge( ... + state.project.annotations.skeleton, a, b); +state.session.selection.selectedEdgeIndex = ... + size(state.project.annotations.skeleton.edges, 1); +state = video_marker.resultFiles.clearExportState(state); +context.appendStatus("Connected " + names(a) + " to " + names(b) + "."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addKeypoint.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addKeypoint.m new file mode 100644 index 000000000..19e27e33f --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addKeypoint.m @@ -0,0 +1,15 @@ +% App-owned implementation for video_marker.skeletonSetup.addKeypoint within the video_marker product workflow. +function state = addKeypoint(state, context) +%ADDKEYPOINT Append one editable skeleton point. +if state.session.cache.videoInfo.frameCount > 0 + return +end +[state.project.annotations.skeleton, index] = ... + video_marker.skeletonDefinition.addPoint( ... + state.project.annotations.skeleton); +state.session.selection.selectedPointIndex = index; +state = video_marker.skeletonSetup.normalizeSelection(state); +state = video_marker.resultFiles.clearExportState(state); +context.appendStatus("Added keypoint " + ... + state.project.annotations.skeleton.pointNames(index) + "."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/connectInOrder.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/connectInOrder.m new file mode 100644 index 000000000..85272bdd3 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/connectInOrder.m @@ -0,0 +1,14 @@ +% App-owned implementation for video_marker.skeletonSetup.connectInOrder within the video_marker product workflow. +function state = connectInOrder(state, context) +%CONNECTINORDER Add every adjacent ordered keypoint edge. +if state.session.cache.videoInfo.frameCount > 0 || ... + numel(state.project.annotations.skeleton.pointNames) < 2 + return +end +state.project.annotations.skeleton = ... + video_marker.skeletonDefinition.connectInOrder( ... + state.project.annotations.skeleton); +state.session.selection.selectedEdgeIndex = 0; +state = video_marker.resultFiles.clearExportState(state); +context.appendStatus("Connected adjacent keypoints in order."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m deleted file mode 100644 index 3d6ca95f0..000000000 --- a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/definitionActions.m +++ /dev/null @@ -1,185 +0,0 @@ -% Expected caller: video_marker.definitionActions. Output owns the editable -% skeleton action slice before a video locks its point/connection schema. -function actions = definitionActions() - actions = struct( ... - "useSkeletonPreset", @onUseSkeletonPreset, ... - "keypointEdited", @onKeypointEdited, ... - "keypointSelected", @onKeypointSelected, ... - "addKeypoint", @onAddKeypoint, ... - "removeKeypoint", @onRemoveKeypoint, ... - "moveKeypointUp", @(state, event, services) ... - moveKeypoint(state, event, services, -1), ... - "moveKeypointDown", @(state, event, services) ... - moveKeypoint(state, event, services, 1), ... - "connectionSelected", @onConnectionSelected, ... - "connectionEndpointChanged", @onConnectionEndpointChanged, ... - "addConnection", @onAddConnection, ... - "connectInOrder", @onConnectInOrder, ... - "removeConnection", @onRemoveConnection); -end - -function state = onUseSkeletonPreset(state, ~, services) - if skeletonLocked(state) - return; - end - presets = video_marker.userInterface.skeletonPresets(); - selected = string(state.session.selection.skeletonPreset); - match = find([presets.label] == selected, 1, 'first'); - if isempty(match) - return; - end - state.project.annotations.skeleton = ... - video_marker.skeletonDefinition.fromParts( ... - presets(match).pointNames, presets(match).edges); - state = video_marker.skeletonSetup.normalizeSelection(state, true); - state = services.workflow.log(state, ... - "Applied skeleton preset: " + selected + "."); -end - -function state = onKeypointEdited(state, event, services) - if skeletonLocked(state) - return; - end - indices = services.events.entries(event, "indices"); - value = services.events.entries(event, "newData"); - if numel(indices) ~= 2 || indices(2) ~= 2 - return; - end - try - state.project.annotations.skeleton = ... - video_marker.skeletonDefinition.renamePoint( ... - state.project.annotations.skeleton, indices(1), value); - state = video_marker.skeletonSetup.normalizeSelection(state); - state = services.workflow.log(state, sprintf( ... - 'Renamed keypoint %d.', indices(1))); - catch ME - services.diagnostics.report('Invalid keypoint name', ME); - services.dialogs.alert(ME.message, 'Invalid keypoint name'); - end -end - -function state = onKeypointSelected(state, event, services) - indices = services.events.entries(event, "indices"); - state.session.selection.selectedPointIndex = selectedRow(indices); -end - -function state = onAddKeypoint(state, ~, services) - if skeletonLocked(state) - return; - end - [state.project.annotations.skeleton, index] = ... - video_marker.skeletonDefinition.addPoint( ... - state.project.annotations.skeleton); - state.session.selection.selectedPointIndex = index; - state = video_marker.skeletonSetup.normalizeSelection(state); - state = services.workflow.log(state, ... - "Added keypoint " + ... - state.project.annotations.skeleton.pointNames(index) + "."); -end - -function state = onRemoveKeypoint(state, ~, services) - index = state.session.selection.selectedPointIndex; - names = state.project.annotations.skeleton.pointNames; - if skeletonLocked(state) || index < 1 || index > numel(names) - return; - end - state.project.annotations.skeleton = ... - video_marker.skeletonDefinition.removePoint( ... - state.project.annotations.skeleton, index); - state.session.selection.selectedPointIndex = min( ... - index, numel(state.project.annotations.skeleton.pointNames)); - state.session.selection.selectedEdgeIndex = 0; - state = video_marker.skeletonSetup.normalizeSelection(state); - state = services.workflow.log(state, sprintf( ... - 'Removed keypoint %d.', index)); -end - -function state = moveKeypoint(state, ~, services, delta) - index = state.session.selection.selectedPointIndex; - names = state.project.annotations.skeleton.pointNames; - if skeletonLocked(state) || index < 1 || index > numel(names) - return; - end - [state.project.annotations.skeleton, index] = ... - video_marker.skeletonDefinition.movePoint( ... - state.project.annotations.skeleton, index, delta); - state.session.selection.selectedPointIndex = index; - state = video_marker.skeletonSetup.normalizeSelection(state); - state = services.workflow.log(state, sprintf( ... - 'Moved keypoint to position %d.', index)); -end - -function state = onConnectionSelected(state, event, services) - indices = services.events.entries(event, "indices"); - state.session.selection.selectedEdgeIndex = selectedRow(indices); -end - -function state = onConnectionEndpointChanged(state, event, ~) - target = string(event.target); - if target == "connectionFrom" - state.session.selection.connectionFrom = string(event.value); - elseif target == "connectionTo" - state.session.selection.connectionTo = string(event.value); - end - state = video_marker.skeletonSetup.normalizeSelection(state); -end - -function state = onAddConnection(state, ~, services) - names = string(state.project.annotations.skeleton.pointNames(:)); - if skeletonLocked(state) || numel(names) < 2 - return; - end - a = find(names == string(state.session.selection.connectionFrom), 1); - b = find(names == string(state.session.selection.connectionTo), 1); - if isempty(a) || isempty(b) || a == b - services.dialogs.alert( ... - 'Choose two different keypoints.', 'Invalid connection'); - return; - end - state.project.annotations.skeleton = ... - video_marker.skeletonDefinition.addEdge( ... - state.project.annotations.skeleton, a, b); - state.session.selection.selectedEdgeIndex = ... - size(state.project.annotations.skeleton.edges, 1); - state = services.workflow.log(state, ... - "Connected " + names(a) + " to " + names(b) + "."); -end - -function state = onConnectInOrder(state, ~, services) - if skeletonLocked(state) || ... - numel(state.project.annotations.skeleton.pointNames) < 2 - return; - end - state.project.annotations.skeleton = ... - video_marker.skeletonDefinition.connectInOrder( ... - state.project.annotations.skeleton); - state.session.selection.selectedEdgeIndex = 0; - state = services.workflow.log(state, ... - "Connected adjacent keypoints in order."); -end - -function state = onRemoveConnection(state, ~, services) - index = state.session.selection.selectedEdgeIndex; - edgeCount = size(state.project.annotations.skeleton.edges, 1); - if skeletonLocked(state) || index < 1 || index > edgeCount - return; - end - state.project.annotations.skeleton = ... - video_marker.skeletonDefinition.removeEdge( ... - state.project.annotations.skeleton, index); - state.session.selection.selectedEdgeIndex = min( ... - index, size(state.project.annotations.skeleton.edges, 1)); - state = services.workflow.log(state, sprintf( ... - 'Removed connection %d.', index)); -end - -function row = selectedRow(indices) - row = 0; - if ~isempty(indices) - row = max(0, round(double(indices(1)))); - end -end - -function tf = skeletonLocked(state) - tf = state.session.cache.videoInfo.frameCount > 0; -end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveDown.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveDown.m new file mode 100644 index 000000000..416e895b4 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveDown.m @@ -0,0 +1,16 @@ +% App-owned implementation for video_marker.skeletonSetup.moveDown within the video_marker product workflow. +function state = moveDown(state, context) +%MOVEDOWN Move the selected keypoint one position later. +if state.session.cache.videoInfo.frameCount > 0 + return +end +[state.project.annotations.skeleton, ... + state.session.selection.selectedPointIndex] = ... + video_marker.skeletonDefinition.movePoint( ... + state.project.annotations.skeleton, ... + state.session.selection.selectedPointIndex, 1); +state = video_marker.skeletonSetup.normalizeSelection(state); +state = video_marker.resultFiles.clearExportState(state); +context.appendStatus("Moved keypoint to position " + ... + string(state.session.selection.selectedPointIndex) + "."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveUp.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveUp.m new file mode 100644 index 000000000..1234cbab5 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveUp.m @@ -0,0 +1,16 @@ +% App-owned implementation for video_marker.skeletonSetup.moveUp within the video_marker product workflow. +function state = moveUp(state, context) +%MOVEUP Move the selected keypoint one position earlier. +if state.session.cache.videoInfo.frameCount > 0 + return +end +[state.project.annotations.skeleton, ... + state.session.selection.selectedPointIndex] = ... + video_marker.skeletonDefinition.movePoint( ... + state.project.annotations.skeleton, ... + state.session.selection.selectedPointIndex, -1); +state = video_marker.skeletonSetup.normalizeSelection(state); +state = video_marker.resultFiles.clearExportState(state); +context.appendStatus("Moved keypoint to position " + ... + string(state.session.selection.selectedPointIndex) + "."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/presets.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/presets.m new file mode 100644 index 000000000..eff64df75 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/presets.m @@ -0,0 +1,18 @@ +% App-owned implementation for video_marker.skeletonSetup.presets within the video_marker product workflow. +function values = presets() +%PRESETS Provide named editable skeleton starting points. +values = [ ... + preset("legacy_leg_5", "Legacy leg (5 points)", ... + ["iliac"; "hip"; "knee"; "ankle"; "foot"], ... + [1 2; 2 3; 3 4; 4 5]), ... + preset("three_point_chain", "Three-point chain", ... + ["point1"; "point2"; "point3"], [1 2; 2 3]), ... + preset("five_point_chain", "Five-point chain", ... + ["point1"; "point2"; "point3"; "point4"; "point5"], ... + [1 2; 2 3; 3 4; 4 5])]; +end + +function value = preset(id, label, pointNames, edges) +value = struct("id", id, "label", label, ... + "pointNames", pointNames, "edges", edges); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeConnection.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeConnection.m new file mode 100644 index 000000000..acabf7f7f --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeConnection.m @@ -0,0 +1,16 @@ +% App-owned implementation for video_marker.skeletonSetup.removeConnection within the video_marker product workflow. +function state = removeConnection(state, context) +%REMOVECONNECTION Remove the selected editable skeleton edge. +index = state.session.selection.selectedEdgeIndex; +count = size(state.project.annotations.skeleton.edges, 1); +if state.session.cache.videoInfo.frameCount > 0 || index < 1 || index > count + return +end +state.project.annotations.skeleton = ... + video_marker.skeletonDefinition.removeEdge( ... + state.project.annotations.skeleton, index); +state.session.selection.selectedEdgeIndex = min(index, ... + size(state.project.annotations.skeleton.edges, 1)); +state = video_marker.resultFiles.clearExportState(state); +context.appendStatus("Removed connection " + string(index) + "."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeKeypoint.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeKeypoint.m new file mode 100644 index 000000000..7fed975da --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeKeypoint.m @@ -0,0 +1,19 @@ +% App-owned implementation for video_marker.skeletonSetup.removeKeypoint within the video_marker product workflow. +function state = removeKeypoint(state, context) +%REMOVEKEYPOINT Remove the selected editable skeleton point. +index = state.session.selection.selectedPointIndex; +names = state.project.annotations.skeleton.pointNames; +if state.session.cache.videoInfo.frameCount > 0 || ... + index < 1 || index > numel(names) + return +end +state.project.annotations.skeleton = ... + video_marker.skeletonDefinition.removePoint( ... + state.project.annotations.skeleton, index); +state.session.selection.selectedPointIndex = min( ... + index, numel(state.project.annotations.skeleton.pointNames)); +state.session.selection.selectedEdgeIndex = 0; +state = video_marker.skeletonSetup.normalizeSelection(state); +state = video_marker.resultFiles.clearExportState(state); +context.appendStatus("Removed keypoint " + string(index) + "."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/renameKeypoint.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/renameKeypoint.m new file mode 100644 index 000000000..dff76a61a --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/renameKeypoint.m @@ -0,0 +1,15 @@ +% App-owned implementation for video_marker.skeletonSetup.renameKeypoint within the video_marker product workflow. +function state=renameKeypoint(state,edit,context) +if state.session.cache.videoInfo.frameCount>0,return,end +if edit.ColumnIndex~=2,return,end +try + state.project.annotations.skeleton=video_marker.skeletonDefinition.renamePoint( ... + state.project.annotations.skeleton,edit.RowIndex,edit.NewValue); + state=video_marker.skeletonSetup.normalizeSelection(state); + state=video_marker.resultFiles.clearExportState(state); + context.appendStatus("Renamed keypoint " + string(edit.RowIndex) + "."); +catch ME + context.reportError("Invalid keypoint name", ME); + context.alert(ME.message, "Invalid keypoint name"); +end +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/selectConnection.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/selectConnection.m new file mode 100644 index 000000000..1c78ee1da --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/selectConnection.m @@ -0,0 +1,8 @@ +% App-owned implementation for video_marker.skeletonSetup.selectConnection within the video_marker product workflow. +function state = selectConnection(state, selection, ~) +%SELECTCONNECTION Store the selected connection row. +state.session.selection.selectedEdgeIndex = 0; +if ~isempty(selection.CellIndices) + state.session.selection.selectedEdgeIndex = selection.CellIndices(1, 1); +end +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/selectConnectionFrom.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/selectConnectionFrom.m new file mode 100644 index 000000000..7cc8e4441 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/selectConnectionFrom.m @@ -0,0 +1,4 @@ +% App-owned implementation for video_marker.skeletonSetup.selectConnectionFrom within the video_marker product workflow. +function state=selectConnectionFrom(state,value,~) +state.session.selection.connectionFrom=string(value); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/selectConnectionTo.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/selectConnectionTo.m new file mode 100644 index 000000000..9ef1f8db9 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/selectConnectionTo.m @@ -0,0 +1,4 @@ +% App-owned implementation for video_marker.skeletonSetup.selectConnectionTo within the video_marker product workflow. +function state=selectConnectionTo(state,value,~) +state.session.selection.connectionTo=string(value); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/selectKeypoint.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/selectKeypoint.m new file mode 100644 index 000000000..e9c479d73 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/selectKeypoint.m @@ -0,0 +1,4 @@ +% App-owned implementation for video_marker.skeletonSetup.selectKeypoint within the video_marker product workflow. +function state=selectKeypoint(state,selection,~) +if isempty(selection.CellIndices),state.session.selection.selectedPointIndex=0;else,state.session.selection.selectedPointIndex=selection.CellIndices(1,1);end +end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/usePreset.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/usePreset.m new file mode 100644 index 000000000..f4539fd4c --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/usePreset.m @@ -0,0 +1,20 @@ +% App-owned implementation for video_marker.skeletonSetup.usePreset within the video_marker product workflow. +function state = usePreset(state, context) +%USEPRESET Replace the editable skeleton with the selected preset. +if state.session.cache.videoInfo.frameCount > 0 + return +end +presets = video_marker.skeletonSetup.presets(); +match = find([presets.label] == string( ... + state.session.selection.skeletonPreset), 1); +if isempty(match) + return +end +state.project.annotations.skeleton = ... + video_marker.skeletonDefinition.fromParts( ... + presets(match).pointNames, presets(match).edges); +state = video_marker.skeletonSetup.normalizeSelection(state, true); +state = video_marker.resultFiles.clearExportState(state); +context.appendStatus("Applied skeleton preset: " + ... + string(presets(match).label) + "."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m b/apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 3d702f981..000000000 --- a/apps/image_measurement/video_marker/+video_marker/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,244 +0,0 @@ -% Expected caller: video_marker.definition. Inputs are app-owned callbacks. -% Output is a data-only UI 5 workbench layout for Video Marker. -function layout = buildWorkbenchLayout(callbacks, ~) - layout = labkit.ui.layout.workbench("videoMarkerApp", "Video Marker", ... - "controlTabs", controlTabs(callbacks), ... - "workspace", videoWorkspace(), ... - "usageTitle", "Workflow Notes", ... - "usage", workflowNotesLines()); -end - -function tabs = controlTabs(callbacks) - tabs = {setupTab(callbacks), videoTab(callbacks), exportTab(callbacks), logTab()}; -end - -function tab = setupTab(callbacks) - tab = labkit.ui.layout.tab("setup", "Setup + Scale", { ... - skeletonSection(callbacks), ... - scaleSection(callbacks)}); -end - -function section = scaleSection(callbacks) - section = labkit.ui.layout.section("scaleSection", "Scale", { ... - labkit.ui.layout.action("measureScaleReference", ... - "Measure reference pixels", callbacks.measureScaleReference, "enabled", false), ... - panner("scaleReferencePixels", "Reference pixels:", 0, ... - [0 5000], 1, false, "scaleCalibrationChanged", callbacks), ... - panner("scaleReferenceLength", "Reference length:", 100, ... - [0 1e6], 10, false, "scaleCalibrationChanged", callbacks), ... - labkit.ui.layout.field("scaleCalibrationUnit", "Scale unit:", ... - "kind", "dropdown", "items", {'m', 'cm', 'mm', 'um', 'nm'}, ... - "value", "um", "enabled", false, ... - "onChange", callbacks.scaleCalibrationChanged), ... - boundPanner("scaleBarLength", "Scale bar length:", 100, ... - [0 1e6], 10, false, "project.parameters.scaleBarLength", ... - "scaleBarSettingChanged"), ... - boundDropdown("scaleBarPosition", "Scale position:", ... - {'Bottom center', 'Bottom left', 'Bottom right', ... - 'Top center', 'Top left', 'Top right'}, "Bottom right", ... - "project.parameters.scaleBarPosition", "scaleBarSettingChanged", false), ... - boundDropdown("scaleBarColor", "Scale color:", ... - {'Black', 'White'}, "Black", ... - "project.parameters.scaleBarColor", "scaleBarSettingChanged", false), ... - labkit.ui.layout.action("placeScaleBar", "Place scale bar", ... - callbacks.placeScaleBar, "enabled", false), ... - labkit.ui.layout.field("scaleReferenceReadout", "Reference px:", ... - "kind", "readonly", "value", "-"), ... - labkit.ui.layout.field("pixelsPerUnitReadout", "Pixels/unit:", ... - "kind", "readonly", "value", "-")}); -end - -function section = skeletonSection(callbacks) - presets = video_marker.userInterface.skeletonPresets(); - presetLabels = cellstr([presets.label]); - section = labkit.ui.layout.section("skeletonSection", "Skeleton definition", { ... - labkit.ui.layout.group("skeletonPresetActions", "Start from preset", { ... - labkit.ui.layout.field("skeletonPreset", "Preset", ... - "kind", "dropdown", "items", presetLabels, ... - "value", presetLabels{1}, ... - "Bind", "session.selection.skeletonPreset"), ... - labkit.ui.layout.action("useSkeletonPreset", "Use preset", ... - callbacks.useSkeletonPreset)}), ... - labkit.ui.layout.resultTable("keypointTable", "Ordered keypoints", ... - "columns", {'Order', 'Keypoint name'}, ... - "data", cell(0, 2), ... - "columnEditable", [false true], ... - "onCellEdit", callbacks.keypointEdited, ... - "onSelectionChange", callbacks.keypointSelected), ... - labkit.ui.layout.group("keypointActions", "", { ... - labkit.ui.layout.action("addKeypoint", "Add keypoint", ... - callbacks.addKeypoint), ... - labkit.ui.layout.action("removeKeypoint", "Remove keypoint", ... - callbacks.removeKeypoint, "enabled", false), ... - labkit.ui.layout.action("moveKeypointUp", "Move up", ... - callbacks.moveKeypointUp, "enabled", false), ... - labkit.ui.layout.action("moveKeypointDown", "Move down", ... - callbacks.moveKeypointDown, "enabled", false)}), ... - labkit.ui.layout.group("connectionEndpoints", "Add connection", { ... - labkit.ui.layout.field("connectionFrom", "From", ... - "kind", "dropdown", "items", {'Add keypoints first'}, ... - "value", "Add keypoints first", "enabled", false, ... - "onChange", callbacks.connectionEndpointChanged), ... - labkit.ui.layout.field("connectionTo", "To", ... - "kind", "dropdown", "items", {'Add keypoints first'}, ... - "value", "Add keypoints first", "enabled", false, ... - "onChange", callbacks.connectionEndpointChanged)}), ... - labkit.ui.layout.group("connectionActions", "", { ... - labkit.ui.layout.action("addConnection", "Add connection", ... - callbacks.addConnection, "enabled", false), ... - labkit.ui.layout.action("connectInOrder", "Connect in order", ... - callbacks.connectInOrder, "enabled", false)}), ... - labkit.ui.layout.resultTable("connectionTable", "Connections", ... - "columns", {'From', 'To'}, ... - "data", cell(0, 2), ... - "onSelectionChange", callbacks.connectionSelected), ... - labkit.ui.layout.action("removeConnection", "Remove connection", ... - callbacks.removeConnection, "enabled", false), ... - labkit.ui.layout.field("skeletonStatus", "Setup status", ... - "kind", "readonly", ... - "value", "Define keypoints to start new, or load a saved project from the State menu.")}); -end - -function tab = videoTab(callbacks) - tab = labkit.ui.layout.tab("video", "Video", { ... - projectSection(callbacks), ... - videoSection(callbacks), ... - frameSection(callbacks), ... - markingSection(callbacks)}); -end - -function section = videoSection(callbacks) - filters = {'*.mp4;*.avi;*.mov;*.m4v;*.mj2', 'Video files'; '*.*', 'All files'}; - section = labkit.ui.layout.section("videoSection", "Video", { ... - labkit.ui.layout.filePanel("videoFile", "Video file", ... - "mode", "single", ... - "filters", filters, ... - "chooseLabel", "Open video", ... - "status", "No video loaded", ... - "emptyText", "No video loaded", ... - "onChoose", callbacks.openVideo), ... - labkit.ui.layout.field("videoSummary", "Video summary", ... - "kind", "readonly", ... - "value", "No video loaded")}); -end - -function section = frameSection(callbacks) - section = labkit.ui.layout.section("frameSection", "Frame", { ... - labkit.ui.layout.panner("currentFrame", "Current frame", ... - "value", 1, ... - "limits", [1 2], ... - "step", 1, ... - "Bind", "session.selection.currentFrame", ... - "Event", "frameChanged"), ... - labkit.ui.layout.group("frameActions", "", { ... - labkit.ui.layout.action("previousFrame", "Previous frame", ... - callbacks.previousFrame, "enabled", false), ... - labkit.ui.layout.action("nextFrame", "Next frame", ... - callbacks.nextFrame, "enabled", false)})}); -end - -function section = markingSection(callbacks) - section = labkit.ui.layout.section("markingSection", "Marking", { ... - labkit.ui.layout.group("pointActions", "", { ... - labkit.ui.layout.action("undoPoint", "Undo last point", ... - callbacks.undoPoint, "enabled", false), ... - labkit.ui.layout.action("clearFramePoints", "Clear frame points", ... - callbacks.clearFramePoints, "enabled", false)}), ... - labkit.ui.layout.field("frameStatus", "Frame status", ... - "kind", "readonly", ... - "value", "Frame: empty")}); -end - -function tab = exportTab(callbacks) - tab = labkit.ui.layout.tab("export", "Import + Export", { ... - labkit.ui.layout.section("markerCsvSection", "Marker CSV", { ... - labkit.ui.layout.group("markerCsvActions", "", { ... - labkit.ui.layout.action("importMarkerCsv", "Import marker CSV", ... - callbacks.importMarkerCsv), ... - labkit.ui.layout.action("exportMarkerCsv", "Export marker CSV", ... - callbacks.exportMarkerCsv, "enabled", false)})}), ... - labkit.ui.layout.section("coordinateCsvSection", "Coordinate CSV", { ... - labkit.ui.layout.field("coordinateUnitMode", "Unit", ... - "kind", "dropdown", "items", {'pixels', 'calibrated_physical'}, ... - "value", "pixels", ... - "Bind", "project.parameters.coordinateUnitMode", ... - "Event", "exportSettingChanged"), ... - labkit.ui.layout.field("coordinateOriginMode", "Origin", ... - "kind", "dropdown", "items", {'top_left_pixel_center', 'first_point'}, ... - "value", "top_left_pixel_center", ... - "Bind", "project.parameters.coordinateOriginMode", ... - "Event", "exportSettingChanged"), ... - labkit.ui.layout.field("coordinateYAxisMode", "Y axis", ... - "kind", "dropdown", "items", {'up', 'down'}, ... - "value", "up", ... - "Bind", "project.parameters.coordinateYAxisMode", ... - "Event", "exportSettingChanged"), ... - labkit.ui.layout.group("coordinateRange", "", { ... - labkit.ui.layout.field("coordinateStartFrame", "Start frame", ... - "kind", "number", "value", 1, ... - "Bind", "project.parameters.coordinateStartFrame", ... - "Event", "exportSettingChanged"), ... - labkit.ui.layout.field("coordinateEndFrame", "End frame", ... - "kind", "number", "value", 1, ... - "Bind", "project.parameters.coordinateEndFrame", ... - "Event", "exportSettingChanged")}), ... - labkit.ui.layout.action("exportCoordinateCsv", "Export coordinate CSV", ... - callbacks.exportCoordinateCsv, "enabled", false)}), ... - labkit.ui.layout.section("summarySection", "Summary", { ... - labkit.ui.layout.resultTable("summaryTable", "Annotation Summary", ... - "columns", {'Metric', 'Value'}, ... - "data", {'Video', 'none'; 'Frames', '0'; 'Confirmed', '0'})})}); -end - -function section = projectSection(callbacks) - choices = video_marker.userInterface.sessionChoices(); - section = labkit.ui.layout.section("projectSection", "Session", { ... - labkit.ui.layout.group("projectActions", "", { ... - labkit.ui.layout.action("openProject", choices.openProject, ... - callbacks.runtimeLoadState), ... - labkit.ui.layout.action("saveAutosave", choices.saveAutosave, ... - callbacks.saveAutosave, "enabled", false), ... - labkit.ui.layout.action("newSetup", choices.newSetup, ... - callbacks.newSetup)})}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log", ... - "value", {'Ready.'})})}); -end - -function workspace = videoWorkspace() - workspace = labkit.ui.layout.workspace("videoPreview", "Video Preview", { ... - labkit.ui.layout.previewArea("videoAxes", "Video Preview", ... - "layout", "single", ... - "axisIds", {'video'}, ... - "axisTitles", {'Frame + Skeleton'})}); -end - -function lines = workflowNotesLines() - lines = { ... - '1. Use an editable preset or add and name ordered keypoints, then add connections.', ... - '2. Open a video, click points in table order, and drag existing points to refine.', ... - '3. Moving forward predicts points automatically; dragging any point creates a new manual anchor.', ... - '4. Export marker CSV for round-trip editing or coordinate CSV for plotting.'}; -end - -function control = panner(id, label, value, limits, step, enabled, eventId, callbacks) - control = labkit.ui.layout.panner(id, label, ... - "value", value, "limits", limits, "step", step, ... - "enabled", enabled, "onChange", callbacks.(char(eventId))); -end - -function control = boundPanner(id, label, value, limits, step, enabled, bind, eventId) - control = labkit.ui.layout.panner(id, label, ... - "value", value, "limits", limits, "step", step, ... - "enabled", enabled, "Bind", bind, "Event", eventId); -end - -function control = boundDropdown(id, label, items, value, bind, eventId, enabled) - control = labkit.ui.layout.field(id, label, ... - "kind", "dropdown", "items", items, "value", value, ... - "enabled", enabled, "Bind", bind, "Event", eventId); -end diff --git a/apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m b/apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m deleted file mode 100644 index 2831a5a72..000000000 --- a/apps/image_measurement/video_marker/+video_marker/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,298 +0,0 @@ -% Expected caller: the LabKit V2 runtime. Input is canonical Video Marker -% state. Output is one deterministic control, preview, and controlled-editor -% presentation with no UI registry, graphics handles, or app-owned runtime. -function view = presentWorkbench(state) - project = state.project; - session = state.session; - skeleton = project.annotations.skeleton; - frames = project.annotations.frames; - calibration = project.annotations.calibration; - info = session.cache.videoInfo; - frameIndex = currentFrame(state); - hasVideo = info.frameCount > 0 && ~isempty(session.cache.currentImage); - locked = hasVideo; - points = currentPoints(state); - pointCount = numel(skeleton.pointIds); - - view = struct(); - view.controls.skeletonPreset = valueSpec(session.selection.skeletonPreset); - view.controls.useSkeletonPreset = enabledSpec(~locked); - view.controls.keypointTable = tableSpec( ... - keypointTable(skeleton), ~locked); - view.controls.connectionTable = tableSpec( ... - connectionTable(skeleton), ~locked); - view.controls.addKeypoint = enabledSpec(~locked); - selectedPoint = double(session.selection.selectedPointIndex); - view.controls.removeKeypoint = enabledSpec(~locked && selectedPoint >= 1); - view.controls.moveKeypointUp = enabledSpec( ... - ~locked && selectedPoint > 1); - view.controls.moveKeypointDown = enabledSpec( ... - ~locked && selectedPoint >= 1 && selectedPoint < pointCount); - view = connectionPresentation(view, state, locked); - view.controls.skeletonStatus = valueSpec(skeletonStatus(locked, pointCount)); - - videoPath = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "video"); - view.controls.videoFile = fileSpec(videoPath, hasVideo); - view.controls.saveAutosave = enabledSpec(hasVideo); - view.controls.videoSummary = valueSpec(videoSummary(info)); - view.controls.currentFrame = struct( ... - "Value", frameIndex, "Limits", [1 max(2, info.frameCount)], ... - "Enabled", hasVideo); - view.controls.previousFrame = enabledSpec(hasVideo && frameIndex > 1); - view.controls.nextFrame = enabledSpec( ... - hasVideo && frameIndex < info.frameCount); - view.controls.undoPoint = enabledSpec(hasVideo && ~isempty(points)); - view.controls.clearFramePoints = enabledSpec(hasVideo && ~isempty(points)); - view.controls.frameStatus = valueSpec(frameStatus( ... - frames, frameIndex, size(points, 1), pointCount, hasVideo)); - - view.controls.importMarkerCsv = enabledSpec(true); - view.controls.exportMarkerCsv = enabledSpec(hasVideo); - view.controls.exportCoordinateCsv = enabledSpec(hasVideo); - view.controls.coordinateStartFrame = rangeSpec( ... - project.parameters.coordinateStartFrame, info.frameCount, hasVideo); - view.controls.coordinateEndFrame = rangeSpec( ... - project.parameters.coordinateEndFrame, info.frameCount, hasVideo); - view.controls.summaryTable = tableSpec(summaryTable(state), true); - view = scalePresentation(view, state, hasVideo); - - model = struct( ... - "imageData", session.cache.currentImage, ... - "title", frameTitle(frameIndex, hasVideo), ... - "skeleton", skeleton, ... - "points", points, ... - "scaleBar", session.view.scaleBar); - view.previews.videoAxes.Axes.video = struct( ... - "Renderer", "videoFrame", "Model", model); - if hasVideo - if session.workflow.scaleReferenceEditing - view.interactions.scaleReference = struct( ... - "Kind", "scaleBarReference", ... - "Targets", "videoAxes", ... - "Value", calibration.referenceLine, ... - "Event", "scaleReferenceEdited", ... - "ImageSize", size(session.cache.currentImage), ... - "ChangePolicy", "commit", ... - "Options", struct("color", [1 1 0])); - else - view.interactions.framePoints = struct( ... - "Kind", "anchors", ... - "Targets", "videoAxes", ... - "Value", points, ... - "Event", "pointsEdited", ... - "ImageSize", size(session.cache.currentImage), ... - "ChangePolicy", "commit", ... - "Options", struct("mode", "points", ... - "maxPoints", pointCount, "color", [0 0.85 1])); - end - end -end - -function view = connectionPresentation(view, state, locked) - names = string(state.project.annotations.skeleton.pointNames(:)); - enough = numel(names) >= 2; - from = validChoice(state.session.selection.connectionFrom, names, 1); - toCandidates = names(names ~= from); - to = validChoice(state.session.selection.connectionTo, toCandidates, 1); - if isempty(names) - names = "Add keypoints first"; - from = names; - toCandidates = names; - to = names; - elseif isempty(toCandidates) - toCandidates = "Add another keypoint"; - to = toCandidates; - end - view.controls.connectionFrom = choiceSpec( ... - names, from, ~locked && enough); - view.controls.connectionTo = choiceSpec( ... - toCandidates, to, ~locked && enough); - view.controls.addConnection = enabledSpec(~locked && enough); - view.controls.connectInOrder = enabledSpec(~locked && enough); - selectedEdge = double(state.session.selection.selectedEdgeIndex); - view.controls.removeConnection = enabledSpec( ... - ~locked && selectedEdge >= 1); -end - -function view = scalePresentation(view, state, hasVideo) - calibration = state.project.annotations.calibration; - editing = state.session.workflow.scaleReferenceEditing; - referencePixels = calibration.referencePixels; - referenceReadout = "-"; - if isfinite(referencePixels) - referenceReadout = sprintf('%.6g', referencePixels); - else - referencePixels = 0; - end - pixelsReadout = "-"; - if calibration.pixelsPerUnit > 0 - pixelsReadout = sprintf('%.6g px/%s', ... - calibration.pixelsPerUnit, calibration.unit); - end - view.controls.measureScaleReference = struct( ... - "Enabled", hasVideo, ... - "Text", ternary(editing, ... - "Finish reference edit", "Measure reference pixels")); - view.controls.scaleReferencePixels = controlSpec( ... - hasVideo && ~editing, referencePixels); - view.controls.scaleReferenceLength = controlSpec( ... - hasVideo, calibration.referenceLength); - view.controls.scaleCalibrationUnit = controlSpec( ... - hasVideo, calibration.unit); - view.controls.scaleBarLength = enabledSpec(hasVideo); - view.controls.scaleBarPosition = enabledSpec(hasVideo); - view.controls.scaleBarColor = enabledSpec(hasVideo); - view.controls.placeScaleBar = enabledSpec( ... - hasVideo && calibration.isCalibrated && ~editing); - view.controls.scaleReferenceReadout = valueSpec(referenceReadout); - view.controls.pixelsPerUnitReadout = valueSpec(pixelsReadout); -end - -function points = currentPoints(state) - points = zeros(0, 2); - frames = state.project.annotations.frames; - index = currentFrame(state); - if ~isempty(frames.coords) && index >= 1 && ... - index <= size(frames.coords, 1) - points = video_marker.frameAnnotations.framePoints(frames, index); - end -end - -function index = currentFrame(state) - index = max(1, round(double(state.session.selection.currentFrame))); - if state.session.cache.videoInfo.frameCount > 0 - index = min(index, state.session.cache.videoInfo.frameCount); - end -end - -function data = keypointTable(skeleton) - names = cellstr(string(skeleton.pointNames(:))); - data = cell(numel(names), 2); - for k = 1:numel(names) - data{k, 1} = k; - data{k, 2} = names{k}; - end -end - -function data = connectionTable(skeleton) - edges = skeleton.edges; - names = string(skeleton.pointNames(:)); - data = cell(size(edges, 1), 2); - for k = 1:size(edges, 1) - data{k, 1} = char(names(edges(k, 1))); - data{k, 2} = char(names(edges(k, 2))); - end -end - -function data = summaryTable(state) - summary = video_marker.frameAnnotations.summary( ... - state.project.annotations.frames); - data = { ... - 'Video', char(labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources, "video")); ... - 'Frames', sprintf('%d', summary.frameCount); ... - 'Empty', sprintf('%d', summary.empty); ... - 'Draft', sprintf('%d', summary.draft); ... - 'Confirmed', sprintf('%d', summary.confirmed); ... - 'Keypoints', sprintf('%d', numel( ... - state.project.annotations.skeleton.pointIds))}; -end - -function value = videoSummary(info) - if info.frameCount <= 0 - value = "No video loaded"; - else - value = sprintf('%d frames | %.4g fps | %dx%d px', ... - info.frameCount, info.frameRate, info.width, info.height); - end -end - -function value = frameStatus(frames, index, currentCount, totalCount, hasVideo) - if ~hasVideo - value = "Frame: empty"; - return; - end - statusName = video_marker.frameAnnotations.statusName( ... - frames.frameStatus(index)); - sourceName = video_marker.frameAnnotations.sourceName( ... - frames.frameSource(index)); - value = sprintf('Frame %d: %s (%s) | points %d / %d', ... - index, statusName, sourceName, currentCount, totalCount); -end - -function value = skeletonStatus(locked, count) - if locked - value = sprintf('Skeleton locked for this video (%d keypoints).', count); - elseif count == 0 - value = "Define keypoints before opening a video."; - else - value = sprintf('%d keypoints ready; add connections or open a video.', count); - end -end - -function value = frameTitle(index, hasVideo) - value = "Frame + Skeleton"; - if hasVideo - value = "Frame " + string(index); - end -end - -function spec = fileSpec(pathValue, loaded) - spec = struct("Files", string(pathValue), ... - "Status", ternary(loaded, "Video loaded", "No video loaded")); -end - -function spec = tableSpec(data, enabled) - spec = struct(); - spec.Data = data; - spec.Enabled = logical(enabled); -end - -function spec = rangeSpec(value, frameCount, enabled) - upper = max(1, frameCount); - value = min(max(1, round(double(value))), upper); - spec = struct("Value", value, "Limits", [1 max(2, upper)], ... - "Enabled", logical(enabled)); -end - -function spec = choiceSpec(items, value, enabled) - spec = struct(); - spec.Items = cellstr(string(items(:))); - spec.Value = char(string(value)); - spec.Enabled = logical(enabled); -end - -function value = validChoice(candidate, choices, fallbackIndex) - choices = string(choices(:)); - if isempty(choices) - value = ""; - return; - end - value = string(candidate); - if ~isscalar(value) || ~any(choices == value) - fallbackIndex = min(max(1, fallbackIndex), numel(choices)); - value = choices(fallbackIndex); - end -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = enabledSpec(enabled) - spec = struct("Enabled", logical(enabled)); -end - -function spec = controlSpec(enabled, value) - spec = struct("Enabled", logical(enabled), "Value", value); -end - -function value = ternary(condition, trueValue, falseValue) - if condition - value = trueValue; - else - value = falseValue; - end -end diff --git a/apps/image_measurement/video_marker/+video_marker/+userInterface/renderVideoFrame.m b/apps/image_measurement/video_marker/+video_marker/+userInterface/renderVideoFrame.m deleted file mode 100644 index 836834ec6..000000000 --- a/apps/image_measurement/video_marker/+video_marker/+userInterface/renderVideoFrame.m +++ /dev/null @@ -1,92 +0,0 @@ -% Expected caller: the registered Video Marker V2 renderer. Inputs are one -% semantic preview axes and a prepared frame/skeleton/scale model. Side effects -% are limited to non-pickable graphics on the supplied axes. -function renderVideoFrame(ax, model) - view = captureImageView(ax, model.imageData); - labkit.ui.plot.clear(ax, "ResetScale", true); - if isempty(model.imageData) - title(ax, char(model.title)); - box(ax, 'on'); - return; - end - if ndims(model.imageData) == 2 - imagesc(ax, model.imageData); - colormap(ax, gray(256)); - else - image(ax, model.imageData); - end - axis(ax, 'image'); - ax.YDir = 'reverse'; - hold(ax, 'on'); - drawSkeleton(ax, model.skeleton, model.points); - drawScaleBar(ax, model.scaleBar); - hold(ax, 'off'); - title(ax, char(model.title)); - xlabel(ax, ''); - ylabel(ax, ''); - box(ax, 'on'); - restoreImageView(ax, view); -end - -function view = captureImageView(ax, imageData) - view = struct("preserve", false, "xLimits", [], "yLimits", []); - images = findobj(ax, 'Type', 'image'); - if isempty(images) || isempty(imageData) - return; - end - previousSize = size(images(1).CData); - nextSize = size(imageData); - if numel(previousSize) < 2 || numel(nextSize) < 2 || ... - ~isequal(previousSize(1:2), nextSize(1:2)) - return; - end - xLimits = double(ax.XLim); - yLimits = double(ax.YLim); - if numel(xLimits) ~= 2 || numel(yLimits) ~= 2 || ... - any(~isfinite([xLimits yLimits])) || ... - diff(xLimits) <= 0 || diff(yLimits) <= 0 - return; - end - view = struct("preserve", true, ... - "xLimits", xLimits, "yLimits", yLimits); -end - -function restoreImageView(ax, view) - if ~view.preserve - return; - end - ax.XLim = view.xLimits; - ax.YLim = view.yLimits; -end - -function drawSkeleton(ax, skeleton, points) - for k = 1:size(skeleton.edges, 1) - edge = skeleton.edges(k, :); - if all(edge <= size(points, 1)) - plot(ax, points(edge, 1), points(edge, 2), '-', ... - 'Color', [0.1 0.65 1], 'LineWidth', 1.5, ... - 'HitTest', 'off', 'PickableParts', 'none'); - end - end - for k = 1:size(points, 1) - text(ax, points(k, 1) + 4, points(k, 2) + 4, ... - string(skeleton.pointNames(k)), ... - 'Color', [1 1 1], 'FontWeight', 'bold', ... - 'Interpreter', 'none', 'HitTest', 'off', ... - 'PickableParts', 'none'); - end -end - -function drawScaleBar(ax, scaleBar) - if isempty(scaleBar) - return; - end - plot(ax, scaleBar.line(:, 1), scaleBar.line(:, 2), '-', ... - 'Color', scaleBar.color, 'LineWidth', 3, ... - 'HitTest', 'off', 'PickableParts', 'none'); - text(ax, scaleBar.labelPosition(1), scaleBar.labelPosition(2), ... - scaleBar.label, 'Color', scaleBar.color, 'FontWeight', 'bold', ... - 'HorizontalAlignment', 'center', ... - 'VerticalAlignment', char(scaleBar.verticalAlignment), ... - 'HitTest', 'off', 'PickableParts', 'none'); -end diff --git a/apps/image_measurement/video_marker/+video_marker/+userInterface/sessionChoices.m b/apps/image_measurement/video_marker/+video_marker/+userInterface/sessionChoices.m deleted file mode 100644 index 7e1f976a5..000000000 --- a/apps/image_measurement/video_marker/+video_marker/+userInterface/sessionChoices.m +++ /dev/null @@ -1,11 +0,0 @@ -%SESSIONCHOICES User-visible Video Marker session action labels. -% Expected callers: the layout, new-setup action, and GUI tests. -function choices = sessionChoices() - choices = struct( ... - "openProject", "Open MAT", ... - "saveAutosave", "Save autosave", ... - "newSetup", "New setup", ... - "cancel", "Cancel", ... - "saveAndStart", "Save and start new", ... - "discardAndStart", "Discard and start new"); -end diff --git a/apps/image_measurement/video_marker/+video_marker/+userInterface/skeletonPresets.m b/apps/image_measurement/video_marker/+video_marker/+userInterface/skeletonPresets.m deleted file mode 100644 index 9a74db6ad..000000000 --- a/apps/image_measurement/video_marker/+video_marker/+userInterface/skeletonPresets.m +++ /dev/null @@ -1,20 +0,0 @@ -%SKELETONPRESETS Built-in editable skeleton starting points for Video Marker. -% Expected callers are the setup layout, setup actions, and tests. Returns a -% struct array with stable id, user-visible label, ordered names, and M-by-2 -% connection indices. Applying a preset replaces the current setup only. -function presets = skeletonPresets() - presets = [ ... - preset("legacy_leg_5", "Legacy leg (5 points)", ... - ["iliac"; "hip"; "knee"; "ankle"; "foot"], ... - [1 2; 2 3; 3 4; 4 5]), ... - preset("three_point_chain", "Three-point chain", ... - ["point1"; "point2"; "point3"], [1 2; 2 3]), ... - preset("five_point_chain", "Five-point chain", ... - ["point1"; "point2"; "point3"; "point4"; "point5"], ... - [1 2; 2 3; 3 4; 4 5])]; -end - -function value = preset(id, label, pointNames, edges) - value = struct('id', id, 'label', label, ... - 'pointNames', pointNames, 'edges', edges); -end diff --git a/apps/image_measurement/video_marker/+video_marker/+videoPreview/draw.m b/apps/image_measurement/video_marker/+video_marker/+videoPreview/draw.m new file mode 100644 index 000000000..0f86c784d --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+videoPreview/draw.m @@ -0,0 +1,73 @@ +% App-owned implementation for video_marker.videoPreview.draw within the video_marker product workflow. +function draw(axesById, model) +%DRAW Render one frame with display-only skeleton and scale overlays. +ax = axesById.video; +view = captureView(ax, model.image); +labkit.app.plot.clearAxes(ax); +if isempty(model.image) + title(ax, model.title); + box(ax, "on"); + return +end +if ndims(model.image) == 2 + imagesc(ax, model.image); + colormap(ax, gray(256)); +else + image(ax, model.image); +end +axis(ax, "image"); +ax.YDir = "reverse"; +hold(ax, "on"); +for k = 1:size(model.skeleton.edges, 1) + edge = model.skeleton.edges(k, :); + if all(edge <= size(model.points, 1)) + plot(ax, model.points(edge, 1), model.points(edge, 2), "-", ... + Color=[0.1 0.65 1], LineWidth=1.5, ... + HitTest="off", PickableParts="none"); + end +end +for k = 1:size(model.points, 1) + text(ax, model.points(k, 1) + 4, model.points(k, 2) + 4, ... + string(model.skeleton.pointNames(k)), Color=[1 1 1], ... + FontWeight="bold", Interpreter="none", ... + HitTest="off", PickableParts="none"); +end +if ~isempty(model.scaleBar) + plot(ax, model.scaleBar.line(:, 1), model.scaleBar.line(:, 2), "-", ... + Color=model.scaleBar.color, LineWidth=3, ... + HitTest="off", PickableParts="none"); + text(ax, model.scaleBar.labelPosition(1), ... + model.scaleBar.labelPosition(2), model.scaleBar.label, ... + Color=model.scaleBar.color, FontWeight="bold", ... + HorizontalAlignment="center", ... + VerticalAlignment=char(model.scaleBar.verticalAlignment), ... + HitTest="off", PickableParts="none"); +end +hold(ax, "off"); +title(ax, model.title); +xlabel(ax, ""); +ylabel(ax, ""); +box(ax, "on"); +restoreView(ax, view); +end + +function view = captureView(ax, imageData) +view = struct("preserve", false, "x", [], "y", []); +images = findobj(ax, Type="image"); +if isempty(images) || isempty(imageData) + return +end +oldSize = size(images(1).CData); +newSize = size(imageData); +if numel(oldSize) >= 2 && numel(newSize) >= 2 && ... + isequal(oldSize(1:2), newSize(1:2)) + view = struct("preserve", true, "x", ax.XLim, "y", ax.YLim); +end +end + +function restoreView(ax, view) +if view.preserve + ax.XLim = view.x; + ax.YLim = view.y; +end +end diff --git a/apps/image_measurement/video_marker/+video_marker/+videoSource/openResource.m b/apps/image_measurement/video_marker/+video_marker/+videoSource/openResource.m new file mode 100644 index 000000000..6a0d62d9e --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+videoSource/openResource.m @@ -0,0 +1,12 @@ +% App-owned implementation for video_marker.videoSource.openResource within the video_marker product workflow. +function resource = openResource(videoPath) +%OPENRESOURCE Open one video and create its bounded decoded-frame cache. +[reader, info] = video_marker.videoSource.openVideo(videoPath); +cache = video_marker.videoSource.createDecodedFrameCache( ... + @(index) video_marker.videoSource.readFrame(reader, index)); +firstFrame = cache.readFrame(1); +cache.reset(1, firstFrame); +resource = struct("path", string(videoPath), ... + "reader", reader, "info", info, "cache", cache, ... + "firstFrame", firstFrame); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+videoSource/selectionChanged.m b/apps/image_measurement/video_marker/+video_marker/+videoSource/selectionChanged.m new file mode 100644 index 000000000..16eaeba37 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+videoSource/selectionChanged.m @@ -0,0 +1,41 @@ +% App-owned implementation for video_marker.videoSource.selectionChanged within the video_marker product workflow. +function state = selectionChanged(state, ~, context) +%SELECTIONCHANGED Initialize durable annotations for the selected video. +state.session = video_marker.createSession(state.project, context); +info = state.session.cache.videoInfo; +pointCount = numel(state.project.annotations.skeleton.pointIds); +if info.frameCount > 0 && pointCount == 0 + context.alert("Define at least one keypoint before opening a video.", ... + "Skeleton required"); + state.project.inputs.sources = struct([]); + state.session = video_marker.createSession(state.project, context); + return +end +if info.frameCount <= 0 + context.removeResource("document", "video"); + state.project.inputs.videoMetadata = video_marker.videoSource.emptyMetadata(); + state.project.annotations.frames = ... + video_marker.frameAnnotations.emptyAnnotations(0, pointCount); + state.project.parameters.coordinateStartFrame = 1; + state.project.parameters.coordinateEndFrame = 1; + state = video_marker.resultFiles.clearExportState(state); + context.appendStatus("No video loaded."); + return +end +frames = state.project.annotations.frames; +matches = ~isempty(frames.coords) && ... + size(frames.coords, 1) == info.frameCount && ... + size(frames.coords, 2) == pointCount; +if ~matches + state.project.annotations.frames = ... + video_marker.frameAnnotations.emptyAnnotations( ... + info.frameCount, pointCount); +end +state.project.inputs.videoMetadata = ... + video_marker.videoSource.metadataFromInfo(info); +state.project.parameters.coordinateStartFrame = 1; +state.project.parameters.coordinateEndFrame = info.frameCount; +state = video_marker.resultFiles.clearExportState(state); +context.appendStatus("Opened video with " + string(info.frameCount) + ... + " frame(s)."); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+workbench/buildLayout.m b/apps/image_measurement/video_marker/+video_marker/+workbench/buildLayout.m new file mode 100644 index 000000000..78dcded47 --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+workbench/buildLayout.m @@ -0,0 +1,175 @@ +% App-owned implementation for video_marker.workbench.buildLayout within the video_marker product workflow. +function layout = buildLayout() +%BUILDLAYOUT Assemble skeleton, video marking, scale, and export capabilities. +presets = video_marker.skeletonSetup.presets(); +setup = labkit.app.layout.tab("setup", "Setup + Scale", { ... + labkit.app.layout.section("skeletonSection", "Skeleton definition", { ... + labkit.app.layout.group("skeletonPresetActions", { ... + labkit.app.layout.field("skeletonPreset", Label="Preset", ... + Kind="choice", Choices=[presets.label], ... + Bind="session.selection.skeletonPreset"), ... + labkit.app.layout.button("useSkeletonPreset", "Use preset", ... + @video_marker.skeletonSetup.usePreset, ... + Tooltip="Replace the editable keypoint order and connections with the selected anatomical skeleton preset.")}, ... + Title="Start from preset", Layout="vertical"), ... + labkit.app.layout.dataTable("keypointTable", ... + Title="Ordered keypoints", ... + Columns=["Order" "Keypoint name"], ... + ColumnEditable=[false true], ... + OnCellEdited=@video_marker.skeletonSetup.renameKeypoint, ... + OnCellSelectionChanged=@video_marker.skeletonSetup.selectKeypoint), ... + labkit.app.layout.group("keypointActions", { ... + labkit.app.layout.button("addKeypoint", "Add keypoint", ... + @video_marker.skeletonSetup.addKeypoint, ... + Tooltip="Append a named landmark to the per-frame annotation order."), ... + labkit.app.layout.button("removeKeypoint", "Remove keypoint", ... + @video_marker.skeletonSetup.removeKeypoint, ... + Tooltip="Remove the selected landmark and any skeleton connections that reference it."), ... + labkit.app.layout.button("moveKeypointUp", "Move up", ... + @video_marker.skeletonSetup.moveUp, ... + Tooltip="Move the selected landmark earlier in the click and export order."), ... + labkit.app.layout.button("moveKeypointDown", "Move down", ... + @video_marker.skeletonSetup.moveDown, ... + Tooltip="Move the selected landmark later in the click and export order.")}), ... + labkit.app.layout.group("connectionEndpoints", { ... + labkit.app.layout.field("connectionFrom", Label="From", ... + Kind="choice", OnValueChanged= ... + @video_marker.skeletonSetup.selectConnectionFrom), ... + labkit.app.layout.field("connectionTo", Label="To", ... + Kind="choice", OnValueChanged= ... + @video_marker.skeletonSetup.selectConnectionTo)}, ... + Title="Add connection", Layout="vertical"), ... + labkit.app.layout.group("connectionActions", { ... + labkit.app.layout.button("addConnection", "Add connection", ... + @video_marker.skeletonSetup.addConnection, ... + Tooltip="Add an undirected display edge between the selected keypoint endpoints."), ... + labkit.app.layout.button("connectInOrder", "Connect in order", ... + @video_marker.skeletonSetup.connectInOrder, ... + Tooltip="Connect each adjacent keypoint in annotation order to form a chain.")}, ... + Layout="horizontal"), ... + labkit.app.layout.dataTable("connectionTable", ... + Title="Connections", ... + Columns=["From" "To"], ... + OnCellSelectionChanged=@video_marker.skeletonSetup.selectConnection), ... + labkit.app.layout.button("removeConnection", "Remove connection", ... + @video_marker.skeletonSetup.removeConnection, ... + Tooltip="Remove the selected skeleton edge without deleting either endpoint keypoint."), ... + labkit.app.layout.field("skeletonStatus", ... + Label="Setup status", Kind="readonly", Enabled=false)}), ... + video_marker.scaleCalibration.layoutSection()}); + +videoFiles = labkit.app.layout.fileList("videoFile", Label="Video file", ... + MaxFiles=1, SelectionMode="single", ... + Filters=["*.mp4;*.avi;*.mov;*.m4v;*.mj2" "Video files"], ... + ChooseLabel="Open video", EmptyText="No video loaded", ... + ChooseTooltip="Open the video whose frames will receive ordered landmark and skeleton annotations.", ... + Bind="project.inputs.sources", ... + OnSelectionChanged=@video_marker.videoSource.selectionChanged, ... + SourceRole="video", SourceIdPrefix="video", Required=true); +video = labkit.app.layout.tab("video", "Video", { ... + labkit.app.layout.section("sessionSection", "Session", { ... + labkit.app.layout.group("projectActions", { ... + labkit.app.layout.button("openProject", "Open MAT", ... + @video_marker.sessionControl.openProject, ... + Tooltip="Open a saved Video Marker MAT project containing setup, source reference, and frame annotations."), ... + labkit.app.layout.button("saveAutosave", "Save autosave", ... + @video_marker.sessionControl.saveAutosave, ... + Tooltip="Write the current skeleton, calibration, and frame annotations to the autosave MAT file.")}, ... + Layout="horizontal"), ... + labkit.app.layout.button("newSetup", "New setup", ... + @video_marker.sessionControl.newSetup, ... + Tooltip="Clear the current annotation project and begin a new editable skeleton setup.")}), ... + labkit.app.layout.section("videoSection", "Video", { ... + videoFiles, ... + labkit.app.layout.field("videoSummary", ... + Label="Video summary", Kind="readonly", Enabled=false)}), ... + labkit.app.layout.section("frameSection", "Frame", { ... + labkit.app.layout.slider("currentFrame", Label="Current frame", ... + Limits=[1 2], Step=1, ... + OnValueChanged=@video_marker.frameNavigation.changeFrame), ... + labkit.app.layout.group("frameActions", { ... + labkit.app.layout.button("previousFrame", "Previous frame", ... + @video_marker.frameNavigation.previous, ... + Tooltip="Move to the preceding video frame while preserving its independent annotation state."), ... + labkit.app.layout.button("nextFrame", "Next frame", ... + @video_marker.frameNavigation.next, ... + Tooltip="Move to the next frame and seed predicted points from the current annotations when available.")}, Layout="horizontal")}), ... + labkit.app.layout.section("markingSection", "Marking", { ... + labkit.app.layout.group("pointActions", { ... + labkit.app.layout.button("undoPoint", "Undo last point", ... + @video_marker.markerEditing.undo, ... + Tooltip="Remove the most recently placed landmark from the current frame."), ... + labkit.app.layout.button("clearFramePoints", "Clear frame points", ... + @video_marker.markerEditing.clear, ... + Tooltip="Remove all landmark coordinates from the current frame without changing other frames.")}, Layout="horizontal"), ... + labkit.app.layout.field("frameStatus", ... + Label="Frame status", Kind="readonly", Enabled=false)})}); + +exports = labkit.app.layout.tab("export", "Import + Export", { ... + labkit.app.layout.section("markerCsvSection", "Marker CSV", { ... + labkit.app.layout.group("markerCsvActions", { ... + labkit.app.layout.button("importMarkerCsv", "Import marker CSV", ... + @video_marker.resultFiles.importMarkers, ... + Tooltip="Import round-trip marker annotations that match the current video and skeleton definition."), ... + labkit.app.layout.button("exportMarkerCsv", "Export marker CSV", ... + @video_marker.resultFiles.exportMarkers, ... + Tooltip="Export frame status and landmark coordinates in the editable marker CSV contract.")}, ... + Layout="horizontal")}), ... + labkit.app.layout.section("coordinateCsvSection", "Coordinate CSV", { ... + boundChoice("coordinateUnitMode", "Unit", ... + ["pixels" "calibrated_physical"], ... + "project.parameters.coordinateUnitMode"), ... + boundChoice("coordinateOriginMode", "Origin", ... + ["top_left_pixel_center" "first_point"], ... + "project.parameters.coordinateOriginMode"), ... + boundChoice("coordinateYAxisMode", "Y axis", ["up" "down"], ... + "project.parameters.coordinateYAxisMode"), ... + labkit.app.layout.group("coordinateRange", { ... + boundFrame("coordinateStartFrame", "Start frame", ... + "project.parameters.coordinateStartFrame"), ... + boundFrame("coordinateEndFrame", "End frame", ... + "project.parameters.coordinateEndFrame")}), ... + labkit.app.layout.button("exportCoordinateCsv", ... + "Export coordinate CSV", ... + @video_marker.resultFiles.exportCoordinates, ... + Tooltip="Export the selected frame range in pixel or calibrated physical coordinates with the chosen origin and Y direction.")}), ... + labkit.app.layout.section("summarySection", "Summary", { ... + labkit.app.layout.dataTable("summaryTable", ... + Title="Annotation Summary", Columns=["Metric" "Value"])})}); + +markers = labkit.app.interaction.pointSlots("framePoints", ... + @video_marker.markerEditing.changePoints, Axis="video", ... + Style=struct("color", [0 0.85 1]), ... + ViewportPolicy="preserve"); +scale = labkit.app.interaction.scaleReference("scaleReference", ... + @video_marker.scaleCalibration.changeReference, Axis="video", ... + Style=struct("color", [1 1 0]), ViewportPolicy="preserve"); +workspace = labkit.app.layout.workspace( ... + labkit.app.layout.plotArea("videoPreview", ... + @video_marker.videoPreview.draw, Title="Video Preview", ... + AxisIds="video", AxisTitles="Frame + Skeleton", ... + Interactions={markers, scale}), Title="Video Preview"); +controls = {setup, video, exports, ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("appLog", Title="Log")})})}; +usage = [ ... + "1. Use an editable preset or add and name ordered keypoints, then add connections.", ... + "2. Open a video, click points in table order, and drag existing points to refine.", ... + "3. Moving forward predicts points automatically; dragging any point creates a new manual anchor.", ... + "4. Export marker CSV for round-trip editing or coordinate CSV for plotting."]; +layout = labkit.app.layout.workbench(controls, Workspace=workspace, ... + UsageTitle="Workflow Notes", Usage=usage); +end + +function node = boundChoice(id, label, choices, bind) +node = labkit.app.layout.field(id, Label=label, Kind="choice", ... + Choices=choices, Bind=bind, ... + OnValueChanged=@video_marker.resultFiles.settingsChanged); +end + +function node = boundFrame(id, label, bind) +node = labkit.app.layout.field(id, Label=label, Kind="numeric", ... + Limits=[1 1e9], Step=1, Bind=bind, ... + OnValueChanged=@video_marker.resultFiles.settingsChanged); +end diff --git a/apps/image_measurement/video_marker/+video_marker/+workbench/present.m b/apps/image_measurement/video_marker/+video_marker/+workbench/present.m new file mode 100644 index 000000000..b1ddcc72a --- /dev/null +++ b/apps/image_measurement/video_marker/+video_marker/+workbench/present.m @@ -0,0 +1,201 @@ +% App-owned implementation for video_marker.workbench.present within the video_marker product workflow. +function view = present(state) +%PRESENT Compose the complete Video Marker semantic snapshot. +skeleton = state.project.annotations.skeleton; +info = state.session.cache.videoInfo; +frame = min(max(1, state.session.cache.frameIndex), max(1, info.frameCount)); +hasVideo = info.frameCount > 0 && ~isempty(state.session.cache.currentImage); +locked = hasVideo; +names = string(skeleton.pointNames(:)); +points = video_marker.markerEditing.currentPoints(state); +pointCount = numel(names); +selectedPoint = double(state.session.selection.selectedPointIndex); +selectedEdge = double(state.session.selection.selectedEdgeIndex); +videoPaths = string(state.session.cache.videoPath); +if strlength(videoPaths) == 0 + videoPaths = strings(1, 0); +end + +view = labkit.app.view.Snapshot() ... + .value("skeletonPreset", ... + string(state.session.selection.skeletonPreset)) ... + .enabled("useSkeletonPreset", ~locked) ... + .tableData("keypointTable", keypointTable(skeleton), ... + Columns=["Order" "Keypoint name"], ... + ColumnEditable=[false true]) ... + .enabled("keypointTable", ~locked) ... + .enabled("addKeypoint", ~locked) ... + .enabled("removeKeypoint", ~locked && selectedPoint >= 1) ... + .enabled("moveKeypointUp", ~locked && selectedPoint > 1) ... + .enabled("moveKeypointDown", ... + ~locked && selectedPoint >= 1 && selectedPoint < pointCount) ... + .tableData("connectionTable", connectionTable(skeleton), ... + Columns=["From" "To"]) ... + .enabled("connectionTable", ~locked) ... + .enabled("removeConnection", ~locked && selectedEdge >= 1) ... + .value("skeletonStatus", skeletonStatus(locked, pointCount)) ... + .enabled("saveAutosave", hasVideo) ... + .enabled("newSetup", true) ... + .filePaths("videoFile", videoPaths) ... + .value("videoSummary", videoSummary(info)) ... + .limits("currentFrame", [1 max(2, info.frameCount)]) ... + .value("currentFrame", frame) ... + .enabled("currentFrame", hasVideo) ... + .enabled("previousFrame", hasVideo && frame > 1) ... + .enabled("nextFrame", hasVideo && frame < info.frameCount) ... + .enabled("undoPoint", hasVideo && ~isempty(points)) ... + .enabled("clearFramePoints", hasVideo && ~isempty(points)) ... + .value("frameStatus", frameStatus(state, frame, points, hasVideo)) ... + .enabled("importMarkerCsv", true) ... + .enabled("exportMarkerCsv", hasVideo) ... + .enabled("exportCoordinateCsv", hasVideo) ... + .value("coordinateUnitMode", ... + string(state.project.parameters.coordinateUnitMode)) ... + .value("coordinateOriginMode", ... + string(state.project.parameters.coordinateOriginMode)) ... + .value("coordinateYAxisMode", ... + string(state.project.parameters.coordinateYAxisMode)) ... + .value("coordinateStartFrame", ... + state.project.parameters.coordinateStartFrame) ... + .value("coordinateEndFrame", ... + state.project.parameters.coordinateEndFrame) ... + .limits("coordinateStartFrame", [1 max(1, info.frameCount)]) ... + .limits("coordinateEndFrame", [1 max(1, info.frameCount)]) ... + .enabled("coordinateStartFrame", hasVideo) ... + .enabled("coordinateEndFrame", hasVideo) ... + .tableData("summaryTable", summaryTable(state), ... + Columns=["Metric" "Value"]); + +[fromChoices, fromValue, toChoices, toValue, connectionEnabled] = ... + connectionChoices(state, locked); +view = view.choices("connectionFrom", fromChoices) ... + .value("connectionFrom", fromValue) ... + .enabled("connectionFrom", connectionEnabled) ... + .choices("connectionTo", toChoices) ... + .value("connectionTo", toValue) ... + .enabled("connectionTo", connectionEnabled) ... + .enabled("addConnection", connectionEnabled) ... + .enabled("connectInOrder", connectionEnabled); + +model = struct("image", state.session.cache.currentImage, ... + "title", frameTitle(frame, hasVideo), ... + "skeleton", skeleton, "points", points, ... + "scaleBar", state.session.view.scaleBar); +imageSize = []; +if hasVideo + imageSize = size(state.session.cache.currentImage); +end +slotPoints = NaN(max(1, pointCount), 2); +if ~isempty(points) + slotPoints(1:min(size(points, 1), pointCount), :) = ... + points(1:min(size(points, 1), pointCount), :); +end +selectedSlot = min(size(points, 1) + 1, max(1, pointCount)); +slotValue = struct("points", slotPoints, ... + "selectedIndex", selectedSlot, "locked", false); +view = view.include(video_marker.scaleCalibration.present(state)) ... + .renderPlot("videoPreview", model) ... + .pointSlots("framePoints", slotValue, ImageSize=imageSize, ... + Enabled=hasVideo && pointCount > 0 && ... + ~state.session.workflow.scaleReferenceEditing); +end + +function data = keypointTable(skeleton) +names = cellstr(string(skeleton.pointNames(:))); +data = cell(numel(names), 2); +for k = 1:numel(names) + data(k, :) = {k, names{k}}; +end +end + +function data = connectionTable(skeleton) +edges = skeleton.edges; +names = string(skeleton.pointNames(:)); +data = cell(size(edges, 1), 2); +for k = 1:size(edges, 1) + data(k, :) = {char(names(edges(k, 1))), char(names(edges(k, 2)))}; +end +end + +function [fromChoices, fromValue, toChoices, toValue, enabled] = ... + connectionChoices(state, locked) +names = string(state.project.annotations.skeleton.pointNames(:)); +enabled = ~locked && numel(names) >= 2; +if isempty(names) + fromChoices = "Add keypoints first"; + toChoices = "Add keypoints first"; + fromValue = fromChoices; + toValue = toChoices; + return +end +fromChoices = names; +fromValue = validChoice(state.session.selection.connectionFrom, names); +toChoices = names(names ~= fromValue); +if isempty(toChoices) + toChoices = "Add another keypoint"; +end +toValue = validChoice(state.session.selection.connectionTo, toChoices); +end + +function value = validChoice(candidate, choices) +value = string(candidate); +if ~isscalar(value) || ~any(choices == value) + value = choices(1); +end +end + +function data = summaryTable(state) +summary = video_marker.frameAnnotations.summary( ... + state.project.annotations.frames); +pathValue = string(state.session.cache.videoPath); +if strlength(pathValue) == 0 + pathValue = "none"; +end +data = { ... + "Video", char(pathValue); ... + "Frames", sprintf("%d", summary.frameCount); ... + "Empty", sprintf("%d", summary.empty); ... + "Draft", sprintf("%d", summary.draft); ... + "Confirmed", sprintf("%d", summary.confirmed); ... + "Keypoints", sprintf("%d", numel( ... + state.project.annotations.skeleton.pointIds))}; +end + +function value = videoSummary(info) +if info.frameCount <= 0 + value = "No video loaded"; +else + value = sprintf("%d frames | %.4g fps | %dx%d px", ... + info.frameCount, info.frameRate, info.width, info.height); +end +end + +function value = frameStatus(state, index, points, hasVideo) +if ~hasVideo + value = "Frame: empty"; + return +end +frames = state.project.annotations.frames; +value = sprintf("Frame %d: %s (%s) | points %d / %d", ... + index, video_marker.frameAnnotations.statusName( ... + frames.frameStatus(index)), ... + video_marker.frameAnnotations.sourceName(frames.frameSource(index)), ... + size(points, 1), numel(state.project.annotations.skeleton.pointIds)); +end + +function value = skeletonStatus(locked, count) +if locked + value = sprintf("Skeleton locked for this video (%d keypoints).", count); +elseif count == 0 + value = "Define keypoints before opening a video."; +else + value = sprintf("%d keypoints ready; add connections or open a video.", count); +end +end + +function value = frameTitle(index, hasVideo) +value = "Frame + Skeleton"; +if hasVideo + value = "Frame " + string(index); +end +end diff --git a/apps/image_measurement/video_marker/+video_marker/createSession.m b/apps/image_measurement/video_marker/+video_marker/createSession.m index 5a7f804b9..4599da9d0 100644 --- a/apps/image_measurement/video_marker/+video_marker/createSession.m +++ b/apps/image_measurement/video_marker/+video_marker/createSession.m @@ -1,17 +1,22 @@ % Rebuild transient Video Marker navigation and decoded-frame state from one -% validated project after Runtime V2 resolves its video source. -function session = createSession(project) +% validated project after the App SDK resolves its video source. +function session = createSession(project, context) currentFrame = 1; info = infoFromProject(project); imageData = []; - videoPath = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "video"); + paths = strings(0, 1); + if ~isempty(project.inputs.sources) + paths = context.resolveSourcePaths(project.inputs.sources); + end + videoPath = ""; + if ~isempty(paths), videoPath = paths(1); end if strlength(videoPath) > 0 && isfile(videoPath) - [reader, info] = video_marker.videoSource.openVideo(videoPath); - verifyAnnotationShape(project.annotations, info); - imageData = video_marker.videoSource.readFrame(reader, currentFrame); + resource = video_marker.videoSource.openResource(videoPath); + info = resource.info; + imageData = resource.firstFrame; + context.setResource("document", "video", resource, []); end - presets = video_marker.userInterface.skeletonPresets(); + presets = video_marker.skeletonSetup.presets(); session = struct( ... "selection", struct( ... "currentFrame", currentFrame, ... @@ -21,11 +26,11 @@ "connectionFrom", "", ... "connectionTo", ""), ... "workflow", struct( ... - "statusMessage", initialStatus(info), ... "scaleReferenceEditing", false), ... "view", struct("scaleBar", []), ... "cache", struct( ... "videoInfo", info, ... + "videoPath", videoPath, ... "currentImage", imageData, ... "frameIndex", currentFrame)); end @@ -43,25 +48,3 @@ end end end - -function verifyAnnotationShape(annotations, info) - frames = annotations.frames; - if isempty(frames.coords) - return; - end - expectedPoints = numel(annotations.skeleton.pointIds); - if size(frames.coords, 1) ~= info.frameCount || ... - size(frames.coords, 2) ~= expectedPoints - error('video_marker:AnnotationShapeMismatch', ... - ['Saved annotations do not match the current video frame count ' ... - 'and skeleton.']); - end -end - -function value = initialStatus(info) - if info.frameCount > 0 - value = "Project video restored."; - else - value = "Define keypoints and connections before opening a video."; - end -end diff --git a/apps/image_measurement/video_marker/+video_marker/definition.m b/apps/image_measurement/video_marker/+video_marker/definition.m index f79b31e8d..67b85e722 100644 --- a/apps/image_measurement/video_marker/+video_marker/definition.m +++ b/apps/image_measurement/video_marker/+video_marker/definition.m @@ -1,22 +1,16 @@ % App-owned runtime definition for labkit_VideoMarker_app. % Expected caller: the public app entrypoint. Output is a declarative % LabKit app definition; side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_VideoMarker_app", ... - "Id", "video_marker", ... - "Title", "Video Marker", ... - "DisplayName", "Video Marker", ... - "Family", "Image Measurement", ... - "AppVersion", "1.5.7", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ... - "Project", video_marker.projectSpec(), ... - "CreateSession", @video_marker.createSession, ... - "Layout", @video_marker.userInterface.buildWorkbenchLayout, ... - "Actions", video_marker.definitionActions(), ... - "Present", @video_marker.userInterface.presentWorkbench, ... - "Renderers", struct( ... - "videoFrame", @video_marker.userInterface.renderVideoFrame), ... - "DebugSample", @video_marker.debug.writeSamplePack); +function app = definition() + app = labkit.app.Definition( ... + Entrypoint="labkit_VideoMarker_app", AppId="video_marker", ... + Title="Video Marker", DisplayName="Video Marker", ... + Family="Image Measurement", AppVersion="1.6.1", ... + Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + ProjectSchema=video_marker.projectSpec(), ... + CreateSession=@video_marker.createSession, ... + Workbench=video_marker.workbench.buildLayout(), ... + PresentWorkbench=@video_marker.workbench.present, ... + BuildDebugSample=@video_marker.debug.writeSamplePack); end diff --git a/apps/image_measurement/video_marker/+video_marker/definitionActions.m b/apps/image_measurement/video_marker/+video_marker/definitionActions.m deleted file mode 100644 index ad2a47f93..000000000 --- a/apps/image_measurement/video_marker/+video_marker/definitionActions.m +++ /dev/null @@ -1,577 +0,0 @@ -% App-owned V2 action registry for Video Marker. Handlers receive canonical -% state/events/services and own durable skeleton/annotation edits, lazy video -% resources, frame navigation, scale calibration, and standard result exports. -function actions = definitionActions() - actions = struct( ... - "openVideo", @onOpenVideo, ... - "frameChanged", @onFrameChanged, ... - "previousFrame", @onPreviousFrame, ... - "nextFrame", @onNextFrame, ... - "pointsEdited", @onPointsEdited, ... - "undoPoint", @onUndoPoint, ... - "clearFramePoints", @onClearFramePoints, ... - "measureScaleReference", @onMeasureScaleReference, ... - "scaleReferenceEdited", @onScaleReferenceEdited, ... - "scaleCalibrationChanged", @onScaleCalibrationChanged, ... - "scaleBarSettingChanged", @onScaleBarSettingChanged, ... - "placeScaleBar", @onPlaceScaleBar, ... - "importMarkerCsv", @onImportMarkerCsv, ... - "exportMarkerCsv", @onExportMarkerCsv, ... - "exportSettingChanged", @onExportSettingChanged, ... - "exportCoordinateCsv", @onExportCoordinateCsv, ... - "saveAutosave", @onSaveAutosave, ... - "newSetup", @onNewSetup); - actions = mergeActions(actions, ... - video_marker.skeletonSetup.definitionActions()); -end - -function state = onOpenVideo(state, event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - if isempty(paths) - state = services.workflow.log(state, "Video selection cancelled."); - return; - end - if isempty(state.project.annotations.skeleton.pointNames) - showError(services, 'Skeleton required', ... - 'Add and name at least one keypoint before opening a video.'); - return; - end - try - resource = openVideoResource(paths(1)); - firstFrame = resource.cache.readFrame(1); - resource.cache.reset(1, firstFrame); - setVideoResource(services, resource); - catch ME - services.diagnostics.report('Could not open video', ME); - showError(services, 'Could not open video', ME.message); - return; - end - state.project.inputs.sources = services.project.sourceRecord( ... - "video", "video", paths(1), true); - state.project.inputs.videoMetadata = ... - video_marker.videoSource.metadataFromInfo(resource.info); - pointCount = numel(state.project.annotations.skeleton.pointIds); - state.project.annotations.frames = ... - video_marker.frameAnnotations.emptyAnnotations( ... - resource.info.frameCount, pointCount); - state.project.annotations.calibration = ... - labkit.ui.interaction.scaleBarCalibration([], [], "um"); - state.project.parameters.coordinateStartFrame = 1; - state.project.parameters.coordinateEndFrame = resource.info.frameCount; - state.session.selection.currentFrame = 1; - state.session.selection.selectedPointIndex = 0; - state.session.selection.selectedEdgeIndex = 0; - state.session.workflow.scaleReferenceEditing = false; - state.session.workflow.statusMessage = "Video opened."; - state.session.view.scaleBar = []; - state.session.cache.videoInfo = resource.info; - state.session.cache.currentImage = firstFrame; - state.session.cache.frameIndex = 1; - state = clearResults(state); - state = services.workflow.log(state, "Opened video: " + paths(1)); -end - -function state = onFrameChanged(state, event, services) - target = event.value; - if isempty(target) - target = state.session.selection.currentFrame; - end - state = goToFrame(state, target, services); -end - -function state = onPreviousFrame(state, ~, services) - state = goToFrame(state, state.session.cache.frameIndex - 1, services); -end - -function state = onNextFrame(state, ~, services) - state = goToFrame(state, state.session.cache.frameIndex + 1, services); -end - -function state = goToFrame(state, target, services) - info = state.session.cache.videoInfo; - if info.frameCount <= 0 || isempty(state.session.cache.currentImage) - return; - end - target = min(max(1, round(finiteScalar(target, ... - state.session.cache.frameIndex))), info.frameCount); - startFrame = state.session.cache.frameIndex; - if target == startFrame - state.session.selection.currentFrame = target; - return; - end - try - resource = ensureVideoResource(state, services); - [frames, imageData, report] = ... - video_marker.frameNavigation.loadTargetFrame( ... - resource.cache.readFrame, state.project.annotations.frames, ... - startFrame, target, state.session.cache.currentImage, ... - numel(state.project.annotations.skeleton.pointIds)); - catch ME - services.diagnostics.report('Could not read frame', ME); - showError(services, 'Could not read frame', ME.message); - state.session.selection.currentFrame = startFrame; - return; - end - if frames.frameStatus(target) == ... - video_marker.frameAnnotations.statusCode("empty") - frames = video_marker.frameAnnotations.inheritDraft(frames, target); - end - state.project.annotations.frames = frames; - state.session.selection.currentFrame = target; - state.session.cache.currentImage = imageData; - state.session.cache.frameIndex = target; - state.session.workflow.scaleReferenceEditing = false; - state.session.view.scaleBar = []; - state = clearResults(state); - if report.predictedFrames > 0 - message = sprintf(['Predicted %d frame(s) through frame %d; ' ... - '%d point(s) used motion fallback.'], ... - report.predictedFrames, target, report.fallbackPoints); - else - message = sprintf('Moved to frame %d.', target); - end - state = services.workflow.log(state, message); -end - -function state = onPointsEdited(state, event, services) - if ~hasVideo(state) - return; - end - points = double(event.value); - if isempty(points) - points = zeros(0, 2); - elseif size(points, 2) ~= 2 - return; - end - total = numel(state.project.annotations.skeleton.pointIds); - if size(points, 1) > total - points = points(1:total, :); - end - status = "draft"; - if size(points, 1) == total - status = "confirmed"; - end - frame = state.session.cache.frameIndex; - state.project.annotations.frames = ... - video_marker.frameAnnotations.setFramePoints( ... - state.project.annotations.frames, frame, points, status, ... - "manual", ones(size(points, 1), 1)); - state = clearResults(state); - state = services.workflow.log(state, sprintf( ... - 'Frame %d points: %d / %d.', frame, size(points, 1), total)); -end - -function state = onUndoPoint(state, ~, services) - points = currentPoints(state); - if isempty(points) - return; - end - points(end, :) = []; - state = setCurrentPoints(state, points); - state = services.workflow.log(state, sprintf( ... - 'Undid last point on frame %d.', state.session.cache.frameIndex)); -end - -function state = onClearFramePoints(state, ~, services) - if ~hasVideo(state) - return; - end - state = setCurrentPoints(state, zeros(0, 2)); - state = services.workflow.log(state, sprintf( ... - 'Cleared frame %d points.', state.session.cache.frameIndex)); -end - -function state = setCurrentPoints(state, points) - total = numel(state.project.annotations.skeleton.pointIds); - status = "draft"; - if isempty(points) - status = "empty"; - elseif size(points, 1) == total - status = "confirmed"; - end - state.project.annotations.frames = ... - video_marker.frameAnnotations.setFramePoints( ... - state.project.annotations.frames, state.session.cache.frameIndex, ... - points, status, "manual", ones(size(points, 1), 1)); - state = clearResults(state); -end - -function state = onMeasureScaleReference(state, ~, services) - if ~hasVideo(state) - showError(services, 'No video loaded', ... - 'Open a video before measuring reference pixels.'); - return; - end - state.session.workflow.scaleReferenceEditing = ... - ~state.session.workflow.scaleReferenceEditing; - state.session.view.scaleBar = []; -end - -function state = onScaleReferenceEdited(state, event, ~) - points = double(event.value); - if isempty(points) - points = zeros(0, 2); - elseif size(points, 2) ~= 2 - return; - end - calibration = state.project.annotations.calibration; - state.project.annotations.calibration = ... - labkit.ui.interaction.scaleBarCalibration( ... - NaN, calibration.referenceLength, calibration.unit, ... - struct("referenceLine", points)); - state.session.view.scaleBar = []; - state = clearResults(state); -end - -function state = onScaleCalibrationChanged(state, event, ~) - calibration = state.project.annotations.calibration; - referencePixels = calibration.referencePixels; - referenceLength = calibration.referenceLength; - unit = calibration.unit; - referenceLine = calibration.referenceLine; - if event.target == "scaleReferencePixels" - referencePixels = positiveOrNaN(event.value); - referenceLine = zeros(0, 2); - elseif event.target == "scaleReferenceLength" - referenceLength = nonnegativeScalar(event.value, referenceLength); - elseif event.target == "scaleCalibrationUnit" - unit = string(event.value); - end - state.project.annotations.calibration = ... - labkit.ui.interaction.scaleBarCalibration( ... - referencePixels, referenceLength, unit, ... - struct("referenceLine", referenceLine)); - state.session.view.scaleBar = []; - state = clearResults(state); -end - -function state = onScaleBarSettingChanged(state, ~, ~) - state.project.parameters.scaleBarLength = nonnegativeScalar( ... - state.project.parameters.scaleBarLength, 0); - state.session.view.scaleBar = []; -end - -function state = onPlaceScaleBar(state, ~, services) - calibration = state.project.annotations.calibration; - if ~hasVideo(state) || ~calibration.isCalibrated - showError(services, 'Calibration required', ... - ['Measure or enter reference pixels, then enter a positive ' ... - 'reference length and unit.']); - return; - end - try - state.session.view.scaleBar = ... - labkit.ui.interaction.scaleBarGeometry( ... - size(state.session.cache.currentImage), calibration, ... - state.project.parameters.scaleBarLength, ... - state.project.parameters.scaleBarPosition, ... - state.project.parameters.scaleBarColor); - state.session.workflow.scaleReferenceEditing = false; - catch ME - services.diagnostics.report('Could not place scale bar', ME); - showError(services, 'Could not place scale bar', ME.message); - end -end - -function state = onImportMarkerCsv(state, ~, services) - [filepath, cancelled] = services.dialogs.inputFile( ... - {'*.csv', 'Marker CSV'}, 'Import marker CSV', ... - services.dialogs.defaultFolder("input")); - if cancelled - state = services.workflow.log(state, "Marker CSV import cancelled."); - return; - end - try - payload = video_marker.markerCsv.readFile(filepath); - catch ME - services.diagnostics.report('Could not import marker CSV', ME); - showError(services, 'Could not import marker CSV', ME.message); - return; - end - state.project.annotations.skeleton = payload.skeleton; - state.project.annotations.frames = payload.annotations; - state.project.annotations.calibration = payload.calibration; - state.project.inputs.videoMetadata = ... - video_marker.videoSource.metadataFromInfo(payload.videoInfo); - state = video_marker.skeletonSetup.normalizeSelection(state, true); - state.session.workflow.scaleReferenceEditing = false; - state.session.view.scaleBar = []; - pathValue = string(payload.videoInfo.path); - if strlength(pathValue) > 0 - required = isfile(pathValue); - state.project.inputs.sources = services.project.sourceRecord( ... - "video", "video", pathValue, required); - else - state.project.inputs.sources = state.project.inputs.sources([]); - end - if isfile(pathValue) - try - resource = openVideoResource(pathValue); - verifyImportedShape(payload.annotations, payload.skeleton, resource.info); - imageData = resource.cache.readFrame(1); - resource.cache.reset(1, imageData); - setVideoResource(services, resource); - state.session.cache.videoInfo = resource.info; - state.session.cache.currentImage = imageData; - state.session.cache.frameIndex = 1; - state.session.selection.currentFrame = 1; - catch ME - services.diagnostics.report('Could not reopen marker CSV video', ME); - state.session.cache.videoInfo = payload.videoInfo; - state.session.cache.currentImage = []; - end - else - state.session.cache.videoInfo = payload.videoInfo; - state.session.cache.currentImage = []; - end - state.project.parameters.coordinateStartFrame = 1; - state.project.parameters.coordinateEndFrame = ... - max(1, payload.videoInfo.frameCount); - state = clearResults(state); - state = services.workflow.log(state, "Imported marker CSV: " + filepath); -end - -function state = onExportMarkerCsv(state, ~, services) - if ~hasVideo(state) - showError(services, 'No video loaded', ... - 'Open a video before exporting marker CSV.'); - return; - end - [filepath, cancelled] = services.dialogs.outputFile( ... - {'*.csv', 'Marker CSV'}, 'Export marker CSV', ... - fullfile(defaultOutputFolder(state, services), ... - 'video_marker_markers.csv')); - if cancelled - state = services.workflow.log(state, "Marker CSV export cancelled."); - return; - end - try - video_marker.markerCsv.writeFile(filepath, ... - state.project.annotations.frames, ... - state.project.annotations.skeleton, ... - state.session.cache.videoInfo, ... - state.project.annotations.calibration); - manifestPath = writeResultManifest(state, services, filepath, ... - "markerCsv", "text/csv", "video_marker_markers.labkit.json"); - catch ME - services.diagnostics.report('Could not export marker CSV', ME); - showError(services, 'Could not export marker CSV', ME.message); - return; - end - state.project.results.markerManifestPath = string(manifestPath); - state = services.workflow.log(state, "Exported marker CSV: " + filepath); -end - -function state = onExportSettingChanged(state, ~, ~) - count = max(1, state.session.cache.videoInfo.frameCount); - state.project.parameters.coordinateStartFrame = min(max(1, round( ... - finiteScalar(state.project.parameters.coordinateStartFrame, 1))), count); - state.project.parameters.coordinateEndFrame = min(max(1, round( ... - finiteScalar(state.project.parameters.coordinateEndFrame, count))), count); - state.project.results.coordinateManifestPath = ""; -end - -function state = onExportCoordinateCsv(state, ~, services) - if ~hasVideo(state) - showError(services, 'No video loaded', ... - 'Open a video before exporting coordinate CSV.'); - return; - end - parameters = state.project.parameters; - opts = video_marker.coordinateExport.options( ... - "startFrame", parameters.coordinateStartFrame, ... - "endFrame", parameters.coordinateEndFrame, ... - "unitMode", parameters.coordinateUnitMode, ... - "originMode", parameters.coordinateOriginMode, ... - "yAxisMode", parameters.coordinateYAxisMode); - [filepath, cancelled] = services.dialogs.outputFile( ... - {'*.csv', 'Coordinate CSV'}, 'Export coordinate CSV', ... - fullfile(defaultOutputFolder(state, services), ... - 'video_marker_coordinates.csv')); - if cancelled - state = services.workflow.log(state, "Coordinate CSV export cancelled."); - return; - end - try - tableData = video_marker.coordinateExport.buildTable( ... - state.project.annotations.frames, ... - state.project.annotations.skeleton, ... - state.session.cache.videoInfo, ... - state.project.annotations.calibration, opts); - writetable(tableData, filepath); - manifestPath = writeResultManifest(state, services, filepath, ... - "coordinateCsv", "text/csv", ... - "video_marker_coordinates.labkit.json"); - catch ME - services.diagnostics.report('Could not export coordinate CSV', ME); - showError(services, 'Could not export coordinate CSV', ME.message); - return; - end - state.project.results.coordinateManifestPath = string(manifestPath); - state = services.workflow.log(state, ... - "Exported coordinate CSV: " + filepath); -end - -function state = onSaveAutosave(state, ~, services) - videoPath = labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources, "video"); - if strlength(videoPath) == 0 - state = services.workflow.log(state, ... - "Autosave unavailable until a video is open."); - return; - end - try - filepath = video_marker.autosave.filePath(videoPath); - state.project.inputs.videoMetadata = ... - video_marker.videoSource.metadataFromInfo( ... - state.session.cache.videoInfo); - services.project.saveAutosave(state, filepath); - catch ME - services.diagnostics.report('Could not save Video Marker autosave', ME); - services.dialogs.alert(ME.message, 'Could not save autosave'); - state = services.workflow.log(state, "Autosave failed: " + ME.message); - return; - end - state = services.workflow.log(state, "Autosave updated: " + filepath); -end - -function state = onNewSetup(state, ~, services) - choices = video_marker.userInterface.sessionChoices(); - answer = services.dialogs.choice( ... - ['Starting a new setup clears the current video, skeleton, and ' ... - 'annotations. Saving the current project first is recommended.'], ... - 'Start a new setup?', ... - [choices.cancel, choices.saveAndStart, choices.discardAndStart], ... - choices.saveAndStart, choices.cancel); - if answer == choices.cancel || strlength(answer) == 0 - state = services.workflow.log(state, "New setup cancelled."); - return; - end - if answer == choices.saveAndStart - filepath = services.project.saveState(); - if strlength(filepath) == 0 - state = services.workflow.log(state, ... - "New setup cancelled because project save was cancelled."); - return; - end - elseif answer ~= choices.discardAndStart - state = services.workflow.log(state, "New setup cancelled."); - return; - end - services.resources.clearScope("session"); - state = services.project.newState(); - state = services.workflow.log(state, ... - "Started a new skeleton setup and cleared the annotation session."); -end - -function resource = ensureVideoResource(state, services) - pathValue = labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources, "video"); - resource = services.resources.get("session", "video"); - if isstruct(resource) && isscalar(resource) && ... - isfield(resource, 'path') && resource.path == pathValue - return; - end - resource = openVideoResource(pathValue); - if ~isempty(state.session.cache.currentImage) - resource.cache.reset(state.session.cache.frameIndex, ... - state.session.cache.currentImage); - end - setVideoResource(services, resource); -end - -function resource = openVideoResource(pathValue) - [reader, info] = video_marker.videoSource.openVideo(pathValue); - cache = video_marker.videoSource.createDecodedFrameCache( ... - @(index) video_marker.videoSource.readFrame(reader, index)); - resource = struct("path", string(pathValue), ... - "reader", reader, "info", info, "cache", cache); -end - -function setVideoResource(services, resource) - services.resources.set("session", "video", resource); -end - -function points = currentPoints(state) - points = zeros(0, 2); - if hasVideo(state) - points = video_marker.frameAnnotations.framePoints( ... - state.project.annotations.frames, ... - state.session.cache.frameIndex); - end -end - -function tf = hasVideo(state) - tf = state.session.cache.videoInfo.frameCount > 0 && ... - ~isempty(state.session.cache.currentImage); -end - -function state = clearResults(state) - state.project.results.markerManifestPath = ""; - state.project.results.coordinateManifestPath = ""; -end - -function folder = defaultOutputFolder(state, services) - videoPath = labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources, "video"); - folder = services.dialogs.defaultOutputFolder( ... - videoPath, "video_marker"); -end - -function manifestPath = writeResultManifest( ... - state, services, filepath, id, mediaType, manifestName) - [folder, name, extension] = fileparts(filepath); - output = services.results.output(id, "primary", ... - string(name) + string(extension), mediaType); - spec = struct( ... - "Outputs", output, ... - "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, ... - "Summary", video_marker.frameAnnotations.summary( ... - state.project.annotations.frames), ... - "ManifestName", manifestName); - [manifestPath, ~] = services.results.writeManifest(folder, spec); -end - -function verifyImportedShape(frames, skeleton, info) - if size(frames.coords, 1) ~= info.frameCount || ... - size(frames.coords, 2) ~= numel(skeleton.pointIds) - error('video_marker:ImportedShapeMismatch', ... - 'Imported annotations do not match the referenced video.'); - end -end - -function showError(services, titleText, message) - services.dialogs.alert(message, titleText); -end - -function value = finiteScalar(value, fallback) - value = double(value); - if isempty(value) || ~isscalar(value) || ~isfinite(value) - value = fallback; - end -end - -function value = nonnegativeScalar(value, fallback) - value = finiteScalar(value, fallback); - if value < 0 - value = fallback; - end -end - -function value = positiveOrNaN(value) - value = finiteScalar(value, NaN); - if ~isfinite(value) || value <= 0 - value = NaN; - end -end - -function target = mergeActions(target, source) - names = fieldnames(source); - for k = 1:numel(names) - target.(names{k}) = source.(names{k}); - end -end diff --git a/apps/image_measurement/video_marker/+video_marker/projectSpec.m b/apps/image_measurement/video_marker/+video_marker/projectSpec.m index e31e65e14..68f455819 100644 --- a/apps/image_measurement/video_marker/+video_marker/projectSpec.m +++ b/apps/image_measurement/video_marker/+video_marker/projectSpec.m @@ -1,22 +1,19 @@ -% App-owned durable Video Marker contract. Runtime V2 calls the one migration +% App-owned durable Video Marker contract. The App SDK calls the one migration % entry for old payload versions, legacy importer for the former MAT variable, % and resume hooks for the current-frame convenience. function spec = projectSpec() - spec = struct( ... - "Version", 2, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", @migrateProject, ... - "LegacyImports", struct( ... + spec = labkit.app.project.Schema( ... + Version=2, Create=@createProject, Validate=@validateProject, Migrate=@migrateProject, ... + LegacyImports=struct( ... "videoMarkerProject", @importLegacyProject), ... - "CreateResume", @createResume, ... - "ApplyResume", @applyResume); + CreateResume=@createResume, ... + ApplyResume=@applyResume); end function project = createProject() project = struct(); project.inputs = struct( ... - "sources", labkit.ui.runtime.emptySourceRecords(), ... + "sources", struct([]), ... "videoMetadata", video_marker.videoSource.emptyMetadata()); project.parameters = struct( ... "coordinateUnitMode", "pixels", ... @@ -31,7 +28,7 @@ "skeleton", video_marker.skeletonDefinition.defaultSkeleton(), ... "frames", video_marker.frameAnnotations.emptyAnnotations(0, 0), ... "calibration", ... - labkit.ui.interaction.scaleBarCalibration([], [], "px")); + labkit.app.interaction.scaleCalibration([], [], "um")); project.results = struct( ... "markerManifestPath", "", ... "coordinateManifestPath", ""); @@ -95,14 +92,14 @@ end function sources = legacySources(legacy) - sources = labkit.ui.runtime.emptySourceRecords(); + sources = struct([]); if isfield(legacy, 'videoReference') && ... isstruct(legacy.videoReference) - sources = labkit.ui.runtime.sourceRecord( ... + sources = labkit.app.project.sourceRecord( ... "video", "video", legacy.videoReference, true); elseif isfield(legacy, 'videoPath') && ... strlength(string(legacy.videoPath)) > 0 - sources = labkit.ui.runtime.sourceRecord( ... + sources = labkit.app.project.sourceRecord( ... "video", "video", string(legacy.videoPath), true); end end @@ -112,15 +109,17 @@ double(session.selection.currentFrame)); end -function session = applyResume(session, resume, project) +function session = applyResume(session, resume, ~) if session.cache.videoInfo.frameCount <= 0 || ... ~isstruct(resume) || ~isfield(resume, 'currentFrame') return; end target = min(max(1, round(double(resume.currentFrame))), ... session.cache.videoInfo.frameCount); - videoPath = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "video"); + videoPath = session.cache.videoPath; + if strlength(videoPath) == 0 || ~isfile(videoPath) + return; + end [reader, ~] = video_marker.videoSource.openVideo(videoPath); session.selection.currentFrame = target; session.cache.currentImage = ... @@ -178,7 +177,7 @@ if isstruct(value) && isfield(value, 'referenceLine') line = value.referenceLine; end - calibration = labkit.ui.interaction.scaleBarCalibration( ... + calibration = labkit.app.interaction.scaleCalibration( ... fieldValue(value, 'referencePixels', NaN), ... fieldValue(value, 'referenceLength', 0), ... fieldValue(value, 'unit', "px"), ... diff --git a/apps/image_measurement/video_marker/labkit_VideoMarker_app.m b/apps/image_measurement/video_marker/labkit_VideoMarker_app.m index 8a3eb54e8..8e7e8d0ec 100644 --- a/apps/image_measurement/video_marker/labkit_VideoMarker_app.m +++ b/apps/image_measurement/video_marker/labkit_VideoMarker_app.m @@ -1,6 +1,5 @@ function varargout = labkit_VideoMarker_app(varargin) %LABKIT_VIDEOMARKER_APP Manually mark ordered feature points across video frames. -% Thin entrypoint delegates requests, launch, and version title to runtime V2. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @video_marker.definition, varargin{:}); +% Thin entrypoint delegates requests, launch, and version title to the App SDK. + [varargout{1:nargout}] = video_marker.definition().launch(varargin{:}); end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+debug/writeSamplePack.m b/apps/labkit_core/figure_studio/+figure_studio/+debug/writeSamplePack.m index 10dfa90a6..087c115a9 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+debug/writeSamplePack.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+debug/writeSamplePack.m @@ -1,27 +1,23 @@ -% Expected caller: figure_studio.definitionActions during debug launch and +% Expected caller: Figure Studio direct callbacks during debug launch and % unit guardrails. Input is a LabKit debug context. Output is a deterministic % synthetic FIG sample pack. Side effects: writes anonymous debug FIG files % and records a session manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Figure Studio debug FIG files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "figure_studio"); - figPath = string(fullfile(folders.sampleFolder, "figure_studio_debug.fig")); + figPath = sampleContext.samplePath("figure_studio/source.fig"); writeDebugFigure(figPath); - pack = struct( ... - "sampleFolder", folders.sampleFolder, ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", figPath, ... - "boundaryFiles", struct()); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_FigureStudio_app", ... - "description", "Anonymous MATLAB FIG sample for Figure Studio debug launch.", ... - "inputs", struct("representativeFig", figPath), ... - "outputFolder", folders.outputFolder); - pack.manifest = manifest; - recordManifest(debugLog, manifest); + project = figure_studio.projectSpec().Create(); + project.inputs.sources = sampleContext.sourceRecord( ... + "figure1", "figure", figPath, true); + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-figure", InitialProject=project, ... + Artifacts={sampleContext.artifact( ... + "sourceFigure", "figure", figPath)}); end function writeDebugFigure(filepath) @@ -46,35 +42,3 @@ function writeDebugFigure(filepath) legend(ax, 'Location', 'northwest', 'Box', 'off'); savefig(fig, char(filepath)); end - -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", ... - "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", ... - "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && ... - isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/applyFigureStyle.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/applyFigureStyle.m index eba3f05d7..34ea32a59 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/applyFigureStyle.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/applyFigureStyle.m @@ -26,7 +26,8 @@ function applyFigureStyle(ax, preset) preset = "nature"; end if isempty(ax) || ~isvalid(ax) - error('labkit:ui:InvalidAxes', 'Axes handle is not valid.'); + error('figure_studio:resultFiles:InvalidAxes', ... + 'Axes handle is not valid.'); end if isstruct(preset) applyStyleStruct(ax, preset); @@ -276,7 +277,7 @@ function applyAxesCanvasFrame(ax, width, height) function tf = applyGridCanvasFrame(ax, width, height) tf = false; try - [tf, frame] = labkit.ui.plot.fitCanvas(ax, width, height); + [tf, frame] = labkit.app.plot.fitCanvasToSource(ax, width, height); if ~tf return; end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/chooseOutputFolder.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/chooseOutputFolder.m new file mode 100644 index 000000000..8569c9ff3 --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/chooseOutputFolder.m @@ -0,0 +1,21 @@ +% App-owned implementation for figure_studio.resultFiles.chooseOutputFolder within the figure_studio product workflow. +function state = chooseOutputFolder(state, callbackContext) +%CHOOSEOUTPUTFOLDER Store the package destination selected by the user. +arguments + state (1, 1) struct + callbackContext (1, 1) labkit.app.CallbackContext +end +startPath = state.project.parameters.outputFolder; +if strlength(startPath) == 0 + startPath = pwd; +end +chosen = callbackContext.chooseOutputFolder(startPath); +if chosen.Cancelled + return +end +state.project.parameters.outputFolder = string(chosen.Value); +state.project.results.lastExport = []; +state.project.results.resultManifestPath = ""; +callbackContext.appendStatus( ... + "Output folder: " + state.project.parameters.outputFolder); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/createStyledFigure.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/createStyledFigure.m index 816cca24d..0574d4128 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/createStyledFigure.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/createStyledFigure.m @@ -4,5 +4,5 @@ fig = figure('Visible', 'off', 'Color', 'w'); ax = axes('Parent', fig); model = struct("plotData", plotData, "style", style, "preview", false); - figure_studio.userInterface.renderStudioPlot(ax, model); + figure_studio.sourceAxes.drawPreview(struct("main", ax), model); end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportCurrent.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportCurrent.m new file mode 100644 index 000000000..3e19c0e1a --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportCurrent.m @@ -0,0 +1,74 @@ +% App-owned implementation for figure_studio.resultFiles.exportCurrent within the figure_studio product workflow. +function state = exportCurrent(state, callbackContext) +%EXPORTCURRENT Write visible data, reconstruction script, and provenance. +arguments + state (1, 1) struct + callbackContext (1, 1) labkit.app.CallbackContext +end +if isempty(state.session.cache.plotData) + callbackContext.alert( ... + "No preview axes content is available to export.", "Figure Studio"); + return +end +root = state.project.parameters.outputFolder; +if strlength(root) == 0 + chosen = callbackContext.chooseOutputFolder(pwd); + if chosen.Cancelled + return + end + root = string(chosen.Value); +end +folder = string(fullfile(root, exportFolderName(state))); +[fig, ax] = figure_studio.resultFiles.createStyledFigure( ... + state.session.cache.plotData, state.project.parameters.style); +cleanup = onCleanup(@() delete(fig)); +payload = figure_studio.resultFiles.exportAxesPackage(ax, folder); +outputs = packageOutputs(payload); +package = labkit.app.result.Package(Outputs=outputs, ... + Inputs=struct("sources", state.project.inputs.sources), ... + Parameters=state.project.parameters, ... + Summary=struct( ... + "objectCount", numel(state.session.cache.plotData.objects)), ... + Warnings=payload.warnings, ... + ManifestName="figure_studio.labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +state.project.parameters.outputFolder = root; +state.project.results.lastExport = struct( ... + "kind", "package", "path", folder, ... + "manifestPath", string(written.Value), "outputs", payload); +state.project.results.resultManifestPath = string(written.Value); +callbackContext.appendStatus("Exported package: " + folder); +end + +function outputs = packageOutputs(payload) +outputs = { ... + resultFile("plotData", "primary", payload.mat, ... + "application/x-matlab-data"), ... + resultFile("recreateScript", "reconstruction", payload.script, ... + "text/x-matlab"), ... + resultFile("readme", "documentation", payload.readme, ... + "text/plain")}; +if strlength(payload.csv) > 0 + outputs{end + 1} = resultFile( ... + "plotCsv", "tabular", payload.csv, "text/csv"); +end +end + +function output = resultFile(id, role, filepath, mediaType) +[~, name, extension] = fileparts(filepath); +output = labkit.app.result.File(id, role, ... + string(name) + string(extension), MediaType=mediaType); +end + +function name = exportFolderName(state) +source = state.session.cache.currentSource; +if strlength(source) == 0 + source = "figure"; +end +[~, stem] = fileparts(source); +if strlength(stem) == 0 + stem = "figure"; +end +name = string(matlab.lang.makeValidName(char(stem))) + "_" + ... + string(datestr(now, "yyyymmdd_HHMMSS")); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportGraphic.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportGraphic.m new file mode 100644 index 000000000..152129341 --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportGraphic.m @@ -0,0 +1,75 @@ +% App-owned implementation for figure_studio.resultFiles.exportGraphic within the figure_studio product workflow. +function state = exportGraphic(state, callbackContext, format) +%EXPORTGRAPHIC Write one styled graphic and its standard result manifest. +arguments + state (1, 1) struct + callbackContext (1, 1) labkit.app.CallbackContext + format (1, 1) string +end +if isempty(state.session.cache.plotData) + callbackContext.alert( ... + "No preview axes content is available to export.", "Figure Studio"); + return +end +extension = "." + format; +chosen = callbackContext.chooseOutputFile( ... + ["*" + extension, upper(format) + " file"], ... + quickExportStartPath(state)); +if chosen.Cancelled + return +end +[fig, ax] = figure_studio.resultFiles.createStyledFigure( ... + state.session.cache.plotData, state.project.parameters.style); +cleanup = onCleanup(@() delete(fig)); +filepath = string(chosen.Value); +if format == "fig" + savefig(fig, filepath); +elseif format == "svg" + exportgraphics(ax, filepath, ContentType="vector"); +else + resolution = max(72, round( ... + 300 * state.project.parameters.style.exportScale)); + exportgraphics(ax, filepath, Resolution=resolution); +end +[folder, base, suffix] = fileparts(filepath); +output = labkit.app.result.File( ... + "styledFigure", "primary", string(base) + string(suffix), ... + MediaType=mediaType(format)); +package = labkit.app.result.Package(Outputs={output}, ... + Inputs=struct("sources", state.project.inputs.sources), ... + Parameters=state.project.parameters, ... + Summary=struct("format", format), ... + ManifestName="figure_studio.labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +state.project.results.lastExport = struct( ... + "kind", format, "path", filepath, ... + "manifestPath", string(written.Value)); +state.project.results.resultManifestPath = string(written.Value); +callbackContext.appendStatus( ... + "Exported " + upper(format) + ": " + filepath); +end + +function startPath = quickExportStartPath(state) +startPath = state.project.parameters.outputFolder; +if strlength(startPath) == 0 + source = state.session.cache.currentSource; + if strlength(source) > 0 && isfile(source) + startPath = string(fileparts(source)); + else + startPath = pwd; + end +end +end + +function value = mediaType(format) +switch format + case "fig" + value = "application/x-matlab-figure"; + case "png" + value = "image/png"; + case "jpg" + value = "image/jpeg"; + case "svg" + value = "image/svg+xml"; +end +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportJpg.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportJpg.m new file mode 100644 index 000000000..747ecadeb --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportJpg.m @@ -0,0 +1,4 @@ +% App-owned implementation for figure_studio.resultFiles.exportJpg within the figure_studio product workflow. +function state = exportJpg(state, context) +state = figure_studio.resultFiles.exportGraphic(state, context, "jpg"); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportPng.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportPng.m new file mode 100644 index 000000000..c28159056 --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportPng.m @@ -0,0 +1,4 @@ +% App-owned implementation for figure_studio.resultFiles.exportPng within the figure_studio product workflow. +function state = exportPng(state, context) +state = figure_studio.resultFiles.exportGraphic(state, context, "png"); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportSvg.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportSvg.m new file mode 100644 index 000000000..7c4557b8a --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportSvg.m @@ -0,0 +1,4 @@ +% App-owned implementation for figure_studio.resultFiles.exportSvg within the figure_studio product workflow. +function state = exportSvg(state, context) +state = figure_studio.resultFiles.exportGraphic(state, context, "svg"); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/extractAxesData.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/extractAxesData.m index 7308e2bda..feb460b2c 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/extractAxesData.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/extractAxesData.m @@ -4,7 +4,8 @@ % recalculation-grade scientific exports. function plotData = extractAxesData(ax) if isempty(ax) || ~isvalid(ax) - error('labkit:ui:InvalidAxes', 'Axes handle is not valid.'); + error('figure_studio:resultFiles:InvalidAxes', ... + 'Axes handle is not valid.'); end plotData = struct(); plotData.schema = "figure_studio.resultFiles.axesData.v1"; diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/generateAxesScript.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/generateAxesScript.m index 971e7a980..26b790c9d 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/generateAxesScript.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/generateAxesScript.m @@ -6,7 +6,7 @@ scriptPath = fullfile(string(folder), "recreate_plot.m"); fid = fopen(scriptPath, 'w'); if fid < 0 - error('labkit:ui:ExportWriteFailed', ... + error('figure_studio:resultFiles:ExportWriteFailed', ... 'Could not write recreate_plot.m.'); end cleanup = onCleanup(@() fclose(fid)); diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/layoutSection.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/layoutSection.m new file mode 100644 index 000000000..ac3c4c877 --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/layoutSection.m @@ -0,0 +1,15 @@ +% App-owned implementation for figure_studio.resultFiles.layoutSection within the figure_studio product workflow. +function section = layoutSection() +section = labkit.app.layout.section("exportSection", "Export Package", { ... + labkit.app.layout.field("outputFolder", Label="Output folder", ... + Kind="readonly", Value=""), ... + labkit.app.layout.group("exportActions", { ... + labkit.app.layout.button("chooseOutputFolder", ... + "Choose output folder", ... + @figure_studio.resultFiles.chooseOutputFolder, ... + Tooltip="Choose the destination for extracted axes data and the reproducible figure script."), ... + labkit.app.layout.button("exportCurrent", ... + "Export data + script", ... + @figure_studio.resultFiles.exportCurrent, ... + Tooltip="Export plotted numeric data together with a MATLAB script that reconstructs the styled figure.")})}); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/saveFig.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/saveFig.m new file mode 100644 index 000000000..50a561567 --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/saveFig.m @@ -0,0 +1,4 @@ +% App-owned implementation for figure_studio.resultFiles.saveFig within the figure_studio product workflow. +function state = saveFig(state, context) +state = figure_studio.resultFiles.exportGraphic(state, context, "fig"); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/writeAxesDataExport.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/writeAxesDataExport.m index 787c2aed5..671b665ed 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/writeAxesDataExport.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/writeAxesDataExport.m @@ -5,7 +5,7 @@ function manifest = writeAxesDataExport(ax, folder) folder = string(folder); if ~isscalar(folder) || strlength(folder) == 0 - error('labkit:ui:InvalidExportFolder', ... + error('figure_studio:resultFiles:InvalidExportFolder', ... 'Export folder must be nonempty scalar text.'); end if ~isfolder(folder) @@ -91,7 +91,7 @@ readmePath = fullfile(folder, "README.txt"); fid = fopen(readmePath, 'w'); if fid < 0 - error('labkit:ui:ExportWriteFailed', ... + error('figure_studio:resultFiles:ExportWriteFailed', ... 'Could not write export README.'); end cleanup = onCleanup(@() fclose(fid)); diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/copyToPreview.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/copyToPreview.m index ed76e94cf..e5ebb2bd4 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/copyToPreview.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/copyToPreview.m @@ -1,8 +1,8 @@ % Copy axes graphics and display state into a Figure Studio preview axes. -% Expected caller is figure_studio.definitionActions; source graphics are not +% Expected caller is Figure Studio direct callbacks; source graphics are not % modified and destination axes content is reset before children are copied. function copyToPreview(srcAx, dstAx) - labkit.ui.plot.clear(dstAx, "ResetScale", true); + labkit.app.plot.clearAxes(dstAx); dstAx.Visible = 'on'; disableDefaultAxesToolbar(dstAx); copyAxesState(srcAx, dstAx); @@ -15,7 +15,7 @@ function copyToPreview(srcAx, dstAx) ylabel(dstAx, string(srcAx.YLabel.String), 'Interpreter', 'none'); zlabel(dstAx, string(srcAx.ZLabel.String), 'Interpreter', 'none'); normalizePreviewAxesLayout(dstAx); - labkit.ui.interaction.enablePopout(dstAx); + labkit.app.plot.fitAxesToGraphics(dstAx); end function disableDefaultAxesToolbar(ax) diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/drawPreview.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/drawPreview.m new file mode 100644 index 000000000..a08b2fa5c --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/drawPreview.m @@ -0,0 +1,184 @@ +% Expected caller: the registered Figure Studio App SDK renderer and export +% helper. +% Inputs are target axes plus a serializable plot/style model. Side effects +% are limited to drawing and styling the target axes. +function drawPreview(axesById, model) + ax = axesById.main; + if isempty(model.plotData) + labkit.app.plot.clearAxes(ax); + if isappdata(ax, 'labkitFigureStudioPlotData') + rmappdata(ax, 'labkitFigureStudioPlotData'); + end + if isappdata(ax, 'labkitFigureStudioPreviewStyle') + rmappdata(ax, 'labkitFigureStudioPreviewStyle'); + end + ax.Visible = 'off'; + title(ax, "No figure loaded"); + return; + end + samePlot = isappdata(ax, 'labkitFigureStudioPlotData') && ... + isequaln(getappdata(ax, 'labkitFigureStudioPlotData'), model.plotData); + if samePlot + style = model.style; + style.previewScale = logical(model.preview); + figure_studio.resultFiles.applyFigureStyle(ax, style); + if model.preview + setappdata(ax, 'labkitFigureStudioPreviewStyle', style); + end + return; + end + labkit.app.plot.clearAxes(ax); + ax.Visible = 'on'; + disableDefaultAxesToolbar(ax); + applyAxesMetadata(ax, model.plotData.axes); + hold(ax, 'on'); + for k = 1:numel(model.plotData.objects) + renderObject(ax, model.plotData.objects(k)); + end + hold(ax, 'off'); + applyAxesMetadata(ax, model.plotData.axes); + if any(strlength(string({model.plotData.objects.displayName})) > 0) + legend(ax, 'show', 'Interpreter', 'none'); + end + style = model.style; + style.previewScale = logical(model.preview); + figure_studio.resultFiles.applyFigureStyle(ax, style); + if model.preview + setappdata(ax, 'labkitFigureStudioPlotData', model.plotData); + setappdata(ax, 'labkitFigureStudioPreviewStyle', style); + figure_studio.sourceAxes.resizePreview(ax, style); + end +end + +function renderObject(ax, object) + switch string(object.type) + case "line" + h = plot(ax, object.x, object.y, ... + 'DisplayName', char(object.displayName)); + applyCoordinates(h, object); + case "scatter" + h = scatter(ax, object.x, object.y, ... + 'DisplayName', char(object.displayName)); + applyCoordinates(h, object); + case "image" + h = image(ax, 'CData', object.c); + applyCoordinates(h, object); + case "surface" + h = surface(ax, object.x, object.y, object.z, object.c, ... + 'DisplayName', char(object.displayName)); + applyCoordinates(h, object); + case "patch" + h = patch(ax, 'XData', object.x, 'YData', object.y, ... + 'DisplayName', char(object.displayName)); + applyCoordinates(h, object); + case "text" + h = renderText(ax, object); + case "constantline" + h = renderConstantLine(ax, object); + otherwise + return; + end + applyStyle(h, object.style); +end + +function h = renderText(ax, object) + value = ""; + if isfield(object.metadata, 'text') + value = string(object.metadata.text); + end + position = double(object.x(:).'); + if numel(position) < 2 + position = [0 0 0]; + end + h = text(ax, position(1), position(2), char(value), ... + 'DisplayName', char(object.displayName)); + if numel(position) >= 3 + h.Position = position(1:3); + end +end + +function h = renderConstantLine(ax, object) + value = fieldValue(object.metadata, 'value', 0); + label = string(fieldValue(object.metadata, 'label', "")); + interceptAxis = lower(string(fieldValue( ... + object.metadata, 'interceptAxis', "x"))); + if interceptAxis == "y" + h = yline(ax, value, char(label), ... + 'DisplayName', char(object.displayName)); + else + h = xline(ax, value, char(label), ... + 'DisplayName', char(object.displayName)); + end +end + +function applyCoordinates(h, object) + values = {object.x, object.y, object.z, object.c, object.alpha}; + names = {'XData', 'YData', 'ZData', 'CData', 'AlphaData'}; + for k = 1:numel(names) + if ~isempty(values{k}) + safeSet(h, names{k}, values{k}); + end + end +end + +function applyStyle(h, style) + names = fieldnames(style); + for k = 1:numel(names) + safeSet(h, names{k}, style.(names{k})); + end +end + +function applyAxesMetadata(ax, meta) + title(ax, meta.title, 'Interpreter', 'none'); + xlabel(ax, meta.xLabel, 'Interpreter', 'none'); + ylabel(ax, meta.yLabel, 'Interpreter', 'none'); + zlabel(ax, meta.zLabel, 'Interpreter', 'none'); + mapping = { ... + 'XScale', 'xScale'; 'YScale', 'yScale'; 'ZScale', 'zScale'; ... + 'XDir', 'xDir'; 'YDir', 'yDir'; 'ZDir', 'zDir'; ... + 'XLim', 'xLim'; 'YLim', 'yLim'; 'ZLim', 'zLim'; ... + 'CLim', 'cLim'; 'Color', 'color'; 'Box', 'box'; ... + 'Layer', 'layer'; 'TickDir', 'tickDir'; ... + 'XGrid', 'xGrid'; 'YGrid', 'yGrid'; 'ZGrid', 'zGrid'; ... + 'XMinorGrid', 'xMinorGrid'; 'YMinorGrid', 'yMinorGrid'; ... + 'ZMinorGrid', 'zMinorGrid'; 'GridAlpha', 'gridAlpha'; ... + 'MinorGridAlpha', 'minorGridAlpha'; ... + 'DataAspectRatio', 'dataAspectRatio'; ... + 'DataAspectRatioMode', 'dataAspectRatioMode'; ... + 'PlotBoxAspectRatio', 'plotBoxAspectRatio'; ... + 'PlotBoxAspectRatioMode', 'plotBoxAspectRatioMode'; ... + 'ColorOrder', 'colorOrder'; 'FontName', 'fontName'; ... + 'FontSize', 'fontSize'; 'LineWidth', 'lineWidth'}; + for k = 1:size(mapping, 1) + if isfield(meta, mapping{k, 2}) + safeSet(ax, mapping{k, 1}, meta.(mapping{k, 2})); + end + end + if isfield(meta, 'colormap') && ~isempty(meta.colormap) + colormap(ax, meta.colormap); + end +end + +function value = fieldValue(owner, name, fallback) + value = fallback; + if isstruct(owner) && isfield(owner, name) && ~isempty(owner.(name)) + value = owner.(name); + end +end + +function safeSet(h, name, value) + try + if isprop(h, name) + h.(name) = value; + end + catch + end +end + +function disableDefaultAxesToolbar(ax) + try + ax.Toolbar.Visible = 'off'; + disableDefaultInteractivity(ax); + catch + end +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/layoutSection.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/layoutSection.m new file mode 100644 index 000000000..bacd8871c --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/layoutSection.m @@ -0,0 +1,35 @@ +% App-owned implementation for figure_studio.sourceAxes.layoutSection within the figure_studio product workflow. +function section = layoutSection() +section = labkit.app.layout.section("sourceSection", "MATLAB Figures", { ... + labkit.app.layout.fileList("figFiles", Label="FIG files", ... + Filters=["*.fig", "MATLAB figure (*.fig)"], ... + SelectionMode="single", ShowStatus=false, ... + ChooseLabel="Add FIG files or scan folder", ... + ChooseTooltip="Add MATLAB FIG files whose axes data and styling will be inspected without rerunning the source analysis.", ... + FolderLabel="Add folder", ... + RecursiveFolderLabel="Add folder tree", ... + RemoveLabel="Remove selected", ClearLabel="Clear figures", ... + EmptyText="No FIG files loaded", ... + Bind="project.inputs.sources", ... + SelectionBind="session.selection.files", ... + SourceRole="figure", SourceIdPrefix="figure", Required=true, ... + OnSelectionChanged=@figure_studio.sourceAxes.selectionChanged), ... + labkit.app.layout.field("currentSource", ... + Label="Current source", Kind="readonly", Value=""), ... + labkit.app.layout.field("statusSummary", Label="Status", ... + Kind="readonly", ... + Value="Load a MATLAB .fig file or send a popout plot to Studio."), ... + labkit.app.layout.group("quickExports", { ... + labkit.app.layout.button("saveFig", "Save FIG", ... + @figure_studio.resultFiles.saveFig, ... + Tooltip="Save the currently styled MATLAB figure as an editable FIG file."), ... + labkit.app.layout.button("exportPng", "PNG", ... + @figure_studio.resultFiles.exportPng, ... + Tooltip="Render the currently styled axes to a raster PNG image."), ... + labkit.app.layout.button("exportJpg", "JPG", ... + @figure_studio.resultFiles.exportJpg, ... + Tooltip="Render the currently styled axes to a lossy raster JPEG image."), ... + labkit.app.layout.button("exportSvg", "SVG", ... + @figure_studio.resultFiles.exportSvg, ... + Tooltip="Export the currently styled axes as scalable vector graphics.")})}); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/resizePreview.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/resizePreview.m new file mode 100644 index 000000000..759c9be40 --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/resizePreview.m @@ -0,0 +1,8 @@ +% App-owned implementation for figure_studio.sourceAxes.resizePreview within the figure_studio product workflow. +function resizePreview(axesHandle, style) +%RESIZEPREVIEW Reapply the Figure Studio canvas policy after native reflow. +if isempty(style) || ~isfield(style, "canvasWidth") || ~isfield(style, "canvasHeight") + return; +end +labkit.app.plot.fitCanvasToSource(axesHandle, style.canvasWidth, style.canvasHeight); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/selectionChanged.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/selectionChanged.m new file mode 100644 index 000000000..a3f7a0394 --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/selectionChanged.m @@ -0,0 +1,60 @@ +% App-owned implementation for figure_studio.sourceAxes.selectionChanged within the figure_studio product workflow. +function state = selectionChanged(state, selection, callbackContext) +%SELECTIONCHANGED Load the selected FIG and adopt its source presentation. +arguments + state (1, 1) struct + selection (1, 1) labkit.app.event.ListSelection + callbackContext (1, 1) labkit.app.CallbackContext +end +state.project.annotations.embeddedPlot = []; +if isempty(selection.Indices) || isempty(state.project.inputs.sources) + state.session.cache.plotData = []; + state.session.cache.currentSource = ""; + state.session.selection.currentIndex = 0; + state.session.workflow.status = "No FIG files loaded."; + state.project.results.lastExport = []; + state.project.results.resultManifestPath = ""; + return +end +paths = callbackContext.resolveSourcePaths(state.project.inputs.sources); +index = min(selection.Indices(1), numel(paths)); +sourcePath = paths(index); +[plotData, sourceStyle] = figure_studio.sourceAxes.readFigFile(sourcePath); +state.session.selection.currentIndex = index; +state.session.cache.plotData = plotData; +state.session.cache.sourceDefaultStyle = sourceStyle; +state.session.cache.currentSource = sourcePath; +state.project.annotations.sourceDefaultStyle = sourceStyle; +state = adoptSourceStyle(state, sourceStyle); +if strlength(state.project.parameters.outputFolder) == 0 + state.project.parameters.outputFolder = string(fileparts(sourcePath)); +end +[~, name, extension] = fileparts(sourcePath); +state.session.workflow.status = "Opened " + ... + string(name) + string(extension) + "."; +state.project.results.lastExport = []; +state.project.results.resultManifestPath = ""; +callbackContext.appendStatus("Opened FIG: " + sourcePath); +end + +function state = adoptSourceStyle(state, sourceStyle) +p = state.project.parameters; +if p.preset == "FIG default" + p.style = sourceStyle; +else + p.style.canvasWidth = sourceStyle.canvasWidth; + p.style.canvasHeight = sourceStyle.canvasHeight; + p.aspectPreset = "Custom"; +end +p.gridChoice = onOff(p.style.gridVisible); +p.boundaryChoice = onOff(p.style.boundaryLines); +state.project.parameters = p; +end + +function value = onOff(tf) +if tf + value = "On"; +else + value = "Off"; +end +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/sourceStyle.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/sourceStyle.m index 9fc1dfb7f..cd0b6e0db 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/sourceStyle.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/sourceStyle.m @@ -1,5 +1,5 @@ % Read style defaults from a source axes for Figure Studio. Expected caller is -% figure_studio.definitionActions; output follows the app-owned style struct. +% Figure Studio direct callbacks; output follows the app-owned style struct. function style = sourceStyle(srcAx, opts) arguments srcAx diff --git a/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/applyPreset.m b/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/applyPreset.m new file mode 100644 index 000000000..c9d607b07 --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/applyPreset.m @@ -0,0 +1,38 @@ +% App-owned implementation for figure_studio.styleLibrary.applyPreset within the figure_studio product workflow. +function state = applyPreset(state, preset, callbackContext) +%APPLYPRESET Apply a named style while preserving canvas/export geometry. +arguments + state (1, 1) struct + preset (1, 1) string + callbackContext (1, 1) labkit.app.CallbackContext +end +p = state.project.parameters; +previous = p.style; +p.preset = preset; +if preset == "FIG default" + p.style = state.session.cache.sourceDefaultStyle; + if isempty(p.style) + p.style = figure_studio.styleLibrary.styleForPreset(preset); + end +else + p.style = figure_studio.styleLibrary.styleForPreset(preset); + p.style.canvasWidth = previous.canvasWidth; + p.style.canvasHeight = previous.canvasHeight; + p.style.exportScale = previous.exportScale; +end +p.gridChoice = onOff(p.style.gridVisible); +p.boundaryChoice = onOff(p.style.boundaryLines); +state.project.parameters = p; +state.session.workflow.status = "Styled with " + preset + "."; +state.project.results.lastExport = []; +state.project.results.resultManifestPath = ""; +callbackContext.appendStatus("Selected style mode: " + preset); +end + +function value = onOff(tf) +if tf + value = "On"; +else + value = "Off"; +end +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/canvasLayoutSection.m b/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/canvasLayoutSection.m new file mode 100644 index 000000000..cb8a2ca11 --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/canvasLayoutSection.m @@ -0,0 +1,45 @@ +% App-owned implementation for figure_studio.styleLibrary.canvasLayoutSection within the figure_studio product workflow. +function section = canvasLayoutSection() +%CANVASLAYOUTSECTION Declare Figure Studio canvas and export-scale controls. +section = labkit.app.layout.section("canvasSection", "Canvas", { ... + labkit.app.layout.field("aspectPreset", Label="Aspect:", ... + Kind="choice", Choices=["4:3", "16:9", "1:1", "3:2", "Custom"], ... + Bind="project.parameters.aspectPreset", ... + OnValueChanged=@aspectChanged), ... + canvasPanner("canvasWidth", "Width px:", [400 8000], 50, ... + @widthChanged), ... + canvasPanner("canvasHeight", "Height px:", [300 8000], 50, ... + @heightChanged), ... + canvasPanner("exportScale", "Export x:", [1 8], 0.5, ... + @exportScaleChanged), ... + labkit.app.layout.field("boundaryLines", Label="Boundary:", ... + Kind="choice", Choices=["Off", "On"], ... + Bind="project.parameters.boundaryChoice", ... + OnValueChanged=@boundaryChanged)}); +end + +function node = canvasPanner(id, label, limits, step, callback) +node = labkit.app.layout.slider(id, Label=label, Limits=limits, Step=step, ... + ValueDisplayFormat="%.6g", ... + Bind="project.parameters.style." + id, OnValueChanged=callback); +end + +function state = aspectChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "aspectPreset"); +end + +function state = widthChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "canvasWidth"); +end + +function state = heightChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "canvasHeight"); +end + +function state = exportScaleChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "exportScale"); +end + +function state = boundaryChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "boundaryLines"); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/layoutSection.m b/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/layoutSection.m new file mode 100644 index 000000000..98c7ae64c --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/layoutSection.m @@ -0,0 +1,64 @@ +% App-owned implementation for figure_studio.styleLibrary.layoutSection within the figure_studio product workflow. +function section = layoutSection() +section = labkit.app.layout.section("styleSection", "Style", { ... + labkit.app.layout.field("stylePreset", Label="Preset:", ... + Kind="choice", Choices=figure_studio.styleLibrary.presetNames(), ... + Bind="project.parameters.preset", ... + OnValueChanged=@figure_studio.styleLibrary.applyPreset), ... + stylePanner("baseFontSize", "All font:", [4 96], 1, ... + @baseFontChanged), ... + stylePanner("titleFontSize", "Title font:", [4 96], 1, ... + @titleFontChanged), ... + stylePanner("labelFontSize", "Label font:", [4 96], 1, ... + @labelFontChanged), ... + stylePanner("tickFontSize", "Tick font:", [4 96], 1, ... + @tickFontChanged), ... + stylePanner("dataLineWidth", "Plot line:", [0.25 8], 0.25, ... + @dataLineChanged), ... + stylePanner("axesLineWidth", "Axes line:", [0.25 8], 0.25, ... + @axesLineChanged), ... + stylePanner("gridAlpha", "Grid alpha:", [0 1], 0.05, ... + @gridAlphaChanged), ... + labkit.app.layout.field("gridVisible", Label="Grid:", ... + Kind="choice", Choices=["Off", "On"], ... + Bind="project.parameters.gridChoice", ... + OnValueChanged=@gridVisibleChanged)}); +end + +function node = stylePanner(id, label, limits, step, callback) +node = labkit.app.layout.slider(id, Label=label, Limits=limits, Step=step, ... + ValueDisplayFormat="%.6g", ... + Bind="project.parameters.style." + id, OnValueChanged=callback); +end + +function state = baseFontChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "baseFontSize"); +end + +function state = titleFontChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "titleFontSize"); +end + +function state = labelFontChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "labelFontSize"); +end + +function state = tickFontChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "tickFontSize"); +end + +function state = dataLineChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "dataLineWidth"); +end + +function state = axesLineChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "axesLineWidth"); +end + +function state = gridAlphaChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "gridAlpha"); +end + +function state = gridVisibleChanged(state, ~, ~) +state = figure_studio.styleLibrary.refreshStyle(state, "gridVisible"); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/refreshStyle.m b/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/refreshStyle.m new file mode 100644 index 000000000..7e705b5e6 --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/refreshStyle.m @@ -0,0 +1,98 @@ +% App-owned implementation for figure_studio.styleLibrary.refreshStyle within the figure_studio product workflow. +function state = refreshStyle(state, changedId) +%REFRESHSTYLE Reconcile one bound style edit with linked Figure Studio state. +arguments + state (1, 1) struct + changedId (1, 1) string +end +p = state.project.parameters; +p.style = sanitizeStyle(p.style); +if changedId == "baseFontSize" + p.style.titleFontSize = p.style.baseFontSize; + p.style.labelFontSize = p.style.baseFontSize; + p.style.tickFontSize = p.style.baseFontSize; + p.style = clearFontOverrides(p.style); +elseif any(changedId == ... + ["titleFontSize", "labelFontSize", "tickFontSize"]) + p.style = markFontOverride(p.style, changedId); +elseif changedId == "gridVisible" + p.style.gridVisible = p.gridChoice == "On"; +elseif changedId == "boundaryLines" + p.style.boundaryLines = p.boundaryChoice == "On"; +end +p.style = applyAspectPreset(p.style, p.aspectPreset, changedId); +state.project.parameters = p; +state.session.workflow.status = "Styled with " + p.preset + "."; +state.project.results.lastExport = []; +state.project.results.resultManifestPath = ""; +end + +function style = sanitizeStyle(style) +defaults = figure_studio.styleLibrary.styleForPreset("LabKit figure"); +names = ["baseFontSize", "titleFontSize", "labelFontSize", ... + "tickFontSize", "dataLineWidth", "axesLineWidth", ... + "gridAlpha", "canvasWidth", "canvasHeight", "exportScale"]; +for name = names + field = char(name); + style.(field) = finiteValue(style.(field), defaults.(field)); +end +style.gridAlpha = min(max(style.gridAlpha, 0), 1); +style.canvasWidth = min(max(style.canvasWidth, 400), 8000); +style.canvasHeight = min(max(style.canvasHeight, 300), 8000); +style.exportScale = min(max(style.exportScale, 1), 8); +end + +function style = clearFontOverrides(style) +style = ensureFontOverrides(style); +style.fontOverrides.title = false; +style.fontOverrides.label = false; +style.fontOverrides.tick = false; +end + +function style = markFontOverride(style, id) +style = ensureFontOverrides(style); +names = struct("titleFontSize", "title", ... + "labelFontSize", "label", "tickFontSize", "tick"); +style.fontOverrides.(names.(char(id))) = true; +end + +function style = ensureFontOverrides(style) +if ~isfield(style, "fontOverrides") || ~isstruct(style.fontOverrides) + style.fontOverrides = struct( ... + "title", false, "label", false, "tick", false); +end +end + +function style = applyAspectPreset(style, preset, changedId) +ratio = aspectRatio(preset); +if ~isfinite(ratio) + return +end +if changedId == "aspectPreset" || changedId == "canvasWidth" + style.canvasHeight = max(1, round(style.canvasWidth / ratio)); +elseif changedId == "canvasHeight" + style.canvasWidth = max(1, round(style.canvasHeight * ratio)); +end +end + +function ratio = aspectRatio(preset) +switch string(preset) + case "4:3" + ratio = 4 / 3; + case "16:9" + ratio = 16 / 9; + case "1:1" + ratio = 1; + case "3:2" + ratio = 3 / 2; + otherwise + ratio = NaN; +end +end + +function value = finiteValue(value, fallback) +value = double(value); +if isempty(value) || ~isscalar(value) || ~isfinite(value) + value = fallback; +end +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m b/apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index df5762e18..000000000 --- a/apps/labkit_core/figure_studio/+figure_studio/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,135 +0,0 @@ -% Expected caller: figure_studio.definition. Inputs are generated callbacks and -% initial app state. Output is a data-only UI 5 workbench layout. -function layout = buildWorkbenchLayout(callbacks, ~) - layout = labkit.ui.layout.workbench("figureStudio", ... - "Figure Studio", ... - "controlTabs", controlTabs(callbacks), ... - "workspace", previewWorkspace()); -end - -function tabs = controlTabs(callbacks) - tabs = { ... - figureTab(callbacks), ... - exportTab(callbacks), ... - logTab()}; -end - -function tab = figureTab(callbacks) - tab = labkit.ui.layout.tab("figures", "Figures", { ... - sourceSection(callbacks), ... - canvasSection(), ... - styleSection()}); -end - -function section = sourceSection(callbacks) - section = labkit.ui.layout.section("sourceSection", "MATLAB Figures", { ... - labkit.ui.layout.filePanel("figFiles", "FIG files", ... - "selectionMode", "single", ... - "showStatus", false, ... - "chooseLabel", "Add FIG files or scan folder", ... - "clearLabel", "Clear figures", ... - "filters", {'*.fig', 'MATLAB figure (*.fig)'}, ... - "dialogTitle", "Select MATLAB figure files or scan a folder", ... - "status", "No FIG files loaded", ... - "emptyText", "No FIG files loaded", ... - "onChoose", callbacks.figuresChosen, ... - "onRemove", callbacks.figuresRemoved, ... - "onClear", callbacks.clearFigures, ... - "onSelectionChange", callbacks.selectionChanged), ... - labkit.ui.layout.field("currentSource", "Current source", ... - "kind", "readonly", ... - "value", ""), ... - labkit.ui.layout.field("statusSummary", "Status", ... - "kind", "readonly", ... - "value", "Load a MATLAB .fig file or send a popout plot to Studio."), ... - labkit.ui.layout.group("quickExports", "", { ... - labkit.ui.layout.action("saveFig", "Save FIG", ... - callbacks.saveFig, "enabled", false), ... - labkit.ui.layout.action("exportPng", "PNG", ... - callbacks.exportPng, "enabled", false), ... - labkit.ui.layout.action("exportJpg", "JPG", ... - callbacks.exportJpg, "enabled", false), ... - labkit.ui.layout.action("exportSvg", "SVG", ... - callbacks.exportSvg, "enabled", false)})}); -end - -function section = styleSection() - section = labkit.ui.layout.section("styleSection", "Style", { ... - labkit.ui.layout.field("stylePreset", "Preset:", ... - "kind", "dropdown", ... - "items", cellstr(figure_studio.styleLibrary.presetNames()), ... - "value", "LabKit figure", ... - "Bind", "project.parameters.preset", ... - "Event", "styleChanged"), ... - stylePanner("baseFontSize", "All font:", 36, [4 96], 1), ... - stylePanner("titleFontSize", "Title font:", 36, [4 96], 1), ... - stylePanner("labelFontSize", "Label font:", 36, [4 96], 1), ... - stylePanner("tickFontSize", "Tick font:", 36, [4 96], 1), ... - stylePanner("dataLineWidth", "Plot line:", 3, [0.25 8], 0.25), ... - stylePanner("axesLineWidth", "Axes line:", 3, [0.25 8], 0.25), ... - stylePanner("gridAlpha", "Grid alpha:", 0.12, [0 1], 0.05), ... - labkit.ui.layout.field("gridVisible", "Grid:", ... - "kind", "dropdown", ... - "items", {'Off', 'On'}, ... - "value", "Off", ... - "Bind", "project.parameters.gridChoice", ... - "Event", "styleParameterChanged")}); -end - -function section = canvasSection() - section = labkit.ui.layout.section("canvasSection", "Canvas", { ... - labkit.ui.layout.field("aspectPreset", "Aspect:", ... - "kind", "dropdown", ... - "items", {'4:3', '16:9', '1:1', '3:2', 'Custom'}, ... - "value", "4:3", ... - "Bind", "project.parameters.aspectPreset", ... - "Event", "styleParameterChanged"), ... - stylePanner("canvasWidth", "Width px:", 720, [400 8000], 50), ... - stylePanner("canvasHeight", "Height px:", 540, [300 8000], 50), ... - stylePanner("exportScale", "Export x:", 2, [1 8], 0.5), ... - labkit.ui.layout.field("boundaryLines", "Boundary:", ... - "kind", "dropdown", ... - "items", {'Off', 'On'}, ... - "value", "On", ... - "Bind", "project.parameters.boundaryChoice", ... - "Event", "styleParameterChanged")}); -end - -function layoutNode = stylePanner(id, labelText, value, limits, step) - layoutNode = labkit.ui.layout.panner(id, labelText, ... - "value", value, ... - "limits", limits, ... - "step", step, ... - "Bind", "project.parameters.style." + string(id), ... - "Event", "styleParameterChanged"); -end - -function tab = exportTab(callbacks) - tab = labkit.ui.layout.tab("export", "Export", { ... - labkit.ui.layout.section("exportSection", "Export Package", { ... - labkit.ui.layout.field("outputFolder", "Output folder", ... - "kind", "readonly", ... - "value", ""), ... - labkit.ui.layout.group("exportActions", "", { ... - labkit.ui.layout.action("chooseOutputFolder", ... - "Choose output folder", callbacks.chooseOutputFolder), ... - labkit.ui.layout.action("exportCurrent", ... - "Export data + script", callbacks.exportCurrent, "enabled", false)})})}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log", ... - "value", {'Ready.'})})}); -end - -function workspace = previewWorkspace() - workspace = labkit.ui.layout.workspace("previewWorkspace", ... - "Figure Preview", { ... - labkit.ui.layout.previewArea("preview", ... - "Current Figure", ... - "layout", "single", ... - "axisIds", {'main'}, ... - "axisTitles", {'No figure loaded'})}); -end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+userInterface/cleanupPreviewResize.m b/apps/labkit_core/figure_studio/+figure_studio/+userInterface/cleanupPreviewResize.m deleted file mode 100644 index 446986f4c..000000000 --- a/apps/labkit_core/figure_studio/+figure_studio/+userInterface/cleanupPreviewResize.m +++ /dev/null @@ -1,20 +0,0 @@ -% Expected caller: the Runtime V2 resource registry. Input is one Figure -% Studio resize resource. Side effect stops/deletes its timer and listeners. -function cleanupPreviewResize(resource) - if ~isstruct(resource) - return; - end - if isfield(resource, 'timer') && ~isempty(resource.timer) && ... - isvalid(resource.timer) - stop(resource.timer); - delete(resource.timer); - end - if isfield(resource, 'listeners') - listeners = resource.listeners; - for k = 1:numel(listeners) - if ~isempty(listeners{k}) && isvalid(listeners{k}) - delete(listeners{k}); - end - end - end -end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+userInterface/installPreviewResize.m b/apps/labkit_core/figure_studio/+figure_studio/+userInterface/installPreviewResize.m deleted file mode 100644 index 1c54b1059..000000000 --- a/apps/labkit_core/figure_studio/+figure_studio/+userInterface/installPreviewResize.m +++ /dev/null @@ -1,49 +0,0 @@ -% Expected caller: Figure Studio Start hook. Input is the registered preview -% axes. Output is an ephemeral timer/listener resource for resize reflow. -function resource = installPreviewResize(ax) - resource = struct("axes", ax, "listeners", {{}}, "timer", []); - if isempty(ax) || ~isvalid(ax) - return; - end - targets = {ax, ax.Parent, ancestor(ax, 'figure')}; - listeners = cell(1, numel(targets)); - for k = 1:numel(targets) - try - listeners{k} = addlistener(targets{k}, 'Position', 'PostSet', ... - @(~, ~) reflow(ax)); - catch - listeners{k} = []; - end - end - resizeTimer = timer( ... - 'ExecutionMode', 'fixedSpacing', ... - 'Period', 0.25, ... - 'BusyMode', 'drop', ... - 'TimerFcn', @(~, ~) reflow(ax)); - start(resizeTimer); - resource.listeners = listeners; - resource.timer = resizeTimer; -end - -function reflow(ax) - if isempty(ax) || ~isvalid(ax) || ... - ~isappdata(ax, 'labkitFigureStudioPreviewStyle') - return; - end - guardKey = 'labkitFigureStudioReflowing'; - if isappdata(ax, guardKey) && getappdata(ax, guardKey) - return; - end - setappdata(ax, guardKey, true); - cleanup = onCleanup(@() clearGuard(ax, guardKey)); - style = getappdata(ax, 'labkitFigureStudioPreviewStyle'); - if ~isempty(ax.Children) - figure_studio.resultFiles.applyFigureStyle(ax, style); - end -end - -function clearGuard(ax, key) - if ~isempty(ax) && isvalid(ax) && isappdata(ax, key) - rmappdata(ax, key); - end -end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+userInterface/presentWorkbench.m b/apps/labkit_core/figure_studio/+figure_studio/+userInterface/presentWorkbench.m deleted file mode 100644 index 5487d4789..000000000 --- a/apps/labkit_core/figure_studio/+figure_studio/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,63 +0,0 @@ -% Expected caller: the LabKit V2 runtime. Input is canonical Figure Studio -% state. Output is deterministic controls and one serializable plot preview. -function view = presentWorkbench(state) - sources = state.project.inputs.sources; - parameters = state.project.parameters; - hasFigure = ~isempty(state.session.cache.plotData); - view = struct(); - view.controls.figFiles = fileSpec( ... - sources, state.session.selection.currentIndex); - view.controls.currentSource = valueSpec(state.session.cache.currentSource); - view.controls.statusSummary = valueSpec(strjoin(summaryLines(state), " | ")); - for id = ["saveFig", "exportPng", "exportJpg", ... - "exportSvg", "exportCurrent"] - view.controls.(id) = enabledSpec(hasFigure); - end - view.controls.outputFolder = valueSpec(parameters.outputFolder); - view.previews.preview.Axes.main = struct( ... - "Renderer", "studioPlot", ... - "Model", struct( ... - "plotData", state.session.cache.plotData, ... - "style", parameters.style, ... - "preview", true)); -end - -function spec = fileSpec(sources, index) - files = repmat(struct("id", "", "path", "", "status", "ready"), ... - numel(sources), 1); - paths = labkit.ui.runtime.sourcePaths(sources); - for k = 1:numel(sources) - files(k).id = string(sources(k).id); - files(k).path = paths(k); - end - selection = strings(0, 1); - if index >= 1 && index <= numel(sources) - selection = string(sources(index).id); - end - spec = struct("Files", files, "Selection", selection); -end - -function lines = summaryLines(state) - lines = strings(0, 1); - lines(end + 1, 1) = string(state.session.workflow.status); - lines(end + 1, 1) = "Style mode: " + state.project.parameters.preset; - style = state.project.parameters.style; - lines(end + 1, 1) = sprintf([ ... - 'Fonts title/label/tick: %.0f / %.0f / %.0f | ' ... - 'line widths data/axes: %.2f / %.2f'], ... - style.titleFontSize, style.labelFontSize, style.tickFontSize, ... - style.dataLineWidth, style.axesLineWidth); - if strlength(state.project.results.resultManifestPath) > 0 - lines(end + 1, 1) = "Last manifest: " + ... - state.project.results.resultManifestPath; - end -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = enabledSpec(value) - spec = struct("Enabled", logical(value)); -end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+userInterface/renderStudioPlot.m b/apps/labkit_core/figure_studio/+figure_studio/+userInterface/renderStudioPlot.m deleted file mode 100644 index fa8bc49b7..000000000 --- a/apps/labkit_core/figure_studio/+figure_studio/+userInterface/renderStudioPlot.m +++ /dev/null @@ -1,182 +0,0 @@ -% Expected caller: the registered Figure Studio V2 renderer and export helper. -% Inputs are target axes plus a serializable plot/style model. Side effects -% are limited to drawing and styling the target axes. -function renderStudioPlot(ax, model) - if isempty(model.plotData) - labkit.ui.plot.clear(ax, "ResetScale", true); - if isappdata(ax, 'labkitFigureStudioPlotData') - rmappdata(ax, 'labkitFigureStudioPlotData'); - end - if isappdata(ax, 'labkitFigureStudioPreviewStyle') - rmappdata(ax, 'labkitFigureStudioPreviewStyle'); - end - ax.Visible = 'off'; - title(ax, "No figure loaded"); - return; - end - samePlot = isappdata(ax, 'labkitFigureStudioPlotData') && ... - isequaln(getappdata(ax, 'labkitFigureStudioPlotData'), model.plotData); - if samePlot - style = model.style; - style.previewScale = logical(model.preview); - figure_studio.resultFiles.applyFigureStyle(ax, style); - if model.preview - setappdata(ax, 'labkitFigureStudioPreviewStyle', style); - end - return; - end - labkit.ui.plot.clear(ax, "ResetScale", true); - ax.Visible = 'on'; - disableDefaultAxesToolbar(ax); - applyAxesMetadata(ax, model.plotData.axes); - hold(ax, 'on'); - for k = 1:numel(model.plotData.objects) - renderObject(ax, model.plotData.objects(k)); - end - hold(ax, 'off'); - applyAxesMetadata(ax, model.plotData.axes); - if any(strlength(string({model.plotData.objects.displayName})) > 0) - legend(ax, 'show', 'Interpreter', 'none'); - end - style = model.style; - style.previewScale = logical(model.preview); - figure_studio.resultFiles.applyFigureStyle(ax, style); - if model.preview - setappdata(ax, 'labkitFigureStudioPlotData', model.plotData); - setappdata(ax, 'labkitFigureStudioPreviewStyle', style); - labkit.ui.interaction.enablePopout(ax); - end -end - -function renderObject(ax, object) - switch string(object.type) - case "line" - h = plot(ax, object.x, object.y, ... - 'DisplayName', char(object.displayName)); - applyCoordinates(h, object); - case "scatter" - h = scatter(ax, object.x, object.y, ... - 'DisplayName', char(object.displayName)); - applyCoordinates(h, object); - case "image" - h = image(ax, 'CData', object.c); - applyCoordinates(h, object); - case "surface" - h = surface(ax, object.x, object.y, object.z, object.c, ... - 'DisplayName', char(object.displayName)); - applyCoordinates(h, object); - case "patch" - h = patch(ax, 'XData', object.x, 'YData', object.y, ... - 'DisplayName', char(object.displayName)); - applyCoordinates(h, object); - case "text" - h = renderText(ax, object); - case "constantline" - h = renderConstantLine(ax, object); - otherwise - return; - end - applyStyle(h, object.style); -end - -function h = renderText(ax, object) - value = ""; - if isfield(object.metadata, 'text') - value = string(object.metadata.text); - end - position = double(object.x(:).'); - if numel(position) < 2 - position = [0 0 0]; - end - h = text(ax, position(1), position(2), char(value), ... - 'DisplayName', char(object.displayName)); - if numel(position) >= 3 - h.Position = position(1:3); - end -end - -function h = renderConstantLine(ax, object) - value = fieldValue(object.metadata, 'value', 0); - label = string(fieldValue(object.metadata, 'label', "")); - interceptAxis = lower(string(fieldValue( ... - object.metadata, 'interceptAxis', "x"))); - if interceptAxis == "y" - h = yline(ax, value, char(label), ... - 'DisplayName', char(object.displayName)); - else - h = xline(ax, value, char(label), ... - 'DisplayName', char(object.displayName)); - end -end - -function applyCoordinates(h, object) - values = {object.x, object.y, object.z, object.c, object.alpha}; - names = {'XData', 'YData', 'ZData', 'CData', 'AlphaData'}; - for k = 1:numel(names) - if ~isempty(values{k}) - safeSet(h, names{k}, values{k}); - end - end -end - -function applyStyle(h, style) - names = fieldnames(style); - for k = 1:numel(names) - safeSet(h, names{k}, style.(names{k})); - end -end - -function applyAxesMetadata(ax, meta) - title(ax, meta.title, 'Interpreter', 'none'); - xlabel(ax, meta.xLabel, 'Interpreter', 'none'); - ylabel(ax, meta.yLabel, 'Interpreter', 'none'); - zlabel(ax, meta.zLabel, 'Interpreter', 'none'); - mapping = { ... - 'XScale', 'xScale'; 'YScale', 'yScale'; 'ZScale', 'zScale'; ... - 'XDir', 'xDir'; 'YDir', 'yDir'; 'ZDir', 'zDir'; ... - 'XLim', 'xLim'; 'YLim', 'yLim'; 'ZLim', 'zLim'; ... - 'CLim', 'cLim'; 'Color', 'color'; 'Box', 'box'; ... - 'Layer', 'layer'; 'TickDir', 'tickDir'; ... - 'XGrid', 'xGrid'; 'YGrid', 'yGrid'; 'ZGrid', 'zGrid'; ... - 'XMinorGrid', 'xMinorGrid'; 'YMinorGrid', 'yMinorGrid'; ... - 'ZMinorGrid', 'zMinorGrid'; 'GridAlpha', 'gridAlpha'; ... - 'MinorGridAlpha', 'minorGridAlpha'; ... - 'DataAspectRatio', 'dataAspectRatio'; ... - 'DataAspectRatioMode', 'dataAspectRatioMode'; ... - 'PlotBoxAspectRatio', 'plotBoxAspectRatio'; ... - 'PlotBoxAspectRatioMode', 'plotBoxAspectRatioMode'; ... - 'ColorOrder', 'colorOrder'; 'FontName', 'fontName'; ... - 'FontSize', 'fontSize'; 'LineWidth', 'lineWidth'}; - for k = 1:size(mapping, 1) - if isfield(meta, mapping{k, 2}) - safeSet(ax, mapping{k, 1}, meta.(mapping{k, 2})); - end - end - if isfield(meta, 'colormap') && ~isempty(meta.colormap) - colormap(ax, meta.colormap); - end -end - -function value = fieldValue(owner, name, fallback) - value = fallback; - if isstruct(owner) && isfield(owner, name) && ~isempty(owner.(name)) - value = owner.(name); - end -end - -function safeSet(h, name, value) - try - if isprop(h, name) - h.(name) = value; - end - catch - end -end - -function disableDefaultAxesToolbar(ax) - try - ax.Toolbar.Visible = 'off'; - disableDefaultInteractivity(ax); - catch - end -end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+workbench/buildLayout.m b/apps/labkit_core/figure_studio/+figure_studio/+workbench/buildLayout.m new file mode 100644 index 000000000..482d4a78a --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+workbench/buildLayout.m @@ -0,0 +1,19 @@ +% App-owned implementation for figure_studio.workbench.buildLayout within the figure_studio product workflow. +function layout = buildLayout() +controls = {labkit.app.layout.tab("figures", "Figures", { ... + figure_studio.sourceAxes.layoutSection(), ... + figure_studio.styleLibrary.canvasLayoutSection(), ... + figure_studio.styleLibrary.layoutSection()}), ... + labkit.app.layout.tab("export", "Export", { ... + figure_studio.resultFiles.layoutSection()}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("appLog", Title="Log")})})}; +workspace = labkit.app.layout.workspace( ... + labkit.app.layout.plotArea("preview", ... + @figure_studio.sourceAxes.drawPreview, ... + Title="Current Figure", AxisIds="main", ... + AxisTitles="No figure loaded"), ... + Title="Figure Preview"); +layout = labkit.app.layout.workbench(controls, Workspace=workspace); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+workbench/present.m b/apps/labkit_core/figure_studio/+figure_studio/+workbench/present.m new file mode 100644 index 000000000..a47671cb1 --- /dev/null +++ b/apps/labkit_core/figure_studio/+figure_studio/+workbench/present.m @@ -0,0 +1,12 @@ +% App-owned implementation for figure_studio.workbench.present within the figure_studio product workflow. +function view = present(state) +hasFigure = ~isempty(state.session.cache.plotData); +view = labkit.app.view.Snapshot() ... + .text("currentSource", state.session.cache.currentSource) ... + .text("statusSummary", state.session.workflow.status) ... + .enabled("saveFig", hasFigure).enabled("exportPng", hasFigure) ... + .enabled("exportJpg", hasFigure).enabled("exportSvg", hasFigure) ... + .enabled("exportCurrent", hasFigure) ... + .text("outputFolder", state.project.parameters.outputFolder) ... + .renderPlot("preview", struct("plotData", state.session.cache.plotData, "style", state.project.parameters.style, "preview", true)); +end diff --git a/apps/labkit_core/figure_studio/+figure_studio/createSession.m b/apps/labkit_core/figure_studio/+figure_studio/createSession.m index cf27fc5c8..962084a02 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/createSession.m +++ b/apps/labkit_core/figure_studio/+figure_studio/createSession.m @@ -1,22 +1,24 @@ %CREATESESSION Rebuild Figure Studio's transient view and decoded plot cache. -% Expected caller: Runtime V2 through figure_studio.definition. Input is a +% Expected caller: App SDK through figure_studio.definition. Input is a % validated current project. Existing source decode failures propagate so a % damaged project cannot replace the live document with an empty cache. -function session = createSession(project) +function session = createSession(project, context) plotData = project.annotations.embeddedPlot; currentIndex = 0; currentSource = ""; sourceDefaultStyle = project.annotations.sourceDefaultStyle; if isempty(plotData) && ~isempty(project.inputs.sources) currentIndex = 1; - currentSource = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources(1)); + paths = context.resolveSourcePaths(project.inputs.sources); + currentSource = paths(1); [plotData, sourceDefaultStyle] = loadSource(currentSource); elseif ~isempty(plotData) currentSource = "Popout axes"; end session = struct( ... - "selection", struct("currentIndex", currentIndex), ... + "selection", struct( ... + "files", labkit.app.event.ListSelection(), ... + "currentIndex", currentIndex), ... "workflow", struct("status", initialStatus(plotData)), ... "cache", struct( ... "plotData", plotData, ... diff --git a/apps/labkit_core/figure_studio/+figure_studio/definition.m b/apps/labkit_core/figure_studio/+figure_studio/definition.m index f4ee85bff..1f9082973 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/definition.m +++ b/apps/labkit_core/figure_studio/+figure_studio/definition.m @@ -1,22 +1,16 @@ % App-owned runtime definition for labkit_FigureStudio_app. Expected caller: % the public app entrypoint. Output is a declarative LabKit app definition; % side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_FigureStudio_app", ... - "Id", "figure_studio", ... - "Title", "Figure Studio", ... - "Family", "LabKit Core", ... - "AppVersion", "0.2.9", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ... - "Project", figure_studio.projectSpec(), ... - "CreateSession", @figure_studio.createSession, ... - "Layout", @figure_studio.userInterface.buildWorkbenchLayout, ... - "Actions", figure_studio.definitionActions(), ... - "Present", @figure_studio.userInterface.presentWorkbench, ... - "Renderers", struct("studioPlot", ... - @figure_studio.userInterface.renderStudioPlot), ... - "Start", @figure_studio.initializeWorkbench, ... - "DebugSample", @figure_studio.debug.writeSamplePack); +function app = definition() + app = labkit.app.Definition( ... + Entrypoint="labkit_FigureStudio_app", AppId="figure_studio", ... + Title="Figure Studio", Family="LabKit Core", ... + AppVersion="0.3.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + ProjectSchema=figure_studio.projectSpec(), ... + CreateSession=@figure_studio.createSession, ... + OnStart=@figure_studio.initializeWorkbench, ... + Workbench=figure_studio.workbench.buildLayout(), ... + PresentWorkbench=@figure_studio.workbench.present, ... + BuildDebugSample=@figure_studio.debug.writeSamplePack); end diff --git a/apps/labkit_core/figure_studio/+figure_studio/definitionActions.m b/apps/labkit_core/figure_studio/+figure_studio/definitionActions.m deleted file mode 100644 index bc438f49d..000000000 --- a/apps/labkit_core/figure_studio/+figure_studio/definitionActions.m +++ /dev/null @@ -1,461 +0,0 @@ -% App-owned V2 actions for Figure Studio. Handlers own FIG source snapshots, -% durable styles, serializable plot models, and exports without UI access. -function actions = definitionActions() - actions = struct( ... - "figuresChosen", @onFiguresChosen, ... - "figuresRemoved", @onFiguresRemoved, ... - "clearFigures", @onClearFigures, ... - "selectionChanged", @onSelectionChanged, ... - "styleChanged", @onStyleChanged, ... - "styleParameterChanged", @onStyleParameterChanged, ... - "chooseOutputFolder", @onChooseOutputFolder, ... - "saveFig", @onSaveFig, ... - "exportPng", @onExportPng, ... - "exportJpg", @onExportJpg, ... - "exportSvg", @onExportSvg, ... - "exportCurrent", @onExportCurrent); -end - -function state = onFiguresChosen(state, event, services) - paths = services.events.paths(event, "files"); - added = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = added; - end - paths = paths(endsWith(lower(paths), ".fig")); - if isempty(paths) - state = services.workflow.log(state, "No FIG files selected."); - return; - end - sources = services.project.reconcileSources( ... - state.project.inputs.sources, paths, "matlab-figure", "figure", true); - index = selectedIndex(sources, added); - [candidate, loaded] = loadSource(state, sources, index, services); - if ~loaded - return; - end - state = candidate; - state.project.inputs.sources = sources; - state.project.annotations.embeddedPlot = []; - state.project.parameters.outputFolder = string( ... - services.dialogs.defaultOutputFolder(paths, "figure_studio", ... - state.project.parameters.outputFolder)); - state.session.workflow.status = sprintf( ... - 'Loaded %d FIG file(s).', numel(sources)); - state = invalidateExport(state); - state = services.workflow.log(state, state.session.workflow.status); -end - -function state = onFiguresRemoved(state, event, services) - sources = state.project.inputs.sources; - indices = services.events.indices(event, "removedFiles", numel(sources)); - if isempty(indices) - return; - end - sources(indices) = []; - state.project.inputs.sources = sources; - state.project.annotations.embeddedPlot = []; - if isempty(sources) - state = clearCurrentPlot(state, "No FIG files loaded."); - else - index = min(max(state.session.selection.currentIndex, 1), numel(sources)); - [state, ~] = loadSource(state, sources, index, services); - end - state = invalidateExport(state); -end - -function state = onClearFigures(state, ~, services) - state.project.inputs.sources = state.project.inputs.sources([]); - state.project.annotations.embeddedPlot = []; - state = clearCurrentPlot(state, "No FIG files loaded."); - state = invalidateExport(state); - state = services.workflow.log(state, "Cleared Figure Studio sources."); -end - -function state = onSelectionChanged(state, event, services) - sources = state.project.inputs.sources; - indices = services.events.indices(event, "selectedFiles", numel(sources)); - if isempty(indices) - return; - end - [state, ~] = loadSource(state, sources, indices(1), services); -end - -function [state, loaded] = loadSource(state, sources, index, services) - loaded = false; - sourcePath = labkit.ui.runtime.sourcePaths(sources(index)); - try - [plotData, sourceStyle] = figure_studio.sourceAxes.readFigFile( ... - sourcePath); - catch ME - state = reportFailure(state, services, "Open FIG", ME); - return; - end - state.session.selection.currentIndex = index; - state.session.cache.plotData = plotData; - state.session.cache.sourceDefaultStyle = sourceStyle; - state.session.cache.currentSource = sourcePath; - state.project.annotations.sourceDefaultStyle = sourceStyle; - state = adoptSourceStyle(state, sourceStyle); - [~, sourceName, sourceExtension] = fileparts(sourcePath); - state.session.workflow.status = "Opened " + ... - string(sourceName) + string(sourceExtension) + "."; - state = services.workflow.log(state, ... - "Opened FIG: " + state.session.cache.currentSource); - loaded = true; -end - -function state = clearCurrentPlot(state, status) - state.session.selection.currentIndex = 0; - state.session.cache.plotData = []; - state.session.cache.currentSource = ""; - state.session.workflow.status = string(status); -end - -function state = adoptSourceStyle(state, sourceStyle) - p = state.project.parameters; - if p.preset == "FIG default" - p.style = sourceStyle; - else - p.style.canvasWidth = sourceStyle.canvasWidth; - p.style.canvasHeight = sourceStyle.canvasHeight; - p.aspectPreset = "Custom"; - end - p.gridChoice = onOff(p.style.gridVisible); - p.boundaryChoice = onOff(p.style.boundaryLines); - state.project.parameters = p; -end - -function state = onStyleChanged(state, ~, services) - p = state.project.parameters; - previous = p.style; - if p.preset == "FIG default" - p.style = state.session.cache.sourceDefaultStyle; - else - p.style = figure_studio.styleLibrary.styleForPreset(p.preset); - p.style.canvasWidth = previous.canvasWidth; - p.style.canvasHeight = previous.canvasHeight; - p.style.exportScale = previous.exportScale; - end - p.gridChoice = onOff(p.style.gridVisible); - p.boundaryChoice = onOff(p.style.boundaryLines); - state.project.parameters = p; - state.session.workflow.status = "Styled with " + p.preset + "."; - state = invalidateExport(state); - state = services.workflow.log(state, ... - "Selected style mode: " + p.preset); -end - -function state = onStyleParameterChanged(state, event, ~) - p = state.project.parameters; - id = string(event.target); - p.style = sanitizeStyle(p.style); - if id == "baseFontSize" - p.style.titleFontSize = p.style.baseFontSize; - p.style.labelFontSize = p.style.baseFontSize; - p.style.tickFontSize = p.style.baseFontSize; - p.style = clearFontOverrides(p.style); - elseif any(id == ["titleFontSize", "labelFontSize", "tickFontSize"]) - p.style = markFontOverride(p.style, id); - elseif id == "gridVisible" - p.style.gridVisible = p.gridChoice == "On"; - elseif id == "boundaryLines" - p.style.boundaryLines = p.boundaryChoice == "On"; - end - p.style = applyAspectPreset(p.style, p.aspectPreset, id); - state.project.parameters = p; - state.session.workflow.status = "Styled with " + p.preset + "."; - state = invalidateExport(state); -end - -function state = onChooseOutputFolder(state, ~, services) - [folder, cancelled] = services.dialogs.outputFolder( ... - "Choose Figure Studio output folder", ... - state.project.parameters.outputFolder); - if cancelled - return; - end - state.project.parameters.outputFolder = string(folder); - state = invalidateExport(state); - state = services.workflow.log(state, "Output folder: " + string(folder)); -end - -function state = onSaveFig(state, ~, services) - if ~hasPlot(state) - services.dialogs.alert( ... - "No preview axes content is available to save.", "Figure Studio"); - return; - end - [filepath, cancelled] = services.dialogs.outputFile( ... - {'*.fig', 'MATLAB figure (*.fig)'}, "Save FIG", ... - quickExportPath(state, ".fig")); - if cancelled - return; - end - try - [fig, ~] = styledFigure(state); - cleanup = onCleanup(@() delete(fig)); - savefig(fig, char(filepath)); - [manifestPath, outputs] = writeSingleManifest( ... - state, services, filepath, "matlab-figure", ... - "application/x-matlab-figure"); - catch ME - state = reportFailure(state, services, "Save FIG", ME); - return; - end - state = recordExport(state, "fig", filepath, manifestPath, outputs); - state = services.workflow.log(state, "Saved FIG: " + string(filepath)); -end - -function state = onExportPng(state, ~, services) - state = onQuickExport(state, services, "png", "image/png"); -end - -function state = onExportJpg(state, ~, services) - state = onQuickExport(state, services, "jpg", "image/jpeg"); -end - -function state = onExportSvg(state, ~, services) - state = onQuickExport(state, services, "svg", "image/svg+xml"); -end - -function state = onQuickExport(state, services, format, mediaType) - if ~hasPlot(state) - services.dialogs.alert( ... - "No preview axes content is available to export.", "Figure Studio"); - return; - end - extension = "." + format; - [filepath, cancelled] = services.dialogs.outputFile( ... - {char("*" + extension), char(upper(format) + " file")}, ... - "Export " + upper(format), quickExportPath(state, extension)); - if cancelled - return; - end - try - [fig, ax] = styledFigure(state); - cleanup = onCleanup(@() delete(fig)); - if format == "svg" - exportgraphics(ax, filepath, 'ContentType', 'vector'); - else - exportgraphics(ax, filepath, 'Resolution', ... - max(72, round(300 * state.project.parameters.style.exportScale))); - end - [manifestPath, outputs] = writeSingleManifest( ... - state, services, filepath, format, mediaType); - catch ME - state = reportFailure(state, services, "Quick export", ME); - return; - end - state = recordExport(state, format, filepath, manifestPath, outputs); - state = services.workflow.log(state, ... - "Exported " + upper(format) + ": " + string(filepath)); -end - -function state = onExportCurrent(state, ~, services) - if ~hasPlot(state) - services.dialogs.alert( ... - "No preview axes content is available to export.", "Figure Studio"); - return; - end - folder = fullfile(state.project.parameters.outputFolder, ... - exportFolderName(state)); - try - [fig, ax] = styledFigure(state); - cleanup = onCleanup(@() delete(fig)); - payload = figure_studio.resultFiles.exportAxesPackage(ax, folder); - outputs = packageOutputs(payload, services); - spec = manifestSpec(state, outputs); - [manifestPath, ~] = services.results.writeManifest(folder, spec); - catch ME - state = reportFailure(state, services, "Export package", ME); - return; - end - state = recordExport(state, "package", folder, manifestPath, outputs); - state = services.workflow.log(state, ... - "Exported package: " + string(folder)); -end - -function [fig, ax] = styledFigure(state) - [fig, ax] = figure_studio.resultFiles.createStyledFigure( ... - state.session.cache.plotData, state.project.parameters.style); -end - -function [manifestPath, outputs] = writeSingleManifest( ... - state, services, filepath, role, mediaType) - [folder, name, extension] = fileparts(filepath); - outputs = services.results.output(role, role, ... - string(name) + string(extension), mediaType); - [manifestPath, ~] = services.results.writeManifest( ... - string(folder), manifestSpec(state, outputs)); -end - -function spec = manifestSpec(state, outputs) - plotData = state.session.cache.plotData; - spec = struct( ... - "Outputs", outputs, ... - "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, ... - "Summary", struct( ... - "objectCount", numel(plotData.objects), ... - "warningCount", numel(plotData.warnings)), ... - "ManifestName", "figure_studio.labkit.json"); -end - -function outputs = packageOutputs(payload, services) - paths = [payload.mat; payload.script; payload.readme]; - roles = ["plot-data", "recreation-script", "readme"]; - media = ["application/x-matlab-data", "text/x-matlab", "text/plain"]; - if strlength(payload.csv) > 0 - paths(end + 1, 1) = payload.csv; - roles(end + 1, 1) = "plot-data-csv"; - media(end + 1, 1) = "text/csv"; - end - outputs = services.results.emptyOutputs(); - for k = 1:numel(paths) - [~, name, extension] = fileparts(paths(k)); - outputs(end + 1, 1) = services.results.output(roles(k), roles(k), ... - string(name) + string(extension), media(k)); - end -end - -function state = recordExport(state, kind, path, manifestPath, outputs) - state.project.results.lastExport = struct( ... - "kind", string(kind), ... - "path", string(path), ... - "outputs", outputs, ... - "manifestPath", string(manifestPath)); - state.project.results.resultManifestPath = string(manifestPath); - state.session.workflow.status = "Exported " + string(kind) + "."; -end - -function state = invalidateExport(state) - state.project.results.lastExport = []; - state.project.results.resultManifestPath = ""; -end - -function tf = hasPlot(state) - tf = ~isempty(state.session.cache.plotData); -end - -function index = selectedIndex(sources, added) - index = max(1, numel(sources)); - if isempty(added) - return; - end - match = find(labkit.ui.runtime.sourcePaths(sources) == ... - string(added(end)), 1, 'first'); - if ~isempty(match) - index = match; - end -end - -function style = sanitizeStyle(style) - defaults = figure_studio.styleLibrary.styleForPreset("LabKit figure"); - names = ["baseFontSize", "titleFontSize", "labelFontSize", ... - "tickFontSize", "dataLineWidth", "axesLineWidth", ... - "gridAlpha", "canvasWidth", "canvasHeight", "exportScale"]; - for name = names - field = char(name); - style.(field) = finiteValue(style.(field), defaults.(field)); - end - style.gridAlpha = min(max(style.gridAlpha, 0), 1); - style.canvasWidth = min(max(style.canvasWidth, 400), 8000); - style.canvasHeight = min(max(style.canvasHeight, 300), 8000); - style.exportScale = min(max(style.exportScale, 1), 8); -end - -function style = clearFontOverrides(style) - style = ensureFontOverrides(style); - style.fontOverrides.title = false; - style.fontOverrides.label = false; - style.fontOverrides.tick = false; -end - -function style = markFontOverride(style, id) - style = ensureFontOverrides(style); - names = struct("titleFontSize", "title", ... - "labelFontSize", "label", "tickFontSize", "tick"); - style.fontOverrides.(names.(char(id))) = true; -end - -function style = ensureFontOverrides(style) - if ~isfield(style, 'fontOverrides') || ~isstruct(style.fontOverrides) - style.fontOverrides = struct( ... - "title", false, "label", false, "tick", false); - end -end - -function style = applyAspectPreset(style, preset, changedId) - ratio = aspectRatio(preset); - if ~isfinite(ratio) - return; - end - if changedId == "aspectPreset" || changedId == "canvasWidth" - style.canvasHeight = max(1, round(style.canvasWidth / ratio)); - elseif changedId == "canvasHeight" - style.canvasWidth = max(1, round(style.canvasHeight * ratio)); - end -end - -function ratio = aspectRatio(preset) - switch string(preset) - case "4:3" - ratio = 4 / 3; - case "16:9" - ratio = 16 / 9; - case "1:1" - ratio = 1; - case "3:2" - ratio = 3 / 2; - otherwise - ratio = NaN; - end -end - -function filepath = quickExportPath(state, extension) - stem = "figure"; - source = state.session.cache.currentSource; - if strlength(source) > 0 - [~, sourceStem] = fileparts(source); - if strlength(sourceStem) > 0 - stem = string(matlab.lang.makeValidName(char(sourceStem))); - end - end - filepath = fullfile(state.project.parameters.outputFolder, stem + extension); -end - -function name = exportFolderName(state) - source = state.session.cache.currentSource; - if strlength(source) == 0 - source = "figure"; - end - [~, stem] = fileparts(source); - if strlength(stem) == 0 - stem = "figure"; - end - name = matlab.lang.makeValidName(char(stem)) + "_" + ... - string(datestr(now, 'yyyymmdd_HHMMSS')); -end - -function value = onOff(tf) - if tf - value = "On"; - else - value = "Off"; - end -end - -function value = finiteValue(value, fallback) - value = double(value); - if isempty(value) || ~isscalar(value) || ~isfinite(value) - value = fallback; - end -end - -function state = reportFailure(state, services, context, exception) - services.diagnostics.report(context, exception); - services.dialogs.alert(exception.message, context); - state.session.workflow.status = context + " failed."; - state = services.workflow.log(state, context + ": " + exception.message); -end diff --git a/apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m b/apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m index 19efe4560..8925fbf97 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m +++ b/apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m @@ -1,44 +1,21 @@ -% Initialize request-dependent Figure Studio state after Runtime V2 has built -% the workbench. The Start hook adopts an axes handoff and registers the -% preview resize resource; it does not own runtime readiness or queueing. -function state = initializeWorkbench(state, ~, services) - if strlength(state.project.parameters.outputFolder) == 0 - state.project.parameters.outputFolder = string( ... - services.dialogs.defaultFolder("output")); - end - launch = struct("hasAxes", false); - if isstruct(services.request) && isfield(services.request, 'launch') - launch = services.request.launch; - end - if isstruct(launch) && isfield(launch, 'hasAxes') && launch.hasAxes - state.project.inputs.sources = state.project.inputs.sources([]); - state.project.annotations.embeddedPlot = launch.plotData; - state.project.annotations.sourceDefaultStyle = launch.sourceStyle; - state.session.cache.plotData = launch.plotData; - state.session.cache.sourceDefaultStyle = launch.sourceStyle; - state.session.cache.currentSource = "Popout axes"; - state = adoptSourceStyle(state, launch.sourceStyle); - state.session.workflow.status = "Received copied axes from popout."; - state = services.workflow.log(state, ... - "Received axes from popout window."); +% App-owned implementation for figure_studio.initializeWorkbench within the figure_studio product workflow. +function state = initializeWorkbench(state, callbackContext) +%INITIALIZEWORKBENCH Complete startup-only Figure Studio defaults. +arguments + state (1, 1) struct + callbackContext (1, 1) labkit.app.CallbackContext +end +if strlength(state.project.parameters.outputFolder) == 0 + folder = string(getenv("USERPROFILE")); + if strlength(folder) == 0 || ~isfolder(folder) + folder = string(getenv("HOME")); end - ax = services.previews.axes("preview", "main"); - resource = figure_studio.userInterface.installPreviewResize(ax); - services.resources.set("session", "figureStudioResize", resource, ... - @figure_studio.userInterface.cleanupPreviewResize); - if services.debug.enabled - state = services.workflow.log(state, ... - "Figure Studio debug trace enabled."); + if strlength(folder) == 0 || ~isfolder(folder) + folder = string(pwd); end + state.project.parameters.outputFolder = folder; +end +if ~isempty(state.session.cache.plotData) + callbackContext.appendStatus("Restored Figure Studio source."); end - -function state = adoptSourceStyle(state, sourceStyle) - state.project.annotations.sourceDefaultStyle = sourceStyle; - if state.project.parameters.preset == "FIG default" - state.project.parameters.style = sourceStyle; - else - state.project.parameters.style.canvasWidth = sourceStyle.canvasWidth; - state.project.parameters.style.canvasHeight = sourceStyle.canvasHeight; - state.project.parameters.aspectPreset = "Custom"; - end end diff --git a/apps/labkit_core/figure_studio/+figure_studio/launchRequest.m b/apps/labkit_core/figure_studio/+figure_studio/launchRequest.m index da9974ce3..10fd0e6db 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/launchRequest.m +++ b/apps/labkit_core/figure_studio/+figure_studio/launchRequest.m @@ -1,9 +1,8 @@ % Expected caller: labkit_FigureStudio_app. Inputs are public entrypoint % varargin values. Output separates Studio-specific launch requests from % ordinary LabKit dispatchRequest arguments. Side effects are none. -function [request, dispatchArgs] = launchRequest(args) - launch = struct("hasAxes", false, "plotData", [], "sourceStyle", []); - request = struct("launch", launch); +function [initialProject, dispatchArgs] = launchRequest(args) + initialProject = []; dispatchArgs = args; if numel(args) >= 2 && isScalarText(args{1}) && string(args{1}) == "axes" ax = args{2}; @@ -11,9 +10,15 @@ error('labkit_FigureStudio_app:InvalidAxes', ... 'Figure Studio axes launch requires a valid axes handle.'); end - request.launch.hasAxes = true; - request.launch.plotData = figure_studio.resultFiles.extractAxesData(ax); - request.launch.sourceStyle = figure_studio.sourceAxes.sourceStyle(ax); + schema = figure_studio.projectSpec(); + initialProject = schema.Create(); + sourceStyle = figure_studio.sourceAxes.sourceStyle(ax); + initialProject.inputs.sources = initialProject.inputs.sources([]); + initialProject.annotations.embeddedPlot = ... + figure_studio.resultFiles.extractAxesData(ax); + initialProject.annotations.sourceDefaultStyle = sourceStyle; + initialProject.parameters.style = sourceStyle; + initialProject.parameters.aspectPreset = "Custom"; dispatchArgs = {}; end end diff --git a/apps/labkit_core/figure_studio/+figure_studio/projectSpec.m b/apps/labkit_core/figure_studio/+figure_studio/projectSpec.m index c381c32a9..6fb49e407 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/projectSpec.m +++ b/apps/labkit_core/figure_studio/+figure_studio/projectSpec.m @@ -2,11 +2,8 @@ % Expected caller: figure_studio.definition. Output owns the current payload % version plus local create and validation callbacks. Side effects are none. function spec = projectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", []); + spec = labkit.app.project.Schema(Version=1, ... + Create=@createProject, Validate=@validateProject); end function project = createProject() @@ -14,7 +11,7 @@ style = figure_studio.styleLibrary.styleForPreset(preset); project = struct(); project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + struct([])); project.parameters = struct( ... "preset", preset, ... "style", style, ... diff --git a/apps/labkit_core/figure_studio/labkit_FigureStudio_app.m b/apps/labkit_core/figure_studio/labkit_FigureStudio_app.m index d5a029391..fe119356b 100644 --- a/apps/labkit_core/figure_studio/labkit_FigureStudio_app.m +++ b/apps/labkit_core/figure_studio/labkit_FigureStudio_app.m @@ -1,7 +1,14 @@ function varargout = labkit_FigureStudio_app(varargin) %LABKIT_FIGURESTUDIO_APP Inspect, style, and export MATLAB figures. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @figure_studio.definition, "RequestAdapter", ... - @figure_studio.launchRequest, varargin{:}); + [initialProject, launchArguments] = ... + figure_studio.launchRequest(varargin); + if isempty(initialProject) + [varargout{1:nargout}] = ... + figure_studio.definition().launch(launchArguments{:}); + else + [varargout{1:nargout}] = ... + figure_studio.definition().launch( ... + InitialProject=initialProject); + end end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/actionSection.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/actionSection.m new file mode 100644 index 000000000..150c0a115 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/actionSection.m @@ -0,0 +1,15 @@ +% App-owned implementation for nerve_response_analysis.analysisRun.actionSection within the nerve_response_analysis product workflow. +function section = actionSection() +%ACTIONSECTION Declare analysis and reset actions. +actions = labkit.app.layout.group("analysisActions", { ... + labkit.app.layout.button("runAnalysis", ... + "Analyze Filtered Files", ... + @nerve_response_analysis.analysisRun.runSession, ... + Tooltip="Analyze the protocol-selected filtered recordings and calculate the configured nerve-response measurements."), ... + labkit.app.layout.button("resetWorkflow", "Reset", ... + @nerve_response_analysis.workbench.resetWorkflow, ... + Tooltip="Clear loaded protocol/session state and restore nerve-response analysis defaults.")}, ... + Layout="horizontal"); +section = labkit.app.layout.section( ... + "actionsSection", "Actions", {actions}); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/changePreviewMode.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/changePreviewMode.m new file mode 100644 index 000000000..e57d4a128 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/changePreviewMode.m @@ -0,0 +1,7 @@ +% App-owned implementation for nerve_response_analysis.analysisRun.changePreviewMode within the nerve_response_analysis product workflow. +function state = changePreviewMode(state, value, ~) +value = string(value); +if isscalar(value) && any(value == ["Counts", "Issues"]) + state.session.view.previewMode = value; +end +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/detailLines.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/detailLines.m new file mode 100644 index 000000000..eee5007ae --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/detailLines.m @@ -0,0 +1,65 @@ +% Expected caller: nerve_response_analysis.workbench.present. +% or buildWorkbenchLayout. Input is an app presentation +% state. Output is compact status-panel text. +function lines = detailLines(S) +%DETAILLINES Build nerve-response analysis detail lines. + + if nargin == 0 || isempty(S) + lines = {'No filter record has been analyzed.'}; + return; + end + + lines = { + char(string(fieldOrDefault(S, "statusMessage", ... + "No filter record has been analyzed."))) + "Max recordings: " + char(maxText(fieldOrDefault(S, "maxRecordings", 0))) + "Max duration: " + char(maxText(fieldOrDefault(S, "maxDurationSec", 0))) + " s" + "Output folder: " + char(displayPath(fieldOrDefault(S, "outputFolder", "")))}; + + analysis = fieldOrDefault(S, "analysis", struct()); + if isstruct(analysis) + lines{end+1} = sprintf("Events: %d", ... + tableHeight(fieldOrDefault(analysis, "events", table()))); + lines{end+1} = sprintf("Metrics: %d", ... + tableHeight(fieldOrDefault(analysis, "metrics", table()))); + issues = fieldOrDefault(analysis, "issues", table()); + if istable(issues) && height(issues) > 0 + lines{end+1} = sprintf("First issue: %s", char(issues.message(1))); + end + end + lines = cellstr(string(lines)); +end + +function value = fieldOrDefault(S, fieldName, defaultValue) + value = defaultValue; + if isstruct(S) && isfield(S, fieldName) + value = S.(fieldName); + end +end + +function n = tableHeight(value) + if istable(value) + n = height(value); + else + n = 0; + end +end + +function text = maxText(value) + value = double(value); + if value <= 0 || ~isfinite(value) + text = "all"; + else + text = string(value); + end +end + +function text = displayPath(pathValue) + pathValue = string(pathValue); + if strlength(pathValue) == 0 + text = "Not selected"; + return; + end + [~, base, ext] = fileparts(char(pathValue)); + text = string([base ext]); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/drawPreview.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/drawPreview.m new file mode 100644 index 000000000..764e427c9 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/drawPreview.m @@ -0,0 +1,110 @@ +% Expected caller: the App SDK runtime registered renderer. Inputs are a UI axes +% and a presentation model. Side effect is redrawing analysis counts/issues. +function drawPreview(axesById, S) +%DRAWANALYSISPREVIEW Draw a compact nerve-response analysis preview. + + ax = axesById.main; + resetAxes(ax); + analysis = fieldOrDefault(S, "analysis", struct()); + mode = string(fieldOrDefault(S, "previewMode", "Counts")); + + if ~isstruct(analysis) || ~isfield(analysis, "events") + text(ax, 0.5, 0.5, "Choose a filter record, then analyze", ... + "HorizontalAlignment", "center", ... + "VerticalAlignment", "middle", ... + "Color", [0.30 0.30 0.30]); + xlim(ax, [0 1]); + ylim(ax, [0 1]); + title(ax, "Analysis Counts"); + return; + end + + if mode == "Issues" + drawIssuePreview(ax, fieldOrDefault(analysis, "issues", table())); + else + drawCountPreview(ax, analysis); + end +end + +function drawCountPreview(ax, analysis) + counts = [ + tableHeight(fieldOrDefault(analysis, "events", table())) + tableHeight(fieldOrDefault(analysis, "trains", table())) + tableHeight(fieldOrDefault(analysis, "metrics", table())) + tableHeight(fieldOrDefault(analysis, "issues", table()))]; + b = bar(ax, counts, "FaceColor", "flat", "EdgeColor", "none"); + b.CData = [0.18 0.36 0.58; 0.16 0.48 0.34; ... + 0.55 0.36 0.62; 0.72 0.26 0.18]; + ax.XTick = 1:4; + ax.XTickLabel = {'Events', 'Trains', 'Metrics', 'Issues'}; + ylabel(ax, "Rows"); + title(ax, "Analysis Counts"); + styleAxes(ax); +end + +function drawIssuePreview(ax, issues) + if ~istable(issues) || height(issues) == 0 + text(ax, 0.5, 0.5, "No analysis issues", ... + "HorizontalAlignment", "center", ... + "VerticalAlignment", "middle", ... + "Color", [0.22 0.42 0.28]); + xlim(ax, [0 1]); + ylim(ax, [0 1]); + title(ax, "Analysis Issues"); + return; + end + + if ismember("severity", issues.Properties.VariableNames) + severity = string(issues.severity); + else + severity = repmat("issue", height(issues), 1); + end + labels = unique(severity, "stable"); + counts = zeros(numel(labels), 1); + for k = 1:numel(labels) + counts(k) = sum(severity == labels(k)); + end + b = bar(ax, counts, "FaceColor", [0.72 0.26 0.18], ... + "EdgeColor", "none"); + if isprop(b, "BaseLine") + b.BaseLine.LineStyle = "-"; + end + ax.XTick = 1:numel(labels); + ax.XTickLabel = cellstr(labels); + ylabel(ax, "Issues"); + title(ax, "Analysis Issues"); + styleAxes(ax); +end + +function n = tableHeight(value) + if istable(value) + n = height(value); + else + n = 0; + end +end + +function resetAxes(ax) + delete(allchild(ax)); + cla(ax); + ax.Color = "white"; + ax.Box = "off"; + ax.XGrid = "on"; + ax.YGrid = "on"; + ax.GridAlpha = 0.18; +end + +function styleAxes(ax) + ax.Color = "white"; + ax.Box = "off"; + ax.YGrid = "on"; + ax.XGrid = "off"; + ax.GridAlpha = 0.18; +end + +function value = fieldOrDefault(S, fieldName, defaultValue) + value = defaultValue; + if isstruct(S) && isfield(S, fieldName) + value = S.(fieldName); + end +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/optionsSection.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/optionsSection.m new file mode 100644 index 000000000..cc5566dba --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/optionsSection.m @@ -0,0 +1,16 @@ +% App-owned implementation for nerve_response_analysis.analysisRun.optionsSection within the nerve_response_analysis product workflow. +function section = optionsSection() +%OPTIONSSECTION Declare analysis limits and current workflow status. +section = labkit.app.layout.section( ... + "optionsSection", "Analysis Options", { ... + labkit.app.layout.field("maxRecordings", ... + Label="Max recordings", Kind="numeric", ... + Bind="project.parameters.maxRecordings", ... + OnValueChanged=@nerve_response_analysis.analysisRun.updateOptions), ... + labkit.app.layout.field("maxDurationSec", ... + Label="Max duration", Kind="numeric", ... + Bind="project.parameters.maxDurationSec", ... + OnValueChanged=@nerve_response_analysis.analysisRun.updateOptions), ... + labkit.app.layout.field("statusField", Label="Status", ... + Kind="readonly", Value="No filter record selected.")}); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/presentationModel.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/presentationModel.m new file mode 100644 index 000000000..c752356f8 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/presentationModel.m @@ -0,0 +1,15 @@ +% App-owned implementation for nerve_response_analysis.analysisRun.presentationModel within the nerve_response_analysis product workflow. +function model = presentationModel(state) +analysis = state.session.cache.analysis; +hasAnalysis = isstruct(analysis) && ~isempty(fieldnames(analysis)); +model = struct( ... + "sessionFile", state.session.cache.filterPath, ... + "protocolFile", state.session.cache.protocolPath, ... + "outputFolder", state.session.workflow.outputFolder, ... + "maxRecordings", state.project.parameters.maxRecordings, ... + "maxDurationSec", state.project.parameters.maxDurationSec, ... + "analysis", analysis, "hasAnalysis", hasAnalysis, ... + "previewMode", state.session.view.previewMode, ... + "statusMessage", state.session.workflow.statusMessage, ... + "lastAction", state.session.workflow.lastAction); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/runSession.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/runSession.m new file mode 100644 index 000000000..8fe2c5944 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/runSession.m @@ -0,0 +1,29 @@ +% App-owned implementation for nerve_response_analysis.analysisRun.runSession within the nerve_response_analysis product workflow. +function state = runSession(state, context) +if isempty(state.session.cache.filterRecord) + context.alert("Select a filter record first.", "Nerve response analysis"); + return; +end +options = struct(); +if state.project.parameters.maxRecordings > 0 + options.maxRecordings = state.project.parameters.maxRecordings; +end +if state.project.parameters.maxDurationSec > 0 + options.maxDurationSec = state.project.parameters.maxDurationSec; +end +try + state.session.cache.analysis = nerve_response_analysis.analysisRun.analyzeSession( ... + state.session.cache.filterRecord, state.session.cache.protocol, options); +catch ME + context.reportError("Analyze nerve response", ME); + state.session.cache.analysis = []; + state.session.workflow.statusMessage = string(ME.message); + state.session.workflow.lastAction = "Analysis failed"; + return; +end +state.project.results.lastExport = []; +state.session.workflow.statusMessage = sprintf("Analyzed %d recording(s).", ... + state.session.cache.analysis.analyzedCount); +state.session.workflow.lastAction = "Analyzed filter record"; +context.appendStatus(state.session.workflow.statusMessage); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/summaryTableData.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/summaryTableData.m new file mode 100644 index 000000000..8b0acee9f --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/summaryTableData.m @@ -0,0 +1,51 @@ +% Expected caller: nerve_response_analysis.workbench.present. +% or buildWorkbenchLayout. Input is an app presentation +% state. Output is a two-column result-table cell array. +function data = summaryTableData(S) +%SUMMARYTABLEDATA Build nerve-response analysis summary rows. + + if nargin == 0 || isempty(S) + S = struct(); + end + analysis = fieldOrDefault(S, "analysis", struct()); + + data = { + 'Filter', displayPath(fieldOrDefault(S, "sessionFile", "")); + 'Protocol', displayPath(fieldOrDefault(S, "protocolFile", "")); + 'Recordings', displayNumber(fieldOrDefault(analysis, "recordingCount", 0)); + 'Analyzed', displayNumber(fieldOrDefault(analysis, "analyzedCount", 0)); + 'Events', displayNumber(tableHeight(fieldOrDefault(analysis, "events", table()))); + 'Trains', displayNumber(tableHeight(fieldOrDefault(analysis, "trains", table()))); + 'Metrics', displayNumber(tableHeight(fieldOrDefault(analysis, "metrics", table()))); + 'Issues', displayNumber(tableHeight(fieldOrDefault(analysis, "issues", table()))); + 'Last action', char(string(fieldOrDefault(S, "lastAction", "Ready")))}; +end + +function value = fieldOrDefault(S, fieldName, defaultValue) + value = defaultValue; + if isstruct(S) && isfield(S, fieldName) + value = S.(fieldName); + end +end + +function n = tableHeight(value) + if istable(value) + n = height(value); + else + n = 0; + end +end + +function text = displayNumber(value) + text = char(string(double(value))); +end + +function text = displayPath(pathValue) + pathValue = string(pathValue); + if strlength(pathValue) == 0 + text = 'Not selected'; + return; + end + [~, base, ext] = fileparts(char(pathValue)); + text = char(string([base ext])); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/updateOptions.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/updateOptions.m new file mode 100644 index 000000000..2c5057ee6 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/updateOptions.m @@ -0,0 +1,24 @@ +% App-owned implementation for nerve_response_analysis.analysisRun.updateOptions within the nerve_response_analysis product workflow. +function state = updateOptions(state, ~, ~) +state.project.parameters.maxRecordings = finiteNonnegative( ... + state.project.parameters.maxRecordings); +state.project.parameters.maxDurationSec = finiteNonnegative( ... + state.project.parameters.maxDurationSec); +state.project.results.lastExport = []; +if isstruct(state.session.cache.analysis) && ... + ~isempty(state.session.cache.analysis) && ... + ~isempty(fieldnames(state.session.cache.analysis)) + state.session.workflow.statusMessage = ... + "Analysis options changed. Analyze session to refresh."; +end +state.session.cache.analysis = []; +state.session.workflow.lastAction = "Updated analysis options"; +end + +function value = finiteNonnegative(value) +if ~isnumeric(value) || ~isscalar(value) || ~isfinite(value) + value = 0; +else + value = max(0, double(value)); +end +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+debug/writeSamplePack.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+debug/writeSamplePack.m index b4a8aec69..f8b926cc8 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+debug/writeSamplePack.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+debug/writeSamplePack.m @@ -1,45 +1,53 @@ -% Expected caller: nerve_response_analysis.definitionActions startup action and unit -% tests. Input is a LabKit debug context. Output is a deterministic synthetic +% Expected caller: nerve_response_analysis.definition and unit tests. +% tests. Input is a bounded diagnostic SampleContext. Output is a deterministic synthetic % filter-record/protocol/RHS sample pack. Side effects: writes anonymous debug -% files and records a session manifest when available. -function pack = writeSamplePack(debugLog) +% files beneath the diagnostic root. +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Nerve Response Analysis debug files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "nerve_response_analysis"); - sampleFolder = fullfile(char(folders.sampleFolder), "nerve_response_analysis"); - rhsFolder = fullfile(sampleFolder, "rhs"); - ensureFolder(rhsFolder); - - rhsA = string(fullfile(rhsFolder, "nerve_response_recording_001_debug.rhs")); - rhsB = string(fullfile(rhsFolder, "nerve_response_recording_002_debug.rhs")); - malformedRhs = string(fullfile(rhsFolder, "nerve_response_malformed_debug.rhs")); - filterRecordPath = string(fullfile(sampleFolder, "nerve_response_filter_record_debug.json")); - protocolPath = string(fullfile(sampleFolder, "nerve_response_protocol_debug.json")); - malformedFilterPath = string(fullfile(sampleFolder, "nerve_response_malformed_filter_record_debug.json")); + rhsA = sampleContext.samplePath( ... + "nerve_response_analysis/rhs/recording_001.rhs"); + rhsB = sampleContext.samplePath( ... + "nerve_response_analysis/rhs/recording_002.rhs"); + malformedRhs = sampleContext.samplePath( ... + "nerve_response_analysis/rhs/malformed.rhs"); + filterRecordPath = sampleContext.samplePath( ... + "nerve_response_analysis/filter_record.json"); + protocolPath = sampleContext.samplePath( ... + "nerve_response_analysis/protocol.json"); + malformedFilterPath = sampleContext.samplePath( ... + "nerve_response_analysis/malformed_filter_record.json"); channels = ["PrimaryChannel", "ReferenceChannel", "ReturnChannel", "AuxChannel"]; writeSyntheticRhs(rhsA, channels, [210 980 1710], 18); writeSyntheticRhs(rhsB, channels, [300 1280], 16); writeTextFile(malformedRhs, ["not an rhs binary"; "boundary=malformed rhs"]); writeProtocol(protocolPath); - writeFilterRecord(filterRecordPath, [rhsA; rhsB], string(rhsFolder)); + writeFilterRecord(filterRecordPath, [rhsA; rhsB], ... + string(fileparts(rhsA))); writeTextFile(malformedFilterPath, ["{""recordings"": "; " ""not complete"""]); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_NerveResponseAnalysis_app", ... - "description", "Anonymous nerve-response RHS/filter-record boundary pack for debug launch.", ... - "sampleFolder", string(sampleFolder), ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", struct( ... - "filterRecordJson", filterRecordPath, ... - "protocolJson", protocolPath, ... - "rhsFiles", [rhsA; rhsB]), ... - "boundaryFiles", struct( ... - "malformedFilterRecordJson", malformedFilterPath, ... - "malformedRhs", malformedRhs)); - recordManifest(debugLog, manifest); - pack = manifest; + project = nerve_response_analysis.projectSpec().Create(); + project.inputs.sources = [ ... + sampleContext.sourceRecord( ... + "filterRecord", "filterRecord", filterRecordPath, true), ... + sampleContext.sourceRecord( ... + "protocol", "protocol", protocolPath, false)]; + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-nerve-response", ... + InitialProject=project, Artifacts={ ... + sampleContext.artifact( ... + "filterRecord", "filterRecord", filterRecordPath), ... + sampleContext.artifact("protocol", "protocol", protocolPath), ... + sampleContext.artifact("recording1", "recording", rhsA), ... + sampleContext.artifact("recording2", "recording", rhsB), ... + sampleContext.artifact("malformedFilter", "boundaryInput", ... + malformedFilterPath, Expectation="rejects"), ... + sampleContext.artifact("malformedRhs", "boundaryInput", ... + malformedRhs, Expectation="rejects")}); end function writeFilterRecord(filepath, rhsFiles, rootFolder) @@ -171,30 +179,6 @@ function writeQString(fid, value) fwrite(fid, uint16(value), "uint16"); end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeJson(filepath, payload) fid = fopen(char(filepath), "w"); if fid < 0 @@ -214,9 +198,3 @@ function writeTextFile(filepath, lines) cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s\n", lines); end - -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/actionSection.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/actionSection.m new file mode 100644 index 000000000..d50ad3b2d --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/actionSection.m @@ -0,0 +1,9 @@ +% App-owned implementation for nerve_response_analysis.resultFiles.actionSection within the nerve_response_analysis product workflow. +function section = actionSection() +%ACTIONSECTION Declare the analysis export action. +section = labkit.app.layout.section( ... + "exportActionsSection", "Export Actions", { ... + labkit.app.layout.button("exportAnalysis", "Export Analysis", ... + @nerve_response_analysis.resultFiles.exportAnalysis, ... + Tooltip="Export the latest nerve-response measurements, summaries, and provenance for the analyzed files.")}); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/chooseOutputFolder.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/chooseOutputFolder.m new file mode 100644 index 000000000..19a441f56 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/chooseOutputFolder.m @@ -0,0 +1,18 @@ +% App-owned implementation for nerve_response_analysis.resultFiles.chooseOutputFolder within the nerve_response_analysis product workflow. +function state = chooseOutputFolder(state, context) +%CHOOSEOUTPUTFOLDER Select the explicit analysis destination. +startPath = state.session.workflow.outputFolder; +if strlength(startPath) == 0 + startPath = pwd; +end +choice = context.chooseOutputFolder(startPath); +if choice.Cancelled + state.session.workflow.lastAction = ... + "Output folder selection cancelled"; + return; +end +state.session.workflow.outputFolder = string(choice.Value); +state.session.workflow.lastAction = "Selected output folder"; +context.appendStatus( ... + "Selected output folder: " + string(choice.Value)); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/clearOutputFolder.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/clearOutputFolder.m new file mode 100644 index 000000000..5c5fb31dc --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/clearOutputFolder.m @@ -0,0 +1,7 @@ +% App-owned implementation for nerve_response_analysis.resultFiles.clearOutputFolder within the nerve_response_analysis product workflow. +function state = clearOutputFolder(state, context) +%CLEAROUTPUTFOLDER Clear the current analysis destination. +state.session.workflow.outputFolder = ""; +state.session.workflow.lastAction = "Cleared output folder"; +context.appendStatus("Cleared output folder."); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/exportAnalysis.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/exportAnalysis.m new file mode 100644 index 000000000..6af12a4e6 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/exportAnalysis.m @@ -0,0 +1,54 @@ +% App-owned implementation for nerve_response_analysis.resultFiles.exportAnalysis within the nerve_response_analysis product workflow. +function state = exportAnalysis(state, context) +analysis = state.session.cache.analysis; +if ~isstruct(analysis) || isempty(fieldnames(analysis)) + context.alert("Run analysis before exporting.", "Export analysis"); + return; +end +folder = state.session.workflow.outputFolder; +if strlength(folder) == 0 + chosen = context.chooseOutputFolder(pwd); + if chosen.Cancelled + return; + end + folder = string(chosen.Value); + state.session.workflow.outputFolder = folder; +end +if exist(folder, "dir") ~= 7 + mkdir(folder); +end +name = "nerve_response_analysis.json"; +path = fullfile(folder, name); +nerve_response_analysis.resultFiles.writeAnalysisJson(analysis, path); +output = labkit.app.result.File("nerveResponseAnalysis", "primary", name, ... + MediaType="application/json"); +package = labkit.app.result.Package(Outputs={output}, ... + Inputs=struct("sources", state.project.inputs.sources), ... + Parameters=state.project.parameters, ... + Summary=analysisSummary(analysis), ... + ManifestName="nerve_response_analysis.labkit.json"); +written = context.writeResultPackage(folder, package); +state.project.results.lastExport = struct("jsonPath", string(path), ... + "manifestPath", string(written.Value)); +state.session.workflow.statusMessage = ... + "Exported nerve-response analysis."; +state.session.workflow.lastAction = "Exported analysis"; +context.appendStatus("Exported analysis: " + string(path)); +end + +function summary = analysisSummary(analysis) +summary = struct( ... + "recordingCount", double(analysis.recordingCount), ... + "analyzedCount", double(analysis.analyzedCount), ... + "eventCount", tableHeight(analysis, "events"), ... + "trainCount", tableHeight(analysis, "trains"), ... + "metricCount", tableHeight(analysis, "metrics"), ... + "issueCount", tableHeight(analysis, "issues")); +end + +function count = tableHeight(analysis, field) +count = 0; +if isfield(analysis, field) && istable(analysis.(field)) + count = height(analysis.(field)); +end +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/outputSection.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/outputSection.m new file mode 100644 index 000000000..ca430f586 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/outputSection.m @@ -0,0 +1,16 @@ +% App-owned implementation for nerve_response_analysis.resultFiles.outputSection within the nerve_response_analysis product workflow. +function section = outputSection() +%OUTPUTSECTION Declare the explicit analysis destination. +actions = labkit.app.layout.group("outputFolderActions", { ... + labkit.app.layout.button("chooseOutputFolder", "Choose output", ... + @nerve_response_analysis.resultFiles.chooseOutputFolder, ... + Tooltip="Choose the destination for nerve-response measurements and analysis evidence."), ... + labkit.app.layout.button("clearOutputFolder", "Clear output", ... + @nerve_response_analysis.resultFiles.clearOutputFolder, ... + Tooltip="Clear the selected export destination without deleting existing result files.")}, ... + Layout="horizontal"); +section = labkit.app.layout.section("outputsSection", "Outputs", { ... + labkit.app.layout.field("outputFolder", Label="Output folder", ... + Kind="readonly", Value="No output folder selected"), ... + actions}); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/writeAnalysisJson.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/writeAnalysisJson.m index 97f1be8ca..523329df0 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/writeAnalysisJson.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/writeAnalysisJson.m @@ -1,4 +1,4 @@ -% Expected caller: nerve_response_analysis.definitionActions. Input is an analysis struct +% Expected caller: nerve_response_analysis.resultFiles.exportAnalysis. Input is an analysis struct % and target JSON path. Output is the written path. Side effect is one file. function outputPath = writeAnalysisJson(analysis, outputPath) %WRITEANALYSISJSON Write nerve-response analysis JSON. diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/filterChanged.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/filterChanged.m new file mode 100644 index 000000000..15afef136 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/filterChanged.m @@ -0,0 +1,17 @@ +% App-owned implementation for nerve_response_analysis.sourceFiles.filterChanged within the nerve_response_analysis product workflow. +function state = filterChanged(state, selection, context) +%FILTERCHANGED Record reader-facing filter selection state after rebuild. +arguments + state (1, 1) struct + selection (1, 1) labkit.app.event.ListSelection + context (1, 1) labkit.app.CallbackContext +end +if isempty(selection.Indices) + state.session.workflow.lastAction = "Cleared filter record"; + return; +end +state.session.workflow.statusMessage = "Filter record selected."; +state.session.workflow.lastAction = "Selected filter record"; +context.appendStatus( ... + "Selected filter record: " + state.session.cache.filterPath); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/filterSection.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/filterSection.m new file mode 100644 index 000000000..3997176fe --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/filterSection.m @@ -0,0 +1,16 @@ +% App-owned implementation for nerve_response_analysis.sourceFiles.filterSection within the nerve_response_analysis product workflow. +function section = filterSection() +%FILTERSECTION Declare the required filter-record source. +file = labkit.app.layout.fileList("sessionFile", ... + Label="Filter JSON", ... + Filters=["*.json", "Filter JSON"], ... + ChooseLabel="Choose filter", EmptyText="No filter selected", ... + ChooseTooltip="Choose the filter-record JSON that identifies the accepted recording set for nerve-response analysis.", ... + SelectionMode="single", MaxFiles=1, ... + Bind="project.inputs.sources", ... + SourceRole="filterRecord", SourceIdPrefix="filterRecord", ... + Required=true, ... + OnSelectionChanged=@nerve_response_analysis.sourceFiles.filterChanged); +section = labkit.app.layout.section( ... + "inputSection", "Filter Record", {file}); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/protocolChanged.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/protocolChanged.m new file mode 100644 index 000000000..4d4c65bd6 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/protocolChanged.m @@ -0,0 +1,16 @@ +% App-owned implementation for nerve_response_analysis.sourceFiles.protocolChanged within the nerve_response_analysis product workflow. +function state = protocolChanged(state, selection, context) +%PROTOCOLCHANGED Record reader-facing optional protocol selection state. +arguments + state (1, 1) struct + selection (1, 1) labkit.app.event.ListSelection + context (1, 1) labkit.app.CallbackContext +end +if isempty(selection.Indices) + state.session.workflow.lastAction = "Cleared protocol"; + return; +end +state.session.workflow.lastAction = "Selected protocol"; +context.appendStatus( ... + "Selected protocol: " + state.session.cache.protocolPath); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/protocolSection.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/protocolSection.m new file mode 100644 index 000000000..b9757f5d3 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/protocolSection.m @@ -0,0 +1,15 @@ +% App-owned implementation for nerve_response_analysis.sourceFiles.protocolSection within the nerve_response_analysis product workflow. +function section = protocolSection() +%PROTOCOLSECTION Declare the optional protocol source. +file = labkit.app.layout.fileList("protocolFile", ... + Label="Protocol JSON", ... + Filters=["*.json", "Protocol JSON"], ... + ChooseLabel="Choose JSON", EmptyText="No protocol selected", ... + ChooseTooltip="Choose the protocol JSON defining channel roles, analysis windows, and response interpretation.", ... + SelectionMode="single", MaxFiles=1, ... + Bind="project.inputs.sources", ... + SourceRole="protocol", SourceIdPrefix="protocol", Required=false, ... + OnSelectionChanged=@nerve_response_analysis.sourceFiles.protocolChanged); +section = labkit.app.layout.section( ... + "protocolSection", "Protocol (recommended)", {file}); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 8fc0796b9..000000000 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,138 +0,0 @@ -% Expected caller: nerve_response_analysis.definition. Inputs are app callback -% handles. Output is a data-only UI 5 workbench layout. -function layout = buildWorkbenchLayout(callbacks) -%BUILDWORKBENCHLAYOUT Build the Nerve Response Analysis UI layout. - - layout = labkit.ui.layout.workbench("nerve_response_analysis", ... - "Nerve Response Analysis", ... - "controlTabs", {setupTab(callbacks), protocolTab(callbacks), ... - reviewTab(), exportTab(callbacks), logTab()}, ... - "workspace", previewWorkspace(callbacks)); -end - -function tab = setupTab(callbacks) - tab = labkit.ui.layout.tab("setup", "Setup", { ... - inputSection(callbacks), ... - optionsSection(), ... - actionsSection(callbacks)}); -end - -function tab = protocolTab(callbacks) - tab = labkit.ui.layout.tab("protocol", "Protocol", { ... - protocolSection(callbacks)}); -end - -function tab = reviewTab() - tab = labkit.ui.layout.tab("review", "Review", { ... - summarySection(), ... - detailsSection()}); -end - -function tab = exportTab(callbacks) - tab = labkit.ui.layout.tab("export", "Export", { ... - outputsSection(callbacks), ... - exportActionsSection(callbacks)}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("logPanel", "Log", ... - "value", {"Ready."})})}); -end - -function section = inputSection(callbacks) - section = labkit.ui.layout.section("inputSection", "Filter Record", { ... - labkit.ui.layout.filePanel("sessionFile", "Filter JSON", ... - "mode", "single", ... - "filters", {'*.json', 'Filter JSON'}, ... - "chooseLabel", "Choose filter", ... - "status", "No filter selected", ... - "onChoose", callbacks.sessionChosen)}); -end - -function section = protocolSection(callbacks) - section = labkit.ui.layout.section("protocolSection", ... - "Protocol (recommended)", { ... - labkit.ui.layout.filePanel("protocolFile", "Protocol JSON", ... - "mode", "single", ... - "filters", {'*.json', 'Protocol JSON'}, ... - "chooseLabel", "Choose JSON", ... - "status", "No protocol selected", ... - "onChoose", callbacks.protocolChosen)}); -end - -function section = optionsSection() - section = labkit.ui.layout.section("optionsSection", "Analysis Options", { ... - labkit.ui.layout.field("maxRecordings", "Max recordings", ... - "kind", "number", ... - "value", 0, ... - "Bind", "project.parameters.maxRecordings", ... - "Event", "settingChanged"), ... - labkit.ui.layout.field("maxDurationSec", "Max duration", ... - "kind", "number", ... - "value", 0, ... - "unit", "s", ... - "Bind", "project.parameters.maxDurationSec", ... - "Event", "settingChanged"), ... - labkit.ui.layout.field("statusField", "Status", ... - "kind", "readonly", ... - "value", "No filter selected.")}); -end - -function section = outputsSection(callbacks) - section = labkit.ui.layout.section("outputsSection", "Outputs", { ... - labkit.ui.layout.field("outputFolder", "Output folder", ... - "kind", "readonly", ... - "value", "No output folder selected"), ... - labkit.ui.layout.group("outputFolderActions", "", { ... - labkit.ui.layout.action("chooseOutputFolder", ... - "Choose output", callbacks.outputFolderChosen), ... - labkit.ui.layout.action("clearOutputFolder", ... - "Clear output", callbacks.outputFolderCleared)})}); -end - -function section = actionsSection(callbacks) - section = labkit.ui.layout.section("actionsSection", "Actions", { ... - labkit.ui.layout.group("analysisActions", "", { ... - labkit.ui.layout.action("runAnalysis", ... - "Analyze Filtered Files", ... - callbacks.runAnalysis, ... - "priority", "primary"), ... - labkit.ui.layout.action("resetWorkflow", ... - "Reset", ... - callbacks.resetWorkflow)})}); -end - -function section = exportActionsSection(callbacks) - section = labkit.ui.layout.section("exportActionsSection", "Export Actions", { ... - labkit.ui.layout.group("exportActions", "", { ... - labkit.ui.layout.action("exportAnalysis", ... - "Export Analysis", ... - callbacks.exportAnalysis, ... - "priority", "primary")})}); -end - -function section = summarySection() - section = labkit.ui.layout.section("summarySection", "Summary", { ... - labkit.ui.layout.resultTable("summaryTable", ... - "Nerve Response Analysis Summary", ... - "columns", {"Field", "Value"}, ... - "data", nerve_response_analysis.userInterface.summaryTableData(struct()))}); -end - -function section = detailsSection() - section = labkit.ui.layout.section("detailsSection", "Details", { ... - labkit.ui.layout.statusPanel("details", "Details", ... - "value", nerve_response_analysis.userInterface.detailLines(struct()))}); -end - -function workspace = previewWorkspace(callbacks) - workspace = labkit.ui.layout.workspace("workspace", "Analysis", { ... - labkit.ui.layout.previewArea("preview", "Preview", ... - "layout", "single", ... - "axisIds", {"main"}, ... - "axisTitles", {"Analysis Counts"}, ... - "viewModes", {"Counts", "Issues"}, ... - "onModeChange", callbacks.previewModeChanged)}); -end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/detailLines.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/detailLines.m deleted file mode 100644 index 5b3302f1a..000000000 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/detailLines.m +++ /dev/null @@ -1,65 +0,0 @@ -% Expected caller: nerve_response_analysis.userInterface.presentWorkbench -% or buildWorkbenchLayout. Input is an app presentation -% state. Output is compact status-panel text. -function lines = detailLines(S) -%DETAILLINES Build nerve-response analysis detail lines. - - if nargin == 0 || isempty(S) - lines = {'No filter record has been analyzed.'}; - return; - end - - lines = { - char(string(fieldOrDefault(S, "statusMessage", ... - "No filter record has been analyzed."))) - "Max recordings: " + char(maxText(fieldOrDefault(S, "maxRecordings", 0))) - "Max duration: " + char(maxText(fieldOrDefault(S, "maxDurationSec", 0))) + " s" - "Output folder: " + char(displayPath(fieldOrDefault(S, "outputFolder", "")))}; - - analysis = fieldOrDefault(S, "analysis", struct()); - if isstruct(analysis) - lines{end+1} = sprintf("Events: %d", ... - tableHeight(fieldOrDefault(analysis, "events", table()))); - lines{end+1} = sprintf("Metrics: %d", ... - tableHeight(fieldOrDefault(analysis, "metrics", table()))); - issues = fieldOrDefault(analysis, "issues", table()); - if istable(issues) && height(issues) > 0 - lines{end+1} = sprintf("First issue: %s", char(issues.message(1))); - end - end - lines = cellstr(string(lines)); -end - -function value = fieldOrDefault(S, fieldName, defaultValue) - value = defaultValue; - if isstruct(S) && isfield(S, fieldName) - value = S.(fieldName); - end -end - -function n = tableHeight(value) - if istable(value) - n = height(value); - else - n = 0; - end -end - -function text = maxText(value) - value = double(value); - if value <= 0 || ~isfinite(value) - text = "all"; - else - text = string(value); - end -end - -function text = displayPath(pathValue) - pathValue = string(pathValue); - if strlength(pathValue) == 0 - text = "Not selected"; - return; - end - [~, base, ext] = fileparts(char(pathValue)); - text = string([base ext]); -end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/drawAnalysisPreview.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/drawAnalysisPreview.m deleted file mode 100644 index 80250005f..000000000 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/drawAnalysisPreview.m +++ /dev/null @@ -1,108 +0,0 @@ -% Expected caller: the Runtime V2 registered renderer. Inputs are a UI axes -% and a presentation model. Side effect is redrawing analysis counts/issues. -function drawAnalysisPreview(ax, S) -%DRAWANALYSISPREVIEW Draw a compact nerve-response analysis preview. - - resetAxes(ax); - analysis = fieldOrDefault(S, "analysis", struct()); - mode = string(fieldOrDefault(S, "previewMode", "Counts")); - - if ~isstruct(analysis) || ~isfield(analysis, "events") - text(ax, 0.5, 0.5, "Choose a filter record, then analyze", ... - "HorizontalAlignment", "center", ... - "VerticalAlignment", "middle", ... - "Color", [0.30 0.30 0.30]); - xlim(ax, [0 1]); - ylim(ax, [0 1]); - title(ax, "Analysis Counts"); - return; - end - - if mode == "Issues" - drawIssuePreview(ax, fieldOrDefault(analysis, "issues", table())); - else - drawCountPreview(ax, analysis); - end -end - -function drawCountPreview(ax, analysis) - counts = [ - tableHeight(fieldOrDefault(analysis, "events", table())) - tableHeight(fieldOrDefault(analysis, "trains", table())) - tableHeight(fieldOrDefault(analysis, "metrics", table())) - tableHeight(fieldOrDefault(analysis, "issues", table()))]; - b = bar(ax, counts, "FaceColor", "flat", "EdgeColor", "none"); - b.CData = [0.18 0.36 0.58; 0.16 0.48 0.34; ... - 0.55 0.36 0.62; 0.72 0.26 0.18]; - ax.XTick = 1:4; - ax.XTickLabel = {'Events', 'Trains', 'Metrics', 'Issues'}; - ylabel(ax, "Rows"); - title(ax, "Analysis Counts"); - styleAxes(ax); -end - -function drawIssuePreview(ax, issues) - if ~istable(issues) || height(issues) == 0 - text(ax, 0.5, 0.5, "No analysis issues", ... - "HorizontalAlignment", "center", ... - "VerticalAlignment", "middle", ... - "Color", [0.22 0.42 0.28]); - xlim(ax, [0 1]); - ylim(ax, [0 1]); - title(ax, "Analysis Issues"); - return; - end - - if ismember("severity", issues.Properties.VariableNames) - severity = string(issues.severity); - else - severity = repmat("issue", height(issues), 1); - end - labels = unique(severity, "stable"); - counts = zeros(numel(labels), 1); - for k = 1:numel(labels) - counts(k) = sum(severity == labels(k)); - end - b = bar(ax, counts, "FaceColor", [0.72 0.26 0.18], ... - "EdgeColor", "none"); - if isprop(b, "BaseLine") - b.BaseLine.LineStyle = "-"; - end - ax.XTick = 1:numel(labels); - ax.XTickLabel = cellstr(labels); - ylabel(ax, "Issues"); - title(ax, "Analysis Issues"); - styleAxes(ax); -end - -function n = tableHeight(value) - if istable(value) - n = height(value); - else - n = 0; - end -end - -function resetAxes(ax) - labkit.ui.plot.clear(ax, "ResetScale", true); - ax.Color = "white"; - ax.Box = "off"; - ax.XGrid = "on"; - ax.YGrid = "on"; - ax.GridAlpha = 0.18; -end - -function styleAxes(ax) - ax.Color = "white"; - ax.Box = "off"; - ax.YGrid = "on"; - ax.XGrid = "off"; - ax.GridAlpha = 0.18; -end - -function value = fieldOrDefault(S, fieldName, defaultValue) - value = defaultValue; - if isstruct(S) && isfield(S, fieldName) - value = S.(fieldName); - end -end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m deleted file mode 100644 index 69595e6d0..000000000 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,82 +0,0 @@ -% Expected caller: Runtime V2. Input is canonical Nerve Response Analysis -% state. Output is deterministic controls, summary, details, log, and preview -% model without UI registry access or side effects. -function view = presentWorkbench(state) - model = presentationModel(state); - filterPath = sourcePath(state, "filterRecord"); - protocolPath = sourcePath(state, "protocol"); - hasFilter = strlength(filterPath) > 0; - hasAnalysis = isstruct(state.session.cache.analysis) && ... - ~isempty(fieldnames(state.session.cache.analysis)); - hasOutput = strlength(state.session.workflow.outputFolder) > 0; - - view = struct(); - view.controls.sessionFile = sourcePanel( ... - filterPath, "No filter selected"); - view.controls.protocolFile = sourcePanel( ... - protocolPath, "No protocol selected"); - view.controls.outputFolder = valueSpec(outputFolderText( ... - state.session.workflow.outputFolder)); - view.controls.runAnalysis = enabledSpec(hasFilter); - view.controls.exportAnalysis = enabledSpec(hasAnalysis && hasOutput); - view.controls.statusField = valueSpec(state.session.workflow.statusMessage); - view.controls.summaryTable = tableSpec( ... - nerve_response_analysis.userInterface.summaryTableData(model)); - view.controls.details = valueSpec( ... - nerve_response_analysis.userInterface.detailLines(model)); - view.controls.preview = valueSpec(state.session.view.previewMode); - view.previews.preview = struct( ... - "Renderer", "analysisPreview", "Model", model); -end - -function model = presentationModel(state) - model = struct( ... - "sessionFile", sourcePath(state, "filterRecord"), ... - "protocolFile", sourcePath(state, "protocol"), ... - "outputFolder", state.session.workflow.outputFolder, ... - "maxRecordings", state.project.parameters.maxRecordings, ... - "maxDurationSec", state.project.parameters.maxDurationSec, ... - "previewMode", state.session.view.previewMode, ... - "analysis", state.session.cache.analysis, ... - "statusMessage", state.session.workflow.statusMessage, ... - "lastAction", state.session.workflow.lastAction); -end - -function spec = sourcePanel(filepath, emptyStatus) - files = struct("id", {}, "path", {}, "status", {}); - if strlength(filepath) > 0 - files = struct("id", "item1", ... - "path", filepath, "status", ""); - end - status = string(emptyStatus); - if ~isempty(files) - status = string(files.path); - end - spec = struct("Files", files, "Status", status); -end - -function filepath = sourcePath(state, id) - filepath = labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources, id); -end - -function text = outputFolderText(filepath) - text = "No output folder selected"; - if strlength(string(filepath)) > 0 - text = string(filepath); - end -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = enabledSpec(value) - spec = struct("Enabled", logical(value)); -end - -function spec = tableSpec(value) - spec = struct(); - spec.Data = value; -end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/summaryTableData.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/summaryTableData.m deleted file mode 100644 index 2065c544f..000000000 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+userInterface/summaryTableData.m +++ /dev/null @@ -1,51 +0,0 @@ -% Expected caller: nerve_response_analysis.userInterface.presentWorkbench -% or buildWorkbenchLayout. Input is an app presentation -% state. Output is a two-column result-table cell array. -function data = summaryTableData(S) -%SUMMARYTABLEDATA Build nerve-response analysis summary rows. - - if nargin == 0 || isempty(S) - S = struct(); - end - analysis = fieldOrDefault(S, "analysis", struct()); - - data = { - 'Filter', displayPath(fieldOrDefault(S, "sessionFile", "")); - 'Protocol', displayPath(fieldOrDefault(S, "protocolFile", "")); - 'Recordings', displayNumber(fieldOrDefault(analysis, "recordingCount", 0)); - 'Analyzed', displayNumber(fieldOrDefault(analysis, "analyzedCount", 0)); - 'Events', displayNumber(tableHeight(fieldOrDefault(analysis, "events", table()))); - 'Trains', displayNumber(tableHeight(fieldOrDefault(analysis, "trains", table()))); - 'Metrics', displayNumber(tableHeight(fieldOrDefault(analysis, "metrics", table()))); - 'Issues', displayNumber(tableHeight(fieldOrDefault(analysis, "issues", table()))); - 'Last action', char(string(fieldOrDefault(S, "lastAction", "Ready")))}; -end - -function value = fieldOrDefault(S, fieldName, defaultValue) - value = defaultValue; - if isstruct(S) && isfield(S, fieldName) - value = S.(fieldName); - end -end - -function n = tableHeight(value) - if istable(value) - n = height(value); - else - n = 0; - end -end - -function text = displayNumber(value) - text = char(string(double(value))); -end - -function text = displayPath(pathValue) - pathValue = string(pathValue); - if strlength(pathValue) == 0 - text = 'Not selected'; - return; - end - [~, base, ext] = fileparts(char(pathValue)); - text = char(string([base ext])); -end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/buildLayout.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/buildLayout.m new file mode 100644 index 000000000..2a9450d36 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/buildLayout.m @@ -0,0 +1,28 @@ +% App-owned implementation for nerve_response_analysis.workbench.buildLayout within the nerve_response_analysis product workflow. +function layout = buildLayout() +%BUILDLAYOUT Compose the five Nerve Response Analysis workflow pages. +controls = { ... + labkit.app.layout.tab("setup", "Setup", { ... + nerve_response_analysis.sourceFiles.filterSection(), ... + nerve_response_analysis.analysisRun.optionsSection(), ... + nerve_response_analysis.analysisRun.actionSection()}), ... + labkit.app.layout.tab("protocol", "Protocol", { ... + nerve_response_analysis.sourceFiles.protocolSection()}), ... + labkit.app.layout.tab("review", "Review", { ... + labkit.app.layout.dataTable("summaryTable", ... + Title="Nerve Response Analysis Summary", ... + Columns=["Field", "Value"]), ... + labkit.app.layout.statusPanel("details", Title="Details")}), ... + labkit.app.layout.tab("export", "Export", { ... + nerve_response_analysis.resultFiles.outputSection(), ... + nerve_response_analysis.resultFiles.actionSection()}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.logPanel("logPanel")})}; +preview = labkit.app.layout.plotArea("preview", ... + @nerve_response_analysis.analysisRun.drawPreview, ... + Title="Preview", AxisTitles="Analysis Counts", ... + ViewModes=["Counts", "Issues"], ... + OnValueChanged=@nerve_response_analysis.analysisRun.changePreviewMode); +layout = labkit.app.layout.workbench(controls, ... + Workspace=labkit.app.layout.workspace(preview, Title="Analysis")); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/present.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/present.m new file mode 100644 index 000000000..7641d1184 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/present.m @@ -0,0 +1,18 @@ +% App-owned implementation for nerve_response_analysis.workbench.present within the nerve_response_analysis product workflow. +function view = present(state) +model = nerve_response_analysis.analysisRun.presentationModel(state); +folder = state.session.workflow.outputFolder; +folderText = "No output folder selected"; +if strlength(folder) > 0 + folderText = folder; +end +view = labkit.app.view.Snapshot() ... + .enabled("runAnalysis", ~isempty(state.session.cache.filterRecord)) ... + .enabled("exportAnalysis", ... + model.hasAnalysis && strlength(folder) > 0) ... + .text("outputFolder", folderText) ... + .tableData("summaryTable", nerve_response_analysis.analysisRun.summaryTableData(model)) ... + .text("details", strjoin(string(nerve_response_analysis.analysisRun.detailLines(model)), newline)) ... + .text("statusField", string(state.session.workflow.statusMessage)) ... + .renderPlot("preview", model); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/resetWorkflow.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/resetWorkflow.m new file mode 100644 index 000000000..b21ba0bc2 --- /dev/null +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/resetWorkflow.m @@ -0,0 +1,9 @@ +% App-owned implementation for nerve_response_analysis.workbench.resetWorkflow within the nerve_response_analysis product workflow. +function state = resetWorkflow(state, context) +%RESETWORKFLOW Restore a new Nerve Response Analysis project and session. +schema = nerve_response_analysis.projectSpec(); +state.project = schema.Create(); +state.session = nerve_response_analysis.createSession( ... + state.project, context); +context.appendStatus("Reset Nerve Response Analysis state."); +end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/createSession.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/createSession.m index 424402316..bbe862c79 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/createSession.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/createSession.m @@ -1,16 +1,15 @@ % Rebuild parsed filter/protocol JSON, output-folder convenience, preview % state, and workflow messages from one validated project. -function session = createSession(project) - paths = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, ["filterRecord", "protocol"]); - filterPath = paths(1); - protocolPath = paths(2); +function session = createSession(project, context) + filterPath = pathForRole( ... + project.inputs.sources, "filterRecord", context); + protocolPath = pathForRole( ... + project.inputs.sources, "protocol", context); filterRecord = loadRequiredJson(filterPath); protocol = loadOptionalJson(protocolPath); outputFolder = ""; if strlength(filterPath) > 0 - outputFolder = string(labkit.ui.runtime.defaultOutputFolder( ... - filterPath, "nerve_response_analysis", "")); + outputFolder = defaultOutputFolder(filterPath); end status = "No filter record selected."; if strlength(filterPath) > 0 @@ -26,6 +25,33 @@ "protocol", protocol, "analysis", [])); end +function folder = defaultOutputFolder(filepath) + parent = string(fileparts(filepath)); + folder = string(fullfile(parent, "nerve_response_analysis")); + if exist(folder, "dir") == 7 + return; + end + [created, ~, ~] = mkdir(folder); + if ~created + folder = parent; + end +end + +function filepath = pathForRole(sources, role, context) + filepath = ""; + if isempty(sources) + return; + end + match = find(string({sources.role}) == role, 1); + if isempty(match) + return; + end + paths = context.resolveSourcePaths(sources(match)); + if ~isempty(paths) + filepath = paths(1); + end +end + function value = loadRequiredJson(filepath) value = []; if strlength(filepath) > 0 diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m index 15be97dd3..b3b693b06 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m @@ -1,23 +1,17 @@ % App-owned runtime definition for labkit_NerveResponseAnalysis_app. % Expected caller: the public app entrypoint. Output is a declarative LabKit % app definition; side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_NerveResponseAnalysis_app", ... - "Id", "nerve_response_analysis", ... - "Title", "Nerve Response Analysis", ... - "DisplayName", "Nerve Response Analysis", ... - "Family", "Neurophysiology", ... - "AppVersion", "1.4.8", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "rhs", ">=1.0 <2"), ... - "Project", nerve_response_analysis.projectSpec(), ... - "CreateSession", @nerve_response_analysis.createSession, ... - "Layout", @nerve_response_analysis.userInterface.buildWorkbenchLayout, ... - "Actions", nerve_response_analysis.definitionActions(), ... - "Present", @nerve_response_analysis.userInterface.presentWorkbench, ... - "Renderers", struct("analysisPreview", ... - @nerve_response_analysis.userInterface.drawAnalysisPreview), ... - "DebugSample", @nerve_response_analysis.debug.writeSamplePack); +function app = definition() + app = labkit.app.Definition( ... + Entrypoint="labkit_NerveResponseAnalysis_app", ... + AppId="nerve_response_analysis", ... + Title="Nerve Response Analysis", ... + DisplayName="Nerve Response Analysis", Family="Neurophysiology", ... + AppVersion="1.5.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2", "rhs", ">=1.0 <2"), ... + ProjectSchema=nerve_response_analysis.projectSpec(), ... + CreateSession=@nerve_response_analysis.createSession, ... + Workbench=nerve_response_analysis.workbench.buildLayout(), ... + PresentWorkbench=@nerve_response_analysis.workbench.present, ... + BuildDebugSample=@nerve_response_analysis.debug.writeSamplePack); end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m deleted file mode 100644 index 9eb64e721..000000000 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definitionActions.m +++ /dev/null @@ -1,251 +0,0 @@ -% App-owned Runtime V2 action table for Nerve Response Analysis. Handlers own -% source selection, analysis, preview selection, and export without UI reads, -% figure callbacks, closure state, or app-managed lifecycle plumbing. -function actions = definitionActions() - actions = struct( ... - "sessionChosen", @onSessionChosen, ... - "protocolChosen", @onProtocolChosen, ... - "outputFolderChosen", @onOutputFolderChosen, ... - "outputFolderCleared", @onOutputFolderCleared, ... - "settingChanged", @onSettingChanged, ... - "previewModeChanged", @onPreviewModeChanged, ... - "runAnalysis", @onRunAnalysis, ... - "exportAnalysis", @onExportAnalysis, ... - "resetWorkflow", @onResetWorkflow); -end - -function state = onSessionChosen(state, event, services) - filepath = firstEventPath(event, services); - if strlength(filepath) == 0 - return; - end - try - filterRecord = jsondecode(fileread(char(filepath))); - catch ME - services.diagnostics.report("Filter record load failed", ME); - state.session.workflow.statusMessage = string(ME.message); - state = services.workflow.log(state, ... - "Filter record load failed: " + string(ME.message)); - return; - end - state.project.inputs.sources = services.project.upsertSource( ... - state.project.inputs.sources, ... - "filterRecord", "filterRecord", filepath, true); - state.project.results.lastExport = []; - state.session.cache.filterPath = filepath; - state.session.cache.filterRecord = filterRecord; - state.session.cache.analysis = []; - state.session.workflow.outputFolder = string( ... - services.dialogs.defaultOutputFolder( ... - filepath, "nerve_response_analysis", ... - state.session.workflow.outputFolder)); - state.session.workflow.statusMessage = "Filter record selected."; - state.session.workflow.lastAction = "Selected filter record"; - state = services.workflow.log(state, ... - "Selected filter record: " + displayPath(filepath)); -end - -function state = onProtocolChosen(state, event, services) - filepath = firstEventPath(event, services); - if strlength(filepath) == 0 - return; - end - protocol = loadOptionalProtocol(filepath); - state.project.inputs.sources = services.project.upsertSource( ... - state.project.inputs.sources, ... - "protocol", "protocol", filepath, false); - state.project.results.lastExport = []; - state.session.cache.protocolPath = filepath; - state.session.cache.protocol = protocol; - if hasAnalysis(state) - state.session.cache.analysis = []; - state.session.workflow.statusMessage = ... - "Protocol selected. Analyze session to refresh."; - end - state.session.workflow.lastAction = "Selected protocol"; - state = services.workflow.log(state, ... - "Selected protocol: " + displayPath(filepath)); -end - -function state = onOutputFolderChosen(state, ~, services) - [folder, cancelled] = services.dialogs.outputFolder( ... - "Select analysis output folder", state.session.workflow.outputFolder); - if cancelled - state.session.workflow.lastAction = ... - "Output folder selection cancelled"; - return; - end - state.session.workflow.outputFolder = string(folder); - state.session.workflow.lastAction = "Selected output folder"; - state = services.workflow.log(state, ... - "Selected output folder: " + displayPath(folder)); -end - -function state = onOutputFolderCleared(state, ~, ~) - state.session.workflow.outputFolder = ""; - state.session.workflow.lastAction = "Cleared output folder"; -end - -function state = onSettingChanged(state, ~, ~) - state.project.parameters.maxRecordings = finiteNonnegativeScalar( ... - state.project.parameters.maxRecordings, 0); - state.project.parameters.maxDurationSec = finiteNonnegativeScalar( ... - state.project.parameters.maxDurationSec, 0); - state.project.results.lastExport = []; - if hasAnalysis(state) - state.session.cache.analysis = []; - state.session.workflow.statusMessage = ... - "Analysis options changed. Analyze session to refresh."; - end - state.session.workflow.lastAction = "Updated analysis options"; -end - -function state = onPreviewModeChanged(state, event, ~) - value = string(event.value); - if isscalar(value) && any(value == ["Counts", "Issues"]) - state.session.view.previewMode = value; - end -end - -function state = onRunAnalysis(state, ~, services) - if strlength(sourcePath(state, "filterRecord")) == 0 - state.session.workflow.statusMessage = "Select a filter record first."; - return; - end - try - opts = analysisOptions(state.project.parameters); - state.session.cache.analysis = ... - nerve_response_analysis.analysisRun.analyzeSession( ... - state.session.cache.filterRecord, state.session.cache.protocol, opts); - catch ME - services.diagnostics.report("Analysis failed", ME); - state.session.cache.analysis = []; - state.session.workflow.statusMessage = string(ME.message); - state.session.workflow.lastAction = "Analysis failed"; - state = services.workflow.log(state, ... - "Analysis failed: " + state.session.workflow.statusMessage); - return; - end - state.project.results.lastExport = []; - state.session.workflow.statusMessage = sprintf( ... - "Analyzed %d recording(s).", ... - state.session.cache.analysis.analyzedCount); - state.session.workflow.lastAction = "Analyzed filter record"; - state = services.workflow.log(state, state.session.workflow.statusMessage); -end - -function state = onExportAnalysis(state, ~, services) - if ~hasAnalysis(state) - state.session.workflow.statusMessage = "Run analysis before exporting."; - return; - end - folder = state.session.workflow.outputFolder; - if strlength(folder) == 0 - state.session.workflow.statusMessage = "Select an output folder first."; - return; - end - outputName = "nerve_response_analysis.json"; - outputPath = fullfile(char(folder), outputName); - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end - nerve_response_analysis.resultFiles.writeAnalysisJson( ... - state.session.cache.analysis, outputPath); - output = services.results.output( ... - "nerveResponseAnalysis", "primary", outputName, "application/json"); - spec = struct( ... - "Outputs", output, ... - "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, ... - "Summary", analysisSummary(state.session.cache.analysis), ... - "ManifestName", "nerve_response_analysis.labkit.json"); - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.lastExport = struct( ... - "jsonPath", string(outputPath), ... - "manifestPath", string(manifestPath)); - state.session.workflow.statusMessage = ... - "Exported nerve-response analysis."; - state.session.workflow.lastAction = "Exported analysis"; - state = services.workflow.log(state, ... - "Exported analysis JSON: " + displayPath(outputPath)); -end - -function state = onResetWorkflow(~, ~, services) - state = services.project.newState(); - state = services.workflow.log(state, ... - "Reset Nerve Response Analysis state."); -end - -function opts = analysisOptions(parameters) - opts = struct(); - if parameters.maxRecordings > 0 - opts.maxRecordings = parameters.maxRecordings; - end - if parameters.maxDurationSec > 0 - opts.maxDurationSec = parameters.maxDurationSec; - end -end - -function summary = analysisSummary(analysis) - summary = struct( ... - "recordingCount", double(analysis.recordingCount), ... - "analyzedCount", double(analysis.analyzedCount), ... - "eventCount", tableHeight(analysis, "events"), ... - "trainCount", tableHeight(analysis, "trains"), ... - "metricCount", tableHeight(analysis, "metrics"), ... - "issueCount", tableHeight(analysis, "issues")); -end - -function count = tableHeight(analysis, fieldName) - count = 0; - if isfield(analysis, fieldName) && istable(analysis.(fieldName)) - count = height(analysis.(fieldName)); - end -end - -function filepath = sourcePath(state, id) - filepath = labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources, id); -end - -function protocol = loadOptionalProtocol(filepath) - try - protocol = jsondecode(fileread(char(filepath))); - catch - protocol = struct(); - end -end - -function filepath = firstEventPath(event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - filepath = ""; - if ~isempty(paths) - filepath = paths(1); - end -end - -function tf = hasAnalysis(state) - value = state.session.cache.analysis; - tf = isstruct(value) && isscalar(value) && ~isempty(fieldnames(value)); -end - -function value = finiteNonnegativeScalar(value, fallback) - value = double(value); - if isempty(value) || ~isscalar(value) || ~isfinite(value) - value = fallback; - return; - end - value = max(0, value); -end - -function text = displayPath(pathValue) - pathValue = string(pathValue); - [~, base, extension] = fileparts(char(pathValue)); - text = string(base) + string(extension); - if strlength(text) == 0 - text = pathValue; - end -end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/projectSpec.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/projectSpec.m index 9ecce52de..ae281ca86 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/projectSpec.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/projectSpec.m @@ -1,18 +1,15 @@ -% App-owned durable Nerve Response Analysis contract. Runtime V2 applies the +% App-owned durable Nerve Response Analysis contract. App SDK runtime applies the % one source-schema migration, then validates fixed filter/protocol source % identities, run limits, and compact export state. function spec = projectSpec() - spec = struct( ... - "Version", 2, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", @migrateProject); + spec = labkit.app.project.Schema(Version=2, ... + Create=@createProject, Validate=@validateProject, Migrate=@migrateProject); end function project = createProject() project = struct(); project.inputs = struct( ... - "sources", labkit.ui.runtime.emptySourceRecords()); + "sources", struct([])); project.parameters = struct( ... "maxRecordings", 0, ... "maxDurationSec", 0); @@ -56,14 +53,18 @@ function validateSources(sources) if isempty(sources) return; end - ids = string({sources.id}); roles = string({sources.role}); allowed = ["filterRecord", "protocol"]; - assert(all(ismember(ids, allowed)) && numel(unique(ids)) == numel(ids) && ... - all(ids == roles), ... + ids = string({sources.id}); + assert(all(strlength(ids) > 0) && ... + numel(unique(ids)) == numel(ids) && ... + all(ismember(roles, allowed)) && ... + all(ids == roles | startsWith(ids, roles + "-")) && ... + nnz(roles == "filterRecord") <= 1 && ... + nnz(roles == "protocol") <= 1, ... 'nerve_response_analysis:InvalidProject', ... - ['Nerve Response Analysis sources must use unique filterRecord or ' ... - 'protocol IDs with matching roles.']); + ['Nerve Response Analysis sources must use unique IDs and at most ' ... + 'one filterRecord and protocol role.']); end function tf = finiteNonnegative(value) diff --git a/apps/neurophysiology/nerve_response_analysis/labkit_NerveResponseAnalysis_app.m b/apps/neurophysiology/nerve_response_analysis/labkit_NerveResponseAnalysis_app.m index d1da3825b..ef6d153f5 100644 --- a/apps/neurophysiology/nerve_response_analysis/labkit_NerveResponseAnalysis_app.m +++ b/apps/neurophysiology/nerve_response_analysis/labkit_NerveResponseAnalysis_app.m @@ -1,6 +1,5 @@ function varargout = labkit_NerveResponseAnalysis_app(varargin) %LABKIT_NERVERESPONSEANALYSIS_APP Launch the Nerve Response Analysis app. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @nerve_response_analysis.definition, varargin{:}); + [varargout{1:nargout}] = nerve_response_analysis.definition().launch(varargin{:}); end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/actionSection.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/actionSection.m new file mode 100644 index 000000000..3b82dd5a8 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/actionSection.m @@ -0,0 +1,13 @@ +% App-owned implementation for response_review_stats.analysisRun.actionSection within the response_review_stats product workflow. +function section = actionSection() +%ACTIONSECTION Declare metric refresh and whole-workflow reset actions. +section = labkit.app.layout.section("actionsSection", "Actions", { ... + labkit.app.layout.group("statsActions", { ... + labkit.app.layout.button("loadMetrics", "Refresh Metrics", ... + @response_review_stats.analysisRun.refreshMetrics, ... + Tooltip="Re-read the selected response-review metrics and rebuild their descriptive statistical summary."), ... + labkit.app.layout.button("resetWorkflow", "Reset", ... + @response_review_stats.workbench.resetWorkflow, ... + Tooltip="Clear loaded response metrics and restore the review-statistics workflow defaults.")}, ... + Layout="horizontal")}); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/changePreviewMode.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/changePreviewMode.m new file mode 100644 index 000000000..8406fa35b --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/changePreviewMode.m @@ -0,0 +1,7 @@ +% App-owned implementation for response_review_stats.analysisRun.changePreviewMode within the response_review_stats product workflow. +function state = changePreviewMode(state, value, ~) +value = string(value); +if isscalar(value) && any(value == ["Summary", "Aligned"]) + state.session.view.previewMode = value; +end +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/detailLines.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/detailLines.m similarity index 100% rename from apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/detailLines.m rename to apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/detailLines.m diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/drawPreview.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/drawPreview.m new file mode 100644 index 000000000..395120826 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/drawPreview.m @@ -0,0 +1,129 @@ +% Expected caller: App SDK runtime registered renderer. Inputs are a UI axes handle +% and presentation model. Side effect is redrawing summary/aligned waveforms. +function drawPreview(axesById, S) +%DRAWSTATSPREVIEW Draw response-review metrics or aligned segments. + + ax = axesById.main; + resetAxes(ax); + mode = string(fieldOrDefault(S, "previewMode", "Summary")); + aligned = fieldOrDefault(S, "aligned", []); + + if mode == "Aligned" && hasAlignedValues(aligned) + drawAligned(ax, aligned); + return; + end + drawSummary(ax, fieldOrDefault(S, "summary", table()), ... + fieldOrDefault(S, "metrics", table())); +end + +function drawAligned(ax, aligned) + timeSec = double(aligned.timeSec(:)); + values = double(aligned.values); + names = string(aligned.segmentNames(:)); + nTraces = size(values, 2); + if nTraces == 0 + drawEmpty(ax, "No aligned segments"); + return; + end + + colors = lines(max(nTraces, 1)); + hold(ax, "on"); + for k = 1:nTraces + plot(ax, timeSec, values(:, k), "LineWidth", 1.0, ... + "Color", colors(k, :), ... + "DisplayName", char(labelFor(names, k))); + end + hold(ax, "off"); + xlabel(ax, "Time (s)"); + ylabel(ax, "Signal"); + title(ax, "Aligned Segments"); + if nTraces <= 8 + legend(ax, "Location", "northeastoutside", "Interpreter", "none"); + end + styleWaveAxes(ax); +end + +function drawSummary(ax, summary, metrics) + if ~istable(summary) || height(summary) == 0 || ... + (~istable(metrics) || height(metrics) == 0) + drawEmpty(ax, "Choose an analysis JSON or segment CSV"); + title(ax, "Metric Summary"); + return; + end + + values = double(summary.MeanPeakToPeak(:)); + if all(~isfinite(values)) + values = double(summary.Count(:)); + yLabel = "Segments"; + else + yLabel = "Mean peak-to-peak"; + end + values(~isfinite(values)) = 0; + b = bar(ax, values, "FaceColor", [0.18 0.38 0.58], ... + "EdgeColor", "none"); + if isprop(b, "BaseLine") + b.BaseLine.LineStyle = "-"; + end + ax.XTick = 1:height(summary); + ax.XTickLabel = cellstr(string(summary.Group)); + ax.XTickLabelRotation = 20; + ylabel(ax, yLabel); + title(ax, "Metric Summary"); + styleBarAxes(ax); +end + +function drawEmpty(ax, message) + text(ax, 0.5, 0.5, string(message), ... + "HorizontalAlignment", "center", ... + "VerticalAlignment", "middle", ... + "Color", [0.30 0.30 0.30]); + xlim(ax, [0 1]); + ylim(ax, [0 1]); +end + +function tf = hasAlignedValues(aligned) + tf = isstruct(aligned) && isfield(aligned, "values") && ... + isfield(aligned, "timeSec") && ~isempty(aligned.values) && ... + ~isempty(aligned.timeSec); +end + +function label = labelFor(names, index) + if index <= numel(names) && strlength(names(index)) > 0 + label = names(index); + else + label = "Segment " + index; + end +end + +function resetAxes(ax) + delete(allchild(ax)); + cla(ax); + ax.Color = "white"; + ax.Box = "off"; + ax.XGrid = "on"; + ax.YGrid = "on"; + ax.GridAlpha = 0.18; +end + +function styleWaveAxes(ax) + ax.Color = "white"; + ax.Box = "off"; + ax.XGrid = "on"; + ax.YGrid = "on"; + ax.GridAlpha = 0.18; +end + +function styleBarAxes(ax) + ax.Color = "white"; + ax.Box = "off"; + ax.YGrid = "on"; + ax.XGrid = "off"; + ax.GridAlpha = 0.18; +end + +function value = fieldOrDefault(S, fieldName, defaultValue) + value = defaultValue; + if isstruct(S) && isfield(S, fieldName) + value = S.(fieldName); + end +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/layoutSection.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/layoutSection.m new file mode 100644 index 000000000..2eb4037fb --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/layoutSection.m @@ -0,0 +1,12 @@ +% App-owned implementation for response_review_stats.analysisRun.layoutSection within the response_review_stats product workflow. +function section = layoutSection() +section = labkit.app.layout.section("analysisSection", "Metric Windows", { ... + labkit.app.layout.rangeField("baselineWindowSec", Label="Baseline", ... + Limits=[-0.1 0.1], Bind="project.parameters.baselineWindowSec", ... + OnValueChanged=@response_review_stats.analysisRun.refreshForWindows), ... + labkit.app.layout.rangeField("noiseWindowSec", Label="Noise", ... + Limits=[-0.1 0.1], Bind="project.parameters.noiseWindowSec", ... + OnValueChanged=@response_review_stats.analysisRun.refreshForWindows), ... + labkit.app.layout.field("statusField", Label="Status", ... + Kind="readonly", Value="No input selected.")}); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/loadMetrics.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/loadMetrics.m index 7f512d605..8b34289c1 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/loadMetrics.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/loadMetrics.m @@ -1,4 +1,4 @@ -% Expected callers: Runtime V2 session creation and definition actions. Input +% Expected callers: App SDK runtime session creation and definition actions. Input % is one analysis JSON or segment CSV path plus metric windows. Outputs are % the rebuildable metric, summary, and aligned-signal caches. function [metrics, summary, aligned] = loadMetrics(filepath, parameters) diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/present.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/present.m new file mode 100644 index 000000000..df4e76e0a --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/present.m @@ -0,0 +1,10 @@ +% App-owned implementation for response_review_stats.analysisRun.present within the response_review_stats product workflow. +function view = present(model) +hasInput = strlength(model.inputFile) > 0; +view = labkit.app.view.Snapshot() ... + .text("statusField", string(model.statusMessage)) ... + .enabled("loadMetrics", hasInput) ... + .tableData("summaryTable", response_review_stats.analysisRun.summaryTableData(model)) ... + .text("details", strjoin(string(response_review_stats.analysisRun.detailLines(model)), newline)) ... + .renderPlot("preview", model); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/presentationModel.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/presentationModel.m new file mode 100644 index 000000000..64e730b80 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/presentationModel.m @@ -0,0 +1,13 @@ +% App-owned implementation for response_review_stats.analysisRun.presentationModel within the response_review_stats product workflow. +function model = presentationModel(state) +model = struct("inputFile", state.session.cache.filepath, ... + "outputFolder", state.session.workflow.outputFolder, ... + "baselineWindowSec", state.project.parameters.baselineWindowSec, ... + "noiseWindowSec", state.project.parameters.noiseWindowSec, ... + "previewMode", state.session.view.previewMode, ... + "metrics", state.session.cache.metrics, ... + "summary", state.session.cache.summary, ... + "aligned", state.session.cache.aligned, ... + "statusMessage", state.session.workflow.statusMessage, ... + "lastAction", state.session.workflow.lastAction); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/refreshForWindows.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/refreshForWindows.m new file mode 100644 index 000000000..15bb40fc3 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/refreshForWindows.m @@ -0,0 +1,18 @@ +% App-owned implementation for response_review_stats.analysisRun.refreshForWindows within the response_review_stats product workflow. +function state = refreshForWindows(state, ~, context) +state.project.parameters.baselineWindowSec = validRange( ... + state.project.parameters.baselineWindowSec, [0.007 0.009]); +state.project.parameters.noiseWindowSec = validRange( ... + state.project.parameters.noiseWindowSec, [0.007 0.009]); +state = response_review_stats.analysisRun.refreshMetrics(state, context); +if height(state.session.cache.metrics) > 0 + state.session.workflow.lastAction = ... + "Refreshed metrics after window change"; +end +end + +function value = validRange(value, fallback) +if ~isnumeric(value) || ~isequal(size(value), [1 2]) || any(~isfinite(value)) + value = fallback; +end +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/refreshMetrics.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/refreshMetrics.m new file mode 100644 index 000000000..c17c6111e --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/refreshMetrics.m @@ -0,0 +1,34 @@ +% App-owned implementation for response_review_stats.analysisRun.refreshMetrics within the response_review_stats product workflow. +function state = refreshMetrics(state, context) +if isempty(state.project.inputs.sources) + state.session.workflow.statusMessage = "Select an analysis JSON or segment CSV first."; + return; +end +sources = state.project.inputs.sources; +roleMatch = string({sources.role}) == "reviewInput"; +paths = context.resolveSourcePaths(sources(roleMatch)); +if isempty(paths) + state.session.workflow.statusMessage = "Select an analysis JSON or segment CSV first."; + return; +end +try + [metrics, summary, aligned] = response_review_stats.analysisRun.loadMetrics( ... + paths(1), state.project.parameters); +catch ME + context.reportError("Metric load", ME); + state.session.cache.metrics = table(); + state.session.cache.summary = table(); + state.session.cache.aligned = []; + state.session.workflow.statusMessage = string(ME.message); + state.session.workflow.lastAction = "Metric load failed"; + return; +end +state.project.results.lastExport = []; +state.session.cache.filepath = paths(1); +state.session.cache.metrics = metrics; +state.session.cache.summary = summary; +state.session.cache.aligned = aligned; +state.session.workflow.statusMessage = sprintf("Loaded %d metric row(s).", height(metrics)); +state.session.workflow.lastAction = "Loaded metrics"; +context.appendStatus(state.session.workflow.statusMessage); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/summaryTableData.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/summaryTableData.m similarity index 100% rename from apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/summaryTableData.m rename to apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/summaryTableData.m diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+debug/writeSamplePack.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+debug/writeSamplePack.m index 37a2703a6..de2e4f734 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+debug/writeSamplePack.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+debug/writeSamplePack.m @@ -1,18 +1,21 @@ -% Expected caller: response_review_stats.definitionActions startup action and unit -% tests. Input is a LabKit debug context. Output is a deterministic segment +% Expected caller: response_review_stats.definition and unit tests. +% tests. Input is a bounded diagnostic SampleContext. Output is a deterministic segment % and analysis-metrics sample pack. Side effects: writes anonymous debug files -% and records a session manifest when available. -function pack = writeSamplePack(debugLog) +% beneath the diagnostic root. +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Response Review Stats debug files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "response_review_stats"); - sampleFolder = fullfile(char(folders.sampleFolder), "response_review_stats"); - ensureFolder(sampleFolder); - - segmentPath = string(fullfile(sampleFolder, "response_segments_representative_debug.csv")); - analysisPath = string(fullfile(sampleFolder, "response_analysis_metrics_debug.json")); - sparsePath = string(fullfile(sampleFolder, "response_segments_valid_sparse_debug.csv")); - malformedPath = string(fullfile(sampleFolder, "response_segments_malformed_debug.csv")); + segmentPath = sampleContext.samplePath( ... + "response_review_stats/representative_segments.csv"); + analysisPath = sampleContext.samplePath( ... + "response_review_stats/analysis_metrics.json"); + sparsePath = sampleContext.samplePath( ... + "response_review_stats/valid_sparse_segments.csv"); + malformedPath = sampleContext.samplePath( ... + "response_review_stats/malformed_segments.csv"); T = segmentTable(9, 0.0002, 0.030); writetable(T, char(segmentPath)); @@ -22,16 +25,20 @@ writetable(sparse, char(sparsePath)); writeTextFile(malformedPath, ["Time_s,Segment_A"; "0,1"; "bad,2"]); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_ResponseReviewStats_app", ... - "description", "Anonymous response segment and metrics boundary pack for debug launch.", ... - "sampleFolder", string(sampleFolder), ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", struct("segmentCsv", segmentPath, "analysisJson", analysisPath), ... - "boundaryFiles", struct("validSparseSegmentCsv", sparsePath, "malformedCsv", malformedPath)); - recordManifest(debugLog, manifest); - pack = manifest; + project = response_review_stats.projectSpec().Create(); + project.inputs.sources = sampleContext.sourceRecord( ... + "reviewInput", "reviewInput", segmentPath, true); + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-response-review", ... + InitialProject=project, Artifacts={ ... + sampleContext.artifact( ... + "representativeSegments", "reviewInput", segmentPath), ... + sampleContext.artifact( ... + "analysisMetrics", "alternateInput", analysisPath), ... + sampleContext.artifact( ... + "sparseSegments", "boundaryInput", sparsePath), ... + sampleContext.artifact("malformedSegments", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function T = segmentTable(count, dt, spanSec) @@ -75,30 +82,6 @@ function writeAnalysisJson(filepath) fprintf(fid, "%s", jsonencode(payload)); end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, lines) fid = fopen(char(filepath), "w"); if fid < 0 @@ -108,9 +91,3 @@ function writeTextFile(filepath, lines) cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s\n", lines); end - -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/chooseOutputFolder.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/chooseOutputFolder.m new file mode 100644 index 000000000..6e88d5b35 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/chooseOutputFolder.m @@ -0,0 +1,17 @@ +% App-owned implementation for response_review_stats.resultFiles.chooseOutputFolder within the response_review_stats product workflow. +function state = chooseOutputFolder(state, context) +%CHOOSEOUTPUTFOLDER Select the explicit destination for metrics exports. +startPath = state.session.workflow.outputFolder; +if strlength(startPath) == 0 + startPath = pwd; +end +choice = context.chooseOutputFolder(startPath); +if choice.Cancelled + state.session.workflow.lastAction = "Output folder selection cancelled"; + return; +end +state.session.workflow.outputFolder = string(choice.Value); +state.session.workflow.lastAction = "Selected output folder"; +context.appendStatus( ... + "Selected output folder: " + string(choice.Value)); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/clearOutputFolder.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/clearOutputFolder.m new file mode 100644 index 000000000..f6fd42024 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/clearOutputFolder.m @@ -0,0 +1,7 @@ +% App-owned implementation for response_review_stats.resultFiles.clearOutputFolder within the response_review_stats product workflow. +function state = clearOutputFolder(state, context) +%CLEAROUTPUTFOLDER Clear the current metrics destination. +state.session.workflow.outputFolder = ""; +state.session.workflow.lastAction = "Cleared output folder"; +context.appendStatus("Cleared output folder."); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/exportMetrics.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/exportMetrics.m new file mode 100644 index 000000000..b4421b70b --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/exportMetrics.m @@ -0,0 +1,38 @@ +% App-owned implementation for response_review_stats.resultFiles.exportMetrics within the response_review_stats product workflow. +function state = exportMetrics(state, context) +%EXPORTMETRICS Write the rebuilt metrics table and its portable manifest. +metrics = state.session.cache.metrics; +if height(metrics) == 0 + context.alert("Load metrics before exporting.", "Export metrics"); + return; +end +folder = state.session.workflow.outputFolder; +if strlength(folder) == 0 + chosen = context.chooseOutputFolder(pwd); + if chosen.Cancelled + return; + end + folder = string(chosen.Value); + state.session.workflow.outputFolder = folder; +end +if exist(folder, "dir") ~= 7 + mkdir(folder); +end +name = "response_review_metrics.csv"; +path = fullfile(folder, name); +response_review_stats.resultFiles.writeMetricsCsv(metrics, path); +output = labkit.app.result.File("responseReviewMetrics", "primary", name, ... + MediaType="text/csv"); +package = labkit.app.result.Package(Outputs={output}, ... + Inputs=struct("sources", state.project.inputs.sources), ... + Parameters=state.project.parameters, ... + Summary=struct("metricCount", height(metrics), ... + "groupCount", height(state.session.cache.summary)), ... + ManifestName="response_review_metrics.labkit.json"); +written = context.writeResultPackage(folder, package); +state.project.results.lastExport = struct("csvPath", string(path), ... + "manifestPath", string(written.Value)); +state.session.workflow.statusMessage = "Exported response-review metrics."; +state.session.workflow.lastAction = "Exported metrics"; +context.appendStatus("Exported metrics: " + string(path)); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/layoutSection.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/layoutSection.m new file mode 100644 index 000000000..8daeaa651 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/layoutSection.m @@ -0,0 +1,7 @@ +% App-owned implementation for response_review_stats.resultFiles.layoutSection within the response_review_stats product workflow. +function section = layoutSection() +section = labkit.app.layout.section("exportActionsSection", "Export Actions", { ... + labkit.app.layout.button("exportMetrics", "Export Metrics", ... + @response_review_stats.resultFiles.exportMetrics, ... + Tooltip="Export the reviewed response metrics and their current statistical summary as portable result files.")}); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/outputSection.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/outputSection.m new file mode 100644 index 000000000..cee2c35c1 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/outputSection.m @@ -0,0 +1,15 @@ +% App-owned implementation for response_review_stats.resultFiles.outputSection within the response_review_stats product workflow. +function section = outputSection() +%OUTPUTSECTION Declare the explicit metrics destination controls. +section = labkit.app.layout.section("outputsSection", "Outputs", { ... + labkit.app.layout.field("outputFolder", Label="Output folder", ... + Kind="readonly", Value="No output folder selected"), ... + labkit.app.layout.group("outputFolderActions", { ... + labkit.app.layout.button("chooseOutputFolder", "Choose output", ... + @response_review_stats.resultFiles.chooseOutputFolder, ... + Tooltip="Choose the destination for the response-review metrics table and statistical summary."), ... + labkit.app.layout.button("clearOutputFolder", "Clear output", ... + @response_review_stats.resultFiles.clearOutputFolder, ... + Tooltip="Clear the selected export destination without deleting existing metric files.")}, ... + Layout="horizontal")}); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/present.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/present.m new file mode 100644 index 000000000..b08855635 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/present.m @@ -0,0 +1,12 @@ +% App-owned implementation for response_review_stats.resultFiles.present within the response_review_stats product workflow. +function view = present(state, model) +folder = state.session.workflow.outputFolder; +text = "No output folder selected"; +if strlength(folder) > 0 + text = folder; +end +view = labkit.app.view.Snapshot() ... + .text("outputFolder", text) ... + .enabled("exportMetrics", ... + height(model.metrics) > 0 && strlength(folder) > 0); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+sourceFiles/layoutSection.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+sourceFiles/layoutSection.m new file mode 100644 index 000000000..7da167d85 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+sourceFiles/layoutSection.m @@ -0,0 +1,12 @@ +% App-owned implementation for response_review_stats.sourceFiles.layoutSection within the response_review_stats product workflow. +function section = layoutSection() +%LAYOUTSECTION Declare source selection; session reconstruction loads metrics. +section = labkit.app.layout.section("inputSection", "Input", { ... + labkit.app.layout.fileList("inputFile", ... + Label="Analysis or segment file", SelectionMode="single", MaxFiles=1, ... + Filters=["*.json;*.csv", "Analysis JSON or segment CSV"], ... + ChooseLabel="Choose input", EmptyText="No input selected", ... + ChooseTooltip="Choose an analysis JSON or segment CSV containing response metrics for statistical review.", ... + Bind="project.inputs.sources", SourceRole="reviewInput", ... + SourceIdPrefix="reviewInput", Required=true)}); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+sourceFiles/present.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+sourceFiles/present.m new file mode 100644 index 000000000..72f933fe9 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+sourceFiles/present.m @@ -0,0 +1,5 @@ +% App-owned implementation for response_review_stats.sourceFiles.present within the response_review_stats product workflow. +function view = present(~) +% The runtime owns opaque portable path display for bound file lists. +view = labkit.app.view.Snapshot().filePaths("inputFile", strings(0, 1)); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index e8793aec9..000000000 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,121 +0,0 @@ -% Expected caller: response_review_stats.definition. Inputs are app callback -% handles. Output is a data-only UI 5 workbench layout. -function layout = buildWorkbenchLayout(callbacks) -%BUILDWORKBENCHLAYOUT Build the Response Review Stats UI layout. - - layout = labkit.ui.layout.workbench("response_review_stats", ... - "Response Review Stats", ... - "controlTabs", {setupTab(callbacks), reviewTab(), ... - exportTab(callbacks), logTab()}, ... - "workspace", previewWorkspace(callbacks)); -end - -function tab = setupTab(callbacks) - tab = labkit.ui.layout.tab("setup", "Setup", { ... - inputSection(callbacks), ... - optionsSection(), ... - actionsSection(callbacks)}); -end - -function tab = reviewTab() - tab = labkit.ui.layout.tab("review", "Review", { ... - summarySection(), ... - detailsSection()}); -end - -function tab = exportTab(callbacks) - tab = labkit.ui.layout.tab("export", "Export", { ... - outputsSection(callbacks), ... - exportActionsSection(callbacks)}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("logPanel", "Log", ... - "value", {"Ready."})})}); -end - -function section = inputSection(callbacks) - section = labkit.ui.layout.section("inputSection", "Input", { ... - labkit.ui.layout.filePanel("inputFile", "Analysis or segment file", ... - "mode", "single", ... - "filters", {'*.json;*.csv', 'Analysis JSON or segment CSV'}, ... - "chooseLabel", "Choose input", ... - "status", "No input selected", ... - "onChoose", callbacks.inputChosen)}); -end - -function section = optionsSection() - section = labkit.ui.layout.section("optionsSection", "Metric Windows", { ... - labkit.ui.layout.rangeField("baselineWindowSec", "Baseline", ... - "limits", [-0.100 0.100], ... - "value", [0.007 0.009], ... - "Bind", "project.parameters.baselineWindowSec", ... - "Event", "settingChanged"), ... - labkit.ui.layout.rangeField("noiseWindowSec", "Noise", ... - "limits", [-0.100 0.100], ... - "value", [0.007 0.009], ... - "Bind", "project.parameters.noiseWindowSec", ... - "Event", "settingChanged"), ... - labkit.ui.layout.field("statusField", "Status", ... - "kind", "readonly", ... - "value", "No input selected.")}); -end - -function section = outputsSection(callbacks) - section = labkit.ui.layout.section("outputsSection", "Outputs", { ... - labkit.ui.layout.field("outputFolder", "Output folder", ... - "kind", "readonly", ... - "value", "No output folder selected"), ... - labkit.ui.layout.group("outputFolderActions", "", { ... - labkit.ui.layout.action("chooseOutputFolder", ... - "Choose output", callbacks.outputFolderChosen), ... - labkit.ui.layout.action("clearOutputFolder", ... - "Clear output", callbacks.outputFolderCleared)})}); -end - -function section = actionsSection(callbacks) - section = labkit.ui.layout.section("actionsSection", "Actions", { ... - labkit.ui.layout.group("statsActions", "", { ... - labkit.ui.layout.action("loadMetrics", ... - "Refresh Metrics", ... - callbacks.loadMetrics, ... - "priority", "primary"), ... - labkit.ui.layout.action("resetWorkflow", ... - "Reset", ... - callbacks.resetWorkflow)})}); -end - -function section = exportActionsSection(callbacks) - section = labkit.ui.layout.section("exportActionsSection", "Export Actions", { ... - labkit.ui.layout.group("exportActions", "", { ... - labkit.ui.layout.action("exportMetrics", ... - "Export Metrics", ... - callbacks.exportMetrics, ... - "priority", "primary")})}); -end - -function section = summarySection() - section = labkit.ui.layout.section("summarySection", "Summary", { ... - labkit.ui.layout.resultTable("summaryTable", ... - "Response Review Stats Summary", ... - "columns", {"Field", "Value"}, ... - "data", response_review_stats.userInterface.summaryTableData(struct()))}); -end - -function section = detailsSection() - section = labkit.ui.layout.section("detailsSection", "Details", { ... - labkit.ui.layout.statusPanel("details", "Details", ... - "value", response_review_stats.userInterface.detailLines(struct()))}); -end - -function workspace = previewWorkspace(callbacks) - workspace = labkit.ui.layout.workspace("workspace", "Stats", { ... - labkit.ui.layout.previewArea("preview", "Preview", ... - "layout", "single", ... - "axisIds", {"main"}, ... - "axisTitles", {"Metric Summary"}, ... - "viewModes", {"Summary", "Aligned"}, ... - "onModeChange", callbacks.previewModeChanged)}); -end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/drawStatsPreview.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/drawStatsPreview.m deleted file mode 100644 index f22560e14..000000000 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/drawStatsPreview.m +++ /dev/null @@ -1,127 +0,0 @@ -% Expected caller: Runtime V2 registered renderer. Inputs are a UI axes handle -% and presentation model. Side effect is redrawing summary/aligned waveforms. -function drawStatsPreview(ax, S) -%DRAWSTATSPREVIEW Draw response-review metrics or aligned segments. - - resetAxes(ax); - mode = string(fieldOrDefault(S, "previewMode", "Summary")); - aligned = fieldOrDefault(S, "aligned", []); - - if mode == "Aligned" && hasAlignedValues(aligned) - drawAligned(ax, aligned); - return; - end - drawSummary(ax, fieldOrDefault(S, "summary", table()), ... - fieldOrDefault(S, "metrics", table())); -end - -function drawAligned(ax, aligned) - timeSec = double(aligned.timeSec(:)); - values = double(aligned.values); - names = string(aligned.segmentNames(:)); - nTraces = size(values, 2); - if nTraces == 0 - drawEmpty(ax, "No aligned segments"); - return; - end - - colors = lines(max(nTraces, 1)); - hold(ax, "on"); - for k = 1:nTraces - plot(ax, timeSec, values(:, k), "LineWidth", 1.0, ... - "Color", colors(k, :), ... - "DisplayName", char(labelFor(names, k))); - end - hold(ax, "off"); - xlabel(ax, "Time (s)"); - ylabel(ax, "Signal"); - title(ax, "Aligned Segments"); - if nTraces <= 8 - legend(ax, "Location", "northeastoutside", "Interpreter", "none"); - end - styleWaveAxes(ax); -end - -function drawSummary(ax, summary, metrics) - if ~istable(summary) || height(summary) == 0 || ... - (~istable(metrics) || height(metrics) == 0) - drawEmpty(ax, "Choose an analysis JSON or segment CSV"); - title(ax, "Metric Summary"); - return; - end - - values = double(summary.MeanPeakToPeak(:)); - if all(~isfinite(values)) - values = double(summary.Count(:)); - yLabel = "Segments"; - else - yLabel = "Mean peak-to-peak"; - end - values(~isfinite(values)) = 0; - b = bar(ax, values, "FaceColor", [0.18 0.38 0.58], ... - "EdgeColor", "none"); - if isprop(b, "BaseLine") - b.BaseLine.LineStyle = "-"; - end - ax.XTick = 1:height(summary); - ax.XTickLabel = cellstr(string(summary.Group)); - ax.XTickLabelRotation = 20; - ylabel(ax, yLabel); - title(ax, "Metric Summary"); - styleBarAxes(ax); -end - -function drawEmpty(ax, message) - text(ax, 0.5, 0.5, string(message), ... - "HorizontalAlignment", "center", ... - "VerticalAlignment", "middle", ... - "Color", [0.30 0.30 0.30]); - xlim(ax, [0 1]); - ylim(ax, [0 1]); -end - -function tf = hasAlignedValues(aligned) - tf = isstruct(aligned) && isfield(aligned, "values") && ... - isfield(aligned, "timeSec") && ~isempty(aligned.values) && ... - ~isempty(aligned.timeSec); -end - -function label = labelFor(names, index) - if index <= numel(names) && strlength(names(index)) > 0 - label = names(index); - else - label = "Segment " + index; - end -end - -function resetAxes(ax) - labkit.ui.plot.clear(ax, "ResetScale", true); - ax.Color = "white"; - ax.Box = "off"; - ax.XGrid = "on"; - ax.YGrid = "on"; - ax.GridAlpha = 0.18; -end - -function styleWaveAxes(ax) - ax.Color = "white"; - ax.Box = "off"; - ax.XGrid = "on"; - ax.YGrid = "on"; - ax.GridAlpha = 0.18; -end - -function styleBarAxes(ax) - ax.Color = "white"; - ax.Box = "off"; - ax.YGrid = "on"; - ax.XGrid = "off"; - ax.GridAlpha = 0.18; -end - -function value = fieldOrDefault(S, fieldName, defaultValue) - value = defaultValue; - if isstruct(S) && isfield(S, fieldName) - value = S.(fieldName); - end -end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/presentWorkbench.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/presentWorkbench.m deleted file mode 100644 index 5a240cde0..000000000 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,72 +0,0 @@ -% Expected caller: Runtime V2. Input is canonical Response Review Stats state. -% Output is deterministic controls, summaries, log, and registered preview. -function view = presentWorkbench(state) - model = presentationModel(state); - hasInput = ~isempty(state.project.inputs.sources); - hasMetrics = height(state.session.cache.metrics) > 0; - hasOutput = strlength(state.session.workflow.outputFolder) > 0; - view = struct(); - view.controls.inputFile = sourcePanel(state.project.inputs.sources); - view.controls.outputFolder = valueSpec(outputFolderText( ... - state.session.workflow.outputFolder)); - view.controls.loadMetrics = enabledSpec(hasInput); - view.controls.exportMetrics = enabledSpec(hasMetrics && hasOutput); - view.controls.statusField = valueSpec(state.session.workflow.statusMessage); - view.controls.summaryTable = tableSpec( ... - response_review_stats.userInterface.summaryTableData(model)); - view.controls.details = valueSpec( ... - response_review_stats.userInterface.detailLines(model)); - view.controls.preview = valueSpec(state.session.view.previewMode); - view.previews.preview = struct( ... - "Renderer", "statsPreview", "Model", model); -end - -function model = presentationModel(state) - model = struct( ... - "inputFile", sourcePath(state.project.inputs.sources), ... - "outputFolder", state.session.workflow.outputFolder, ... - "baselineWindowSec", state.project.parameters.baselineWindowSec, ... - "noiseWindowSec", state.project.parameters.noiseWindowSec, ... - "previewMode", state.session.view.previewMode, ... - "metrics", state.session.cache.metrics, ... - "summary", state.session.cache.summary, ... - "aligned", state.session.cache.aligned, ... - "statusMessage", state.session.workflow.statusMessage, ... - "lastAction", state.session.workflow.lastAction); -end - -function spec = sourcePanel(sources) - files = struct("id", {}, "path", {}, "status", {}); - status = "No input selected"; - filepath = sourcePath(sources); - if strlength(filepath) > 0 - files = struct("id", "item1", "path", filepath, "status", ""); - status = filepath; - end - spec = struct("Files", files, "Status", status); -end - -function filepath = sourcePath(sources) - filepath = labkit.ui.runtime.sourcePaths(sources, "reviewInput"); -end - -function text = outputFolderText(filepath) - text = "No output folder selected"; - if strlength(string(filepath)) > 0 - text = string(filepath); - end -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = enabledSpec(value) - spec = struct("Enabled", logical(value)); -end - -function spec = tableSpec(value) - spec = struct(); - spec.Data = value; -end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/buildLayout.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/buildLayout.m new file mode 100644 index 000000000..0da024ed3 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/buildLayout.m @@ -0,0 +1,28 @@ +% App-owned implementation for response_review_stats.workbench.buildLayout within the response_review_stats product workflow. +function layout = buildLayout() +%BUILDLAYOUT Declare Response Review Stats by workflow ownership. + +controls = { ... + labkit.app.layout.tab("setup", "Setup", { ... + response_review_stats.sourceFiles.layoutSection(), ... + response_review_stats.analysisRun.layoutSection(), ... + response_review_stats.analysisRun.actionSection()}), ... + labkit.app.layout.tab("review", "Review", { ... + labkit.app.layout.dataTable("summaryTable", ... + Title="Response Review Stats Summary", ... + Columns=["Field", "Value"]), ... + labkit.app.layout.statusPanel("details", Title="Details")}), ... + labkit.app.layout.tab("export", "Export", { ... + response_review_stats.resultFiles.outputSection(), ... + response_review_stats.resultFiles.layoutSection()}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.logPanel("logPanel")})}; +workspace = labkit.app.layout.workspace( ... + labkit.app.layout.plotArea("preview", ... + @response_review_stats.analysisRun.drawPreview, ... + Title="Preview", AxisTitles="Metric Summary", ... + ViewModes=["Summary", "Aligned"], ... + OnValueChanged=@response_review_stats.analysisRun.changePreviewMode), ... + Title="Stats"); +layout = labkit.app.layout.workbench(controls, Workspace=workspace); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/present.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/present.m new file mode 100644 index 000000000..64fcad9ce --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/present.m @@ -0,0 +1,8 @@ +% App-owned implementation for response_review_stats.workbench.present within the response_review_stats product workflow. +function view = present(state) +%PRESENT Compose feature-owned Response Review Stats view fragments. + +model = response_review_stats.analysisRun.presentationModel(state); +view = response_review_stats.analysisRun.present(model) ... + .include(response_review_stats.resultFiles.present(state, model)); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/resetWorkflow.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/resetWorkflow.m new file mode 100644 index 000000000..ec75a81c9 --- /dev/null +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/resetWorkflow.m @@ -0,0 +1,8 @@ +% App-owned implementation for response_review_stats.workbench.resetWorkflow within the response_review_stats product workflow. +function state = resetWorkflow(state, context) +%RESETWORKFLOW Restore a new Response Review Stats project and session. +schema = response_review_stats.projectSpec(); +state.project = schema.Create(); +state.session = response_review_stats.createSession(state.project, context); +context.appendStatus("Reset Response Review Stats state."); +end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/createSession.m b/apps/neurophysiology/response_review_stats/+response_review_stats/createSession.m index 52e183f3b..5caf907b1 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/createSession.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/createSession.m @@ -1,28 +1,56 @@ % Rebuild transient metrics, aligned signals, preview selection, output-folder % convenience, and workflow messages from one validated project. -function session = createSession(project) - filepath = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "reviewInput"); +function session = createSession(project, context) + filepath = pathForRole( ... + project.inputs.sources, "reviewInput", context); [metrics, summary, aligned] = emptyCache(); outputFolder = ""; status = "No input selected."; + lastAction = "Ready"; if strlength(filepath) > 0 [metrics, summary, aligned] = ... response_review_stats.analysisRun.loadMetrics( ... filepath, project.parameters); - outputFolder = string(labkit.ui.runtime.defaultOutputFolder( ... - filepath, "response_review_stats", "")); + outputFolder = defaultOutputFolder(filepath); status = sprintf("Loaded %d metric row(s).", height(metrics)); + lastAction = "Auto-loaded metrics"; end session = struct( ... "workflow", struct("statusMessage", status, ... - "lastAction", "Ready", ... + "lastAction", lastAction, ... "outputFolder", outputFolder), ... "view", struct("previewMode", "Summary"), ... "cache", struct("filepath", filepath, "metrics", metrics, ... "summary", summary, "aligned", aligned)); end +function folder = defaultOutputFolder(filepath) + parent = string(fileparts(filepath)); + folder = string(fullfile(parent, "response_review_stats")); + if exist(folder, "dir") == 7 + return; + end + [created, ~, ~] = mkdir(folder); + if ~created + folder = parent; + end +end + +function filepath = pathForRole(sources, role, context) + filepath = ""; + if isempty(sources) + return; + end + match = find(string({sources.role}) == role, 1); + if isempty(match) + return; + end + paths = context.resolveSourcePaths(sources(match)); + if ~isempty(paths) + filepath = paths(1); + end +end + function [metrics, summary, aligned] = emptyCache() metrics = table(); summary = table(); diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/definition.m b/apps/neurophysiology/response_review_stats/+response_review_stats/definition.m index 5fae976c6..4d6180f41 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/definition.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/definition.m @@ -1,22 +1,19 @@ % App-owned runtime definition for labkit_ResponseReviewStats_app. Expected % caller: the public app entrypoint. Output is a declarative LabKit app % definition; side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_ResponseReviewStats_app", ... - "Id", "response_review_stats", ... - "Title", "Response Review Stats", ... - "DisplayName", "Response Review Stats", ... - "Family", "Neurophysiology", ... - "AppVersion", "1.4.7", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ... - "Project", response_review_stats.projectSpec(), ... - "CreateSession", @response_review_stats.createSession, ... - "Layout", @response_review_stats.userInterface.buildWorkbenchLayout, ... - "Actions", response_review_stats.definitionActions(), ... - "Present", @response_review_stats.userInterface.presentWorkbench, ... - "Renderers", struct("statsPreview", ... - @response_review_stats.userInterface.drawStatsPreview), ... - "DebugSample", @response_review_stats.debug.writeSamplePack); +function app = definition() + app = labkit.app.Definition( ... + Entrypoint="labkit_ResponseReviewStats_app", ... + AppId="response_review_stats", ... + Title="Response Review Stats", ... + DisplayName="Response Review Stats", ... + Family="Neurophysiology", ... + AppVersion="1.5.1", ... + Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + ProjectSchema=response_review_stats.projectSpec(), ... + CreateSession=@response_review_stats.createSession, ... + Workbench=response_review_stats.workbench.buildLayout(), ... + PresentWorkbench=@response_review_stats.workbench.present, ... + BuildDebugSample=@response_review_stats.debug.writeSamplePack); end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m b/apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m deleted file mode 100644 index 50d3d1f31..000000000 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/definitionActions.m +++ /dev/null @@ -1,172 +0,0 @@ -% App-owned Runtime V2 actions for Response Review Stats. Handlers own source -% selection, cache rebuild, preview selection, and export without direct UI or -% lifecycle plumbing. -function actions = definitionActions() - actions = struct( ... - "inputChosen", @onInputChosen, ... - "outputFolderChosen", @onOutputFolderChosen, ... - "outputFolderCleared", @onOutputFolderCleared, ... - "settingChanged", @onSettingChanged, ... - "previewModeChanged", @onPreviewModeChanged, ... - "loadMetrics", @onLoadMetrics, ... - "exportMetrics", @onExportMetrics, ... - "resetWorkflow", @onResetWorkflow); -end - -function state = onInputChosen(state, event, services) - filepath = firstEventPath(event, services); - if strlength(filepath) == 0 - return; - end - state.project.inputs.sources = services.project.sourceRecord( ... - "reviewInput", "reviewInput", filepath, true); - state.project.results.lastExport = []; - state.session.cache.filepath = filepath; - state.session.workflow.outputFolder = string( ... - services.dialogs.defaultOutputFolder(filepath, ... - "response_review_stats", state.session.workflow.outputFolder)); - state.session.workflow.lastAction = "Selected input"; - state = services.workflow.log(state, ... - "Selected input: " + displayPath(filepath)); - state = rebuildMetrics(state, "Auto-loaded metrics", services); -end - -function state = onOutputFolderChosen(state, ~, services) - [folder, cancelled] = services.dialogs.outputFolder( ... - "Select metrics output folder", state.session.workflow.outputFolder); - if cancelled - state.session.workflow.lastAction = ... - "Output folder selection cancelled"; - return; - end - state.session.workflow.outputFolder = string(folder); - state.session.workflow.lastAction = "Selected output folder"; - state = services.workflow.log(state, ... - "Selected output folder: " + displayPath(folder)); -end - -function state = onOutputFolderCleared(state, ~, ~) - state.session.workflow.outputFolder = ""; - state.session.workflow.lastAction = "Cleared output folder"; -end - -function state = onSettingChanged(state, ~, services) - state.project.parameters.baselineWindowSec = validRange( ... - state.project.parameters.baselineWindowSec, [0.007 0.009]); - state.project.parameters.noiseWindowSec = validRange( ... - state.project.parameters.noiseWindowSec, [0.007 0.009]); - state.project.results.lastExport = []; - if ~isempty(state.project.inputs.sources) - state = rebuildMetrics(state, ... - "Refreshed metrics after window change", services); - end -end - -function state = onPreviewModeChanged(state, event, ~) - value = string(event.value); - if isscalar(value) && any(value == ["Summary", "Aligned"]) - state.session.view.previewMode = value; - end -end - -function state = onLoadMetrics(state, ~, services) - state = rebuildMetrics(state, "Loaded metrics", services); -end - -function state = rebuildMetrics(state, actionLabel, services) - if isempty(state.project.inputs.sources) - state.session.workflow.statusMessage = ... - "Select an analysis JSON or segment CSV first."; - return; - end - try - [metrics, summary, aligned] = ... - response_review_stats.analysisRun.loadMetrics( ... - state.session.cache.filepath, state.project.parameters); - catch ME - services.diagnostics.report("Metric load failed", ME); - state.session.cache.metrics = table(); - state.session.cache.summary = table(); - state.session.cache.aligned = []; - state.session.workflow.statusMessage = string(ME.message); - state.session.workflow.lastAction = "Metric load failed"; - state = services.workflow.log(state, ... - "Metric load failed: " + state.session.workflow.statusMessage); - return; - end - state.session.cache.metrics = metrics; - state.session.cache.summary = summary; - state.session.cache.aligned = aligned; - state.session.workflow.statusMessage = sprintf( ... - "Loaded %d metric row(s).", height(metrics)); - state.session.workflow.lastAction = string(actionLabel); - state = services.workflow.log(state, state.session.workflow.statusMessage); -end - -function state = onExportMetrics(state, ~, services) - metrics = state.session.cache.metrics; - if height(metrics) == 0 - state.session.workflow.statusMessage = "Load metrics before exporting."; - return; - end - folder = state.session.workflow.outputFolder; - if strlength(folder) == 0 - state.session.workflow.statusMessage = "Select an output folder first."; - return; - end - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end - outputName = "response_review_metrics.csv"; - outputPath = fullfile(char(folder), outputName); - response_review_stats.resultFiles.writeMetricsCsv(metrics, outputPath); - output = services.results.output( ... - "responseReviewMetrics", "primary", outputName, "text/csv"); - spec = struct( ... - "Outputs", output, "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, ... - "Summary", struct("metricCount", height(metrics), ... - "groupCount", height(state.session.cache.summary)), ... - "ManifestName", "response_review_metrics.labkit.json"); - [manifestPath, ~] = services.results.writeManifest(folder, spec); - state.project.results.lastExport = struct( ... - "csvPath", string(outputPath), ... - "manifestPath", string(manifestPath)); - state.session.workflow.statusMessage = "Exported response-review metrics."; - state.session.workflow.lastAction = "Exported metrics"; - state = services.workflow.log(state, ... - "Exported metrics CSV: " + displayPath(outputPath)); -end - -function state = onResetWorkflow(~, ~, services) - state = services.project.newState(); - state = services.workflow.log(state, ... - "Reset Response Review Stats state."); -end - -function filepath = firstEventPath(event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - filepath = ""; - if ~isempty(paths) - filepath = paths(1); - end -end - -function value = validRange(value, fallback) - value = double(value); - if ~isequal(size(value), [1 2]) || any(~isfinite(value)) - value = fallback; - end -end - -function text = displayPath(pathValue) - pathValue = string(pathValue); - [~, base, extension] = fileparts(char(pathValue)); - text = string(base) + string(extension); - if strlength(text) == 0 - text = pathValue; - end -end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/projectSpec.m b/apps/neurophysiology/response_review_stats/+response_review_stats/projectSpec.m index cdb1e2780..0ae19e315 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/projectSpec.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/projectSpec.m @@ -1,18 +1,15 @@ -% App-owned durable Response Review Stats contract. Runtime V2 upgrades the +% App-owned durable Response Review Stats contract. App SDK runtime upgrades the % former singular source through the one migration entry, then validates metric % windows and durable export state. function spec = projectSpec() - spec = struct( ... - "Version", 2, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", @migrateProject); + spec = labkit.app.project.Schema(Version=2, ... + Create=@createProject, Validate=@validateProject, Migrate=@migrateProject); end function project = createProject() project = struct(); project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + struct([])); project.parameters = struct( ... "baselineWindowSec", [0.007 0.009], ... "noiseWindowSec", [0.007 0.009]); diff --git a/apps/neurophysiology/response_review_stats/labkit_ResponseReviewStats_app.m b/apps/neurophysiology/response_review_stats/labkit_ResponseReviewStats_app.m index 3d647c07f..ac655bee2 100644 --- a/apps/neurophysiology/response_review_stats/labkit_ResponseReviewStats_app.m +++ b/apps/neurophysiology/response_review_stats/labkit_ResponseReviewStats_app.m @@ -1,6 +1,5 @@ function varargout = labkit_ResponseReviewStats_app(varargin) %LABKIT_RESPONSEREVIEWSTATS_APP Launch the Response Review Stats app. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @response_review_stats.definition, varargin{:}); + [varargout{1:nargout}] = response_review_stats.definition().launch(varargin{:}); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyAdaptiveWindow.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyAdaptiveWindow.m new file mode 100644 index 000000000..ba50eff64 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyAdaptiveWindow.m @@ -0,0 +1,16 @@ +% App-owned implementation for rhs_preview.analysisRun.applyAdaptiveWindow within the rhs_preview product workflow. +function session = applyAdaptiveWindow(session, parameters) +%APPLYADAPTIVEWINDOW Select a bounded preview duration for current channels. +duration = rhs_preview.analysisRun.suggestedPreviewDurationSec( ... + session.cache.index, session.cache.previewChannelRows, ... + parameters.maxPreviewChannels); +if ~(isnumeric(duration) && isscalar(duration) && ... + isfinite(duration) && duration > 0) + return; +end +session.view.windowDurationSec = duration; +context = rhs_preview.analysisRun.previewContext(session, parameters); +session.view.windowStartSec = ... + rhs_preview.analysisRun.clampWindowStartSec( ... + context.windowStartSec, context); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyFileFilterTableData.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyFileFilterTableData.m index 851e0337a..0666a34ea 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyFileFilterTableData.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyFileFilterTableData.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions and unit tests. Inputs are current filter +% Expected caller: RHS Preview direct callbacks and unit tests. Inputs are current filter % rows plus GUI table data. Output is updated rows preserving file paths and % recording ids. function rows = applyFileFilterTableData(rows, data) diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyPreviewChannelsTableData.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyPreviewChannelsTableData.m index 0f1dac4db..8d31adb81 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyPreviewChannelsTableData.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyPreviewChannelsTableData.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions and unit tests. Inputs are the current +% Expected caller: RHS Preview direct callbacks and unit tests. Inputs are the current % Preview channel rows plus GUI table cell data. Output is updated channel % rows preserving RHS-derived family/channel/unit metadata. function rows = applyPreviewChannelsTableData(rows, data) diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyPreviewContext.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyPreviewContext.m new file mode 100644 index 000000000..28cc86394 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/applyPreviewContext.m @@ -0,0 +1,10 @@ +% App-owned implementation for rhs_preview.analysisRun.applyPreviewContext within the rhs_preview product workflow. +function session = applyPreviewContext(session, context) +%APPLYPREVIEWCONTEXT Commit preview calculation outputs to transient session. +session.view.windowStartSec = context.windowStartSec; +session.view.windowDurationSec = context.windowDurationSec; +session.view.roiSec = context.roiSec; +session.cache.preview = context.preview; +session.workflow.statusMessage = context.statusMessage; +session.workflow.lastAction = context.lastAction; +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeFamily.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeFamily.m new file mode 100644 index 000000000..4dacefbeb --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeFamily.m @@ -0,0 +1,31 @@ +% App-owned implementation for rhs_preview.analysisRun.changeFamily within the rhs_preview product workflow. +function applicationState = changeFamily( ... + applicationState, value, callbackContext) +%CHANGEFAMILY Rebuild rows and preview for a selected RHS channel family. +family = string(value); +if ~isscalar(family) || strlength(family) == 0 + error("rhs_preview:InvalidPreviewFamily", ... + "Preview family must be nonempty scalar text."); +end +selection = rhs_preview.analysisRun.channelSelection( ... + applicationState.session.cache.info, family, ""); +applicationState.project.parameters.family = selection.family; +applicationState.session = rhs_preview.analysisRun.rebuildPreviewRows( ... + applicationState.session, applicationState.project.parameters, ... + applicationState.session.cache.previewChannelRows); +applicationState.project.annotations.previewChannelRows = ... + applicationState.session.cache.previewChannelRows; +if applicationState.session.view.autoWindow + applicationState.session = rhs_preview.analysisRun.applyAdaptiveWindow( ... + applicationState.session, applicationState.project.parameters); +end +[applicationState.session, ~, message] = ... + rhs_preview.analysisRun.readCurrentPreview( ... + applicationState.session, applicationState.project.parameters, ... + "Updated preview window", false); +applicationState.session.workflow.lastAction = ... + "Updated preview settings"; +if strlength(message) > 0 + callbackContext.appendStatus(message); +end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeMaxPreviewChannels.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeMaxPreviewChannels.m new file mode 100644 index 000000000..61062b852 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeMaxPreviewChannels.m @@ -0,0 +1,30 @@ +% App-owned implementation for rhs_preview.analysisRun.changeMaxPreviewChannels within the rhs_preview product workflow. +function applicationState = changeMaxPreviewChannels( ... + applicationState, value, callbackContext) +%CHANGEMAXPREVIEWCHANNELS Normalize the channel cap and refresh the preview. +value = double(value); +if ~(isscalar(value) && isfinite(value)) + error("rhs_preview:InvalidPreviewChannels", ... + "Max preview channels must be a finite scalar."); +end +applicationState.project.parameters.maxPreviewChannels = ... + max(1, round(value)); +applicationState.session = rhs_preview.analysisRun.rebuildPreviewRows( ... + applicationState.session, applicationState.project.parameters, ... + applicationState.session.cache.previewChannelRows); +applicationState.project.annotations.previewChannelRows = ... + applicationState.session.cache.previewChannelRows; +if applicationState.session.view.autoWindow + applicationState.session = rhs_preview.analysisRun.applyAdaptiveWindow( ... + applicationState.session, applicationState.project.parameters); +end +[applicationState.session, ~, message] = ... + rhs_preview.analysisRun.readCurrentPreview( ... + applicationState.session, applicationState.project.parameters, ... + "Updated preview window", false); +applicationState.session.workflow.lastAction = ... + "Updated preview settings"; +if strlength(message) > 0 + callbackContext.appendStatus(message); +end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeWindowStart.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeWindowStart.m new file mode 100644 index 000000000..2c4755f2a --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeWindowStart.m @@ -0,0 +1,26 @@ +% App-owned implementation for rhs_preview.analysisRun.changeWindowStart within the rhs_preview product workflow. +function applicationState = changeWindowStart( ... + applicationState, value, callbackContext) +%CHANGEWINDOWSTART Clamp the pan position and lazily read the new window. +value = double(value); +if ~(isscalar(value) && isfinite(value)) + error("rhs_preview:InvalidWindowStart", ... + "Preview window start must be a finite scalar."); +end +context = rhs_preview.analysisRun.previewContext( ... + applicationState.session, applicationState.project.parameters); +context.windowStartSec = ... + rhs_preview.analysisRun.clampWindowStartSec(value, context); +applicationState.session = rhs_preview.analysisRun.applyPreviewContext( ... + applicationState.session, context); +applicationState.session.view.autoWindow = false; +[applicationState.session, ~, message] = ... + rhs_preview.analysisRun.readCurrentPreview( ... + applicationState.session, applicationState.project.parameters, ... + "Panned preview window", false); +applicationState.session.workflow.lastAction = ... + "Updated preview settings"; +if strlength(message) > 0 + callbackContext.appendStatus(message); +end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/channelRows.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/channelRows.m index a45ec88f7..b46f70c86 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/channelRows.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/channelRows.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions and unit tests. Inputs are one RHS info +% Expected caller: RHS Preview direct callbacks and unit tests. Inputs are one RHS info % struct, a channel family, maximum default preview count, and optional % protocol struct. Output is a table used by the Preview channel selector. function rows = channelRows(info, family, maxPreviewChannels, protocol) diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/channelSelection.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/channelSelection.m similarity index 97% rename from apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/channelSelection.m rename to apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/channelSelection.m index 794c24b8a..13f21396c 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/channelSelection.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/channelSelection.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions and unit tests. Inputs are one RHS info +% Expected caller: RHS Preview direct callbacks and unit tests. Inputs are one RHS info % struct plus the current requested family/channel. Output is display-ready % dropdown options and the normalized readable channel choice. function selection = channelSelection(info, currentFamily, currentChannel) diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/clampRoi.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/clampRoi.m index d1e5bb5e8..abaa9852f 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/clampRoi.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/clampRoi.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions. Inputs are requested ROI seconds and the +% Expected caller: RHS Preview direct callbacks. Inputs are requested ROI seconds and the % current preview time vector. Output is a sorted ROI clamped to preview time. function roiSec = clampRoi(roiSec, timeSec) %CLAMPROI Clamp dragged ROI to preview samples. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/clampWindowStartSec.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/clampWindowStartSec.m index 9083f778f..4d8f94e31 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/clampWindowStartSec.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/clampWindowStartSec.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions and preview-window ops. Inputs are a +% Expected caller: RHS Preview direct callbacks and preview-window ops. Inputs are a % requested start time and app state. Output is a valid start time. function startSec = clampWindowStartSec(startSec, S) %CLAMPWINDOWSTARTSEC Clamp preview start into indexed file bounds. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/detailLines.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/detailLines.m new file mode 100644 index 000000000..19c903b1e --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/detailLines.m @@ -0,0 +1,110 @@ +% Expected caller: RHS Preview direct callbacks and rhs_preview.analysisRun.buildWorkbenchLayout. Input is +% the app state struct. Output is a cell array of display lines for a status +% panel. No UI handles or app state are mutated. +function lines = detailLines(S) +%DETAILLINES Build RHS preview detail lines for display. + + S = normalizeState(S); + lines = { ... + char("RHS file: " + displayFile(S.rhsFile)); ... + char("Protocol: " + displayFile(S.protocolFile)); ... + char("Status: " + string(S.statusMessage)); ... + char("Preview family: " + string(S.family)); ... + char("Selected channels: " + selectedChannelsText(S)); ... + char("Filter rows: " + filterRowsText(S)); ... + char("Window: " + sprintf("%.6g", S.windowStartSec) + " s + " + ... + sprintf("%.6g", S.windowDurationSec) + " s"); ... + char("ROI: " + roiText(S)); ... + char("Channels: " + channelNamesText(S.info)); ... + char("Last action: " + string(S.lastAction))}; +end + +function S = normalizeState(S) + if ~isstruct(S) + S = struct(); + end + defaults = struct( ... + "rhsFile", "", ... + "protocolFile", "", ... + "family", "amplifier", ... + "previewChannelRows", table(), ... + "filterRows", table(), ... + "windowStartSec", 0, ... + "windowDurationSec", 0.050, ... + "roiSec", [NaN NaN], ... + "info", [], ... + "statusMessage", "No RHS file selected.", ... + "lastAction", "Ready"); + names = fieldnames(defaults); + for k = 1:numel(names) + if ~isfield(S, names{k}) + S.(names{k}) = defaults.(names{k}); + end + end +end + +function text = filterRowsText(S) + text = "none"; + if ~isfield(S, "filterRows") || ~istable(S.filterRows) || ... + height(S.filterRows) == 0 + return; + end + rows = S.filterRows; + goodCount = sum(string(rows.label) == "good"); + badCount = sum(string(rows.label) == "bad"); + text = string(sprintf("%d total, %d good, %d bad", ... + height(rows), goodCount, badCount)); +end + +function text = roiText(S) + roiSec = double(S.roiSec); + if numel(roiSec) ~= 2 || any(~isfinite(roiSec)) || diff(roiSec) <= 0 + text = "none"; + else + text = string(sprintf("%.6g to %.6g s", roiSec(1), roiSec(2))); + end +end + +function text = displayFile(filepath) + filepath = string(filepath); + if strlength(filepath) == 0 + text = "none"; + return; + end + [~, name, ext] = fileparts(char(filepath)); + text = string([name ext]); +end + +function text = channelNamesText(info) + text = "n/a"; + if ~isstruct(info) || ~isfield(info, "channelFamilies") + return; + end + channels = info.channelFamilies.amplifier; + if isempty(channels) + text = "none"; + return; + end + names = string({channels.nativeName}); + if numel(names) > 8 + names = [names(1:8), "..."]; + end + text = strjoin(names, ", "); +end + +function text = selectedChannelsText(S) + text = "none"; + if ~isfield(S, "previewChannelRows") || ~istable(S.previewChannelRows) || ... + height(S.previewChannelRows) == 0 + return; + end + rows = S.previewChannelRows(logical(S.previewChannelRows.preview), :); + if height(rows) == 0 + return; + end + names = string(rows.channel(:)).'; + if numel(names) > 8 + names = [names(1:8), "..."]; + end + text = string(sprintf("%d (%s)", height(rows), char(strjoin(names, ", ")))); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/discoverFilterRows.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/discoverFilterRows.m index f275732b7..3eb6a4e7c 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/discoverFilterRows.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/discoverFilterRows.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions and unit tests. Inputs are one root folder +% Expected caller: RHS Preview direct callbacks and unit tests. Inputs are one root folder % or a string list of RHS task files plus optional previous filter rows. % Output is the RHS file list with user labels/comments preserved by file % path. No QC is performed. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/drawPreview.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/drawPreview.m new file mode 100644 index 000000000..e07583169 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/drawPreview.m @@ -0,0 +1,59 @@ +% Expected caller: RHS Preview direct callbacks. Inputs are one axes handle and app state +% with an optional preview window. Side effect redraws stacked RHS waveforms. +function drawStackedPreview(axesById, S) +%DRAWSTACKEDPREVIEW Draw time-aligned stacked RHS preview traces. + + ax = axesById.main; + labkit.app.plot.clearAxes(ax); + title(ax, 'RHS Stacked Preview'); + xlabel(ax, 'Time (s)'); + ylabel(ax, 'Channel'); + grid(ax, 'on'); + ax.Box = 'on'; + + if isempty(S.preview) || isempty(S.preview.values) + textHandle = text(ax, 0.5, 0.5, 'Select channels and read a window', ... + 'Units', 'normalized', 'HorizontalAlignment', 'center', ... + 'Color', [0.35 0.35 0.35]); + textHandle.HitTest = 'off'; + ax.XLim = [0 1]; + ax.YLim = [0 1]; + ax.YTick = []; + return; + end + + timeSec = double(S.preview.timeSec(:)); + values = double(S.preview.values); + channels = string(S.preview.channels(:)); + nChannels = size(values, 2); + if isempty(timeSec) || nChannels == 0 + return; + end + + offsets = (1:nChannels).'; + hold(ax, 'on'); + colors = lines(max(nChannels, 1)); + for k = 1:nChannels + y = normalizeTrace(values(:, k)); + lineHandle = plot(ax, timeSec, y + offsets(k), 'LineWidth', 0.8, ... + 'Color', colors(k, :)); + lineHandle.HitTest = 'off'; + end + hold(ax, 'off'); + ax.YTick = offsets; + ax.YTickLabel = cellstr(channels); + ax.YLim = [0.25 nChannels + 0.75]; + if numel(timeSec) > 1 + ax.XLim = [timeSec(1) timeSec(end)]; + end +end + +function y = normalizeTrace(y) + y = fillmissing(double(y(:)), "linear", "EndValues", "nearest"); + y = y - median(y, "omitnan"); + scale = max(abs(y), [], "omitnan"); + if ~isfinite(scale) || scale <= 0 + scale = 1; + end + y = 0.40 .* (y ./ scale); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editFileFilter.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editFileFilter.m new file mode 100644 index 000000000..c96b4f010 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editFileFilter.m @@ -0,0 +1,30 @@ +% App-owned implementation for rhs_preview.analysisRun.editFileFilter within the rhs_preview product workflow. +function applicationState = editFileFilter( ... + applicationState, edit, callbackContext) +%EDITFILEFILTER Apply typed label/comment edits and persist their source order. +arguments + applicationState (1, 1) struct + edit (1, 1) labkit.app.event.TableCellEdit + callbackContext (1, 1) labkit.app.CallbackContext +end +rows = rhs_preview.analysisRun.applyFileFilterTableData( ... + applicationState.session.cache.filterRows, edit.Data); +applicationState.session.cache.filterRows = rows; +applicationState = storeAnnotations(applicationState); +applicationState.session.workflow.statusMessage = "File filter updated."; +applicationState.session.workflow.lastAction = "Updated file filter"; +end + +function applicationState = storeAnnotations(applicationState) +rows = applicationState.session.cache.filterRows; +applicationState.project.annotations.filterLabels = string(rows.label); +applicationState.project.annotations.filterComments = string(rows.comment); +sources = applicationState.project.inputs.sources; +if isempty(sources) + applicationState.project.annotations.filterSourceIds = strings(0, 1); + return; +end +mask = string({sources.role}) == "filterRecording"; +applicationState.project.annotations.filterSourceIds = ... + string({sources(mask).id}).'; +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editPreviewChannels.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editPreviewChannels.m new file mode 100644 index 000000000..91ce0e17e --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editPreviewChannels.m @@ -0,0 +1,27 @@ +% App-owned implementation for rhs_preview.analysisRun.editPreviewChannels within the rhs_preview product workflow. +function applicationState = editPreviewChannels( ... + applicationState, edit, callbackContext) +%EDITPREVIEWCHANNELS Apply one typed table edit and refresh selected traces. +arguments + applicationState (1, 1) struct + edit (1, 1) labkit.app.event.TableCellEdit + callbackContext (1, 1) labkit.app.CallbackContext +end +rows = rhs_preview.analysisRun.applyPreviewChannelsTableData( ... + applicationState.session.cache.previewChannelRows, edit.Data); +applicationState.session.cache.previewChannelRows = rows; +applicationState.project.annotations.previewChannelRows = rows; +if applicationState.session.view.autoWindow + applicationState.session = rhs_preview.analysisRun.applyAdaptiveWindow( ... + applicationState.session, applicationState.project.parameters); +end +[applicationState.session, ~, message] = ... + rhs_preview.analysisRun.readCurrentPreview( ... + applicationState.session, applicationState.project.parameters, ... + "Updated preview channel window", false); +applicationState.session.workflow.lastAction = ... + "Updated preview channels"; +if strlength(message) > 0 + callbackContext.appendStatus(message); +end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editRange.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editRange.m new file mode 100644 index 000000000..9e71d6d83 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editRange.m @@ -0,0 +1,18 @@ +% App-owned implementation for rhs_preview.analysisRun.editRange within the rhs_preview product workflow. +function applicationState = editRange( ... + applicationState, range, ~) +%EDITRANGE Commit one dragged ROI inside the current preview samples. +range = double(range(:)).'; +preview = applicationState.session.cache.preview; +if numel(range) ~= 2 || ~all(isfinite(range)) || ... + ~isstruct(preview) || ~isfield(preview, "timeSec") || ... + isempty(preview.timeSec) + return; +end +applicationState.session.view.roiSec = ... + rhs_preview.analysisRun.clampRoi(range, preview.timeSec); +roi = applicationState.session.view.roiSec; +applicationState.session.workflow.statusMessage = string(sprintf( ... + "ROI %.6g to %.6g s.", roi(1), roi(2))); +applicationState.session.workflow.lastAction = "Updated preview ROI"; +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/fileFilterTableData.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/fileFilterTableData.m similarity index 84% rename from apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/fileFilterTableData.m rename to apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/fileFilterTableData.m index a0b3fd4e4..2f17cc680 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/fileFilterTableData.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/fileFilterTableData.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions/buildWorkbenchLayout. Input is app state. Output is +% Expected caller: RHS Preview direct callbacks/buildWorkbenchLayout. Input is app state. Output is % editable cell rows for manual RHS file filtering. function data = fileFilterTableData(S) %FILEFILTERTABLEDATA Build display rows for the file filter table. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/filterTableSection.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/filterTableSection.m new file mode 100644 index 000000000..1c90a2fea --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/filterTableSection.m @@ -0,0 +1,11 @@ +% App-owned implementation for rhs_preview.analysisRun.filterTableSection within the rhs_preview product workflow. +function section = filterTableSection() +%FILTERTABLESECTION Declare editable filter labels and comments. +files = labkit.app.layout.dataTable("fileFilterTable", ... + Title="Files", ... + Columns=["Label", "File", "Comment"], ... + ColumnEditable=[true false true], ... + OnCellEdited=@rhs_preview.analysisRun.editFileFilter); +section = labkit.app.layout.section( ... + "filterTableSection", "File Filter", {files}); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/hasReadableChannel.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/hasReadableChannel.m index 56f526443..ec9a4a1e9 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/hasReadableChannel.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/hasReadableChannel.m @@ -1,9 +1,9 @@ -% Expected caller: rhs_preview.definitionActions. Input is app state. Output indicates +% Expected caller: RHS Preview direct callbacks. Input is app state. Output indicates % whether the selected RHS, family, and preview rows can read a waveform. function tf = hasReadableChannel(S) %HASREADABLECHANNEL True when preview reading has at least one channel. - selection = rhs_preview.userInterface.channelSelection(S.info, S.family, ""); + selection = rhs_preview.analysisRun.channelSelection(S.info, S.family, ""); tf = strlength(string(S.rhsFile)) > 0 && selection.hasChannels && ... isfield(S, "previewChannelRows") && istable(S.previewChannelRows) && ... height(S.previewChannelRows) > 0 && any(logical(S.previewChannelRows.preview)); diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/hasValidRoi.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/hasValidRoi.m index 6874504ed..b6abfcf9f 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/hasValidRoi.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/hasValidRoi.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions. Input is app state. Output indicates +% Expected caller: RHS Preview direct callbacks. Input is app state. Output indicates % whether a dragged ROI has a positive time width. function tf = hasValidRoi(S) %HASVALIDROI True for a finite nonzero preview ROI. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/maxInteractivePreviewDurationSec.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/maxInteractivePreviewDurationSec.m index 2c5906b53..73269f88f 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/maxInteractivePreviewDurationSec.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/maxInteractivePreviewDurationSec.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions. Input is app state. Output is a preview +% Expected caller: RHS Preview direct callbacks. Input is app state. Output is a preview % duration cap that keeps interactive redraws bounded by sample count. function durationSec = maxInteractivePreviewDurationSec(S) %MAXINTERACTIVEPREVIEWDURATIONSEC Cap scroll-zoom preview duration. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewActionsSection.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewActionsSection.m new file mode 100644 index 000000000..56328bc43 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewActionsSection.m @@ -0,0 +1,16 @@ +% App-owned implementation for rhs_preview.analysisRun.previewActionsSection within the rhs_preview product workflow. +function section = previewActionsSection() +%PREVIEWACTIONSSECTION Declare preview read, ROI zoom, and reset actions. +actions = labkit.app.layout.group("rhsActions", { ... + labkit.app.layout.button("refreshPreviewWindow", ... + "Refresh Preview", @rhs_preview.analysisRun.refreshPreview, ... + Tooltip="Read the selected RHS channels and time window, then rebuild the waveform preview."), ... + labkit.app.layout.button("zoomToRoiWindow", ... + "Zoom to ROI", @rhs_preview.analysisRun.zoomToRoi, ... + Tooltip="Set the preview window to the current response ROI without changing the recording data."), ... + labkit.app.layout.button("resetWorkflow", ... + "Reset", @rhs_preview.workbench.resetWorkflow, ... + Tooltip="Clear the current RHS preview selections and restore the workflow defaults.")}); +section = labkit.app.layout.section( ... + "previewActionsSection", "Preview Actions", {actions}); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/previewChannelsTableData.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewChannelsTableData.m similarity index 87% rename from apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/previewChannelsTableData.m rename to apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewChannelsTableData.m index ebea4b507..7d15b4df0 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/previewChannelsTableData.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewChannelsTableData.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions/buildWorkbenchLayout. Input is app state. Output is +% Expected caller: RHS Preview direct callbacks/buildWorkbenchLayout. Input is app state. Output is % an editable cell table for channel preview and protocol-role drafting. function data = previewChannelsTableData(S) %PREVIEWCHANNELSTABLEDATA Build display rows for Preview channel selection. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewContext.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewContext.m new file mode 100644 index 000000000..8597fbccd --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewContext.m @@ -0,0 +1,18 @@ +% App-owned implementation for rhs_preview.analysisRun.previewContext within the rhs_preview product workflow. +function context = previewContext(session, parameters) +%PREVIEWCONTEXT Build the app-owned model consumed by preview calculations. +context = struct( ... + "rhsFile", session.cache.rhsPath, ... + "family", string(parameters.family), ... + "maxPreviewChannels", double(parameters.maxPreviewChannels), ... + "previewChannelRows", session.cache.previewChannelRows, ... + "windowStartSec", double(session.view.windowStartSec), ... + "windowDurationSec", double(session.view.windowDurationSec), ... + "roiSec", double(session.view.roiSec), ... + "autoWindow", logical(session.view.autoWindow), ... + "info", session.cache.info, ... + "index", session.cache.index, ... + "preview", session.cache.preview, ... + "statusMessage", string(session.workflow.statusMessage), ... + "lastAction", string(session.workflow.lastAction)); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewOptionsSection.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewOptionsSection.m new file mode 100644 index 000000000..c9dc112a8 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewOptionsSection.m @@ -0,0 +1,26 @@ +% App-owned implementation for rhs_preview.analysisRun.previewOptionsSection within the rhs_preview product workflow. +function section = previewOptionsSection() +%PREVIEWOPTIONSSECTION Declare preview family, pan, size, and status controls. +section = labkit.app.layout.section( ... + "previewOptionsSection", "Preview Window", { ... + labkit.app.layout.field("channelFamily", ... + Label="Family", Kind="choice", ... + Choices="amplifier", Value="amplifier", Enabled=false, ... + Bind="project.parameters.family", ... + OnValueChanged=@rhs_preview.analysisRun.changeFamily), ... + labkit.app.layout.slider("windowStartPanner", ... + Label="Pan", Limits=[0 1], Value=0, Step=0.002, ... + ValueDisplayFormat="%.6g", ShowTicks=false, Enabled=false, ... + Bind="session.view.windowStartSec", ... + OnValueChanged=@rhs_preview.analysisRun.changeWindowStart), ... + labkit.app.layout.field("windowSummary", ... + Label="Window", Kind="readonly", ... + Value="Select RHS to estimate preview length."), ... + labkit.app.layout.field("maxPreviewChannels", ... + Label="Max channels", Kind="numeric", Value=8, ... + Bind="project.parameters.maxPreviewChannels", ... + OnValueChanged=@rhs_preview.analysisRun.changeMaxPreviewChannels), ... + labkit.app.layout.field("statusField", ... + Label="Status", Kind="readonly", ... + Value="No RHS file selected.")}); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewWindowBounds.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewWindowBounds.m index e8056ac05..f46d378a1 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewWindowBounds.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/previewWindowBounds.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions and preview-window ops. Input is app +% Expected caller: RHS Preview direct callbacks and preview-window ops. Input is app % state. Output summarizes indexed duration and legal interactive window % bounds without modifying state. function bounds = previewWindowBounds(S) diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/protocolRolesSection.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/protocolRolesSection.m new file mode 100644 index 000000000..5604476f2 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/protocolRolesSection.m @@ -0,0 +1,11 @@ +% App-owned implementation for rhs_preview.analysisRun.protocolRolesSection within the rhs_preview product workflow. +function section = protocolRolesSection() +%PROTOCOLROLESSECTION Declare the editable channel-role draft. +roles = labkit.app.layout.dataTable("previewChannelsTable", ... + Title="Roles", ... + Columns=["Plot", "Role", "Label", "Channel"], ... + ColumnEditable=[true true true false], ... + OnCellEdited=@rhs_preview.analysisRun.editPreviewChannels); +section = labkit.app.layout.section( ... + "protocolRolesSection", "Protocol Channel Roles", {roles}); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/readCurrentPreview.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/readCurrentPreview.m new file mode 100644 index 000000000..f32649bda --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/readCurrentPreview.m @@ -0,0 +1,25 @@ +% App-owned implementation for rhs_preview.analysisRun.readCurrentPreview within the rhs_preview product workflow. +function [session, ok, logMessage] = readCurrentPreview( ... + session, parameters, actionLabel, preserveRoi) +%READCURRENTPREVIEW Read selected channels from the current transient window. +if nargin < 4 + preserveRoi = false; +end +context = rhs_preview.analysisRun.previewContext(session, parameters); +selected = selectedChannels( ... + context.previewChannelRows, context.maxPreviewChannels); +[context, ok, logMessage] = ... + rhs_preview.analysisRun.readPreviewWindow( ... + context, selected, actionLabel, preserveRoi); +session = rhs_preview.analysisRun.applyPreviewContext(session, context); +end + +function selected = selectedChannels(rows, limit) +selected = strings(0, 1); +if ~istable(rows) || height(rows) == 0 + return; +end +selected = string(rows.channel(logical(rows.preview))); +count = min(numel(selected), max(1, floor(double(limit)))); +selected = selected(1:count); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/rebuildPreviewRows.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/rebuildPreviewRows.m new file mode 100644 index 000000000..0931d99c7 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/rebuildPreviewRows.m @@ -0,0 +1,25 @@ +% App-owned implementation for rhs_preview.analysisRun.rebuildPreviewRows within the rhs_preview product workflow. +function session = rebuildPreviewRows(session, parameters, savedRows) +%REBUILDPREVIEWROWS Reconcile indexed channels, protocol, and durable drafts. +if nargin < 3 || ~istable(savedRows) + savedRows = table(); +end +rows = rhs_preview.analysisRun.channelRows( ... + session.cache.info, parameters.family, ... + parameters.maxPreviewChannels, session.cache.protocol); +if height(rows) > 0 && height(savedRows) > 0 && ... + all(ismember(["channel", "preview", "role", "label"], ... + string(savedRows.Properties.VariableNames))) + savedChannels = string(savedRows.channel); + for row = 1:height(rows) + match = find(savedChannels == string(rows.channel(row)), 1); + if isempty(match) + continue; + end + rows.preview(row) = logical(savedRows.preview(match)); + rows.role(row) = string(savedRows.role(match)); + rows.label(row) = string(savedRows.label(match)); + end +end +session.cache.previewChannelRows = rows; +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshFilterFiles.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshFilterFiles.m new file mode 100644 index 000000000..3e31f0bcb --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshFilterFiles.m @@ -0,0 +1,31 @@ +% App-owned implementation for rhs_preview.analysisRun.refreshFilterFiles within the rhs_preview product workflow. +function applicationState = refreshFilterFiles( ... + applicationState, callbackContext) +%REFRESHFILTERFILES Rescan the currently selected RHS filter files. +rows = applicationState.session.cache.filterRows; +if ~istable(rows) || height(rows) == 0 + applicationState.session.workflow.statusMessage = ... + "Select RHS filter files first."; + return; +end +try + rows = rhs_preview.analysisRun.discoverFilterRows( ... + string(rows.filePath), rows); +catch ME + callbackContext.reportError("Folder scan failed", ME); + applicationState.session.workflow.statusMessage = string(ME.message); + return; +end +applicationState.session.cache.filterRows = rows; +applicationState.project.annotations.filterLabels = string(rows.label); +applicationState.project.annotations.filterComments = string(rows.comment); +sources = applicationState.project.inputs.sources; +mask = string({sources.role}) == "filterRecording"; +applicationState.project.annotations.filterSourceIds = ... + string({sources(mask).id}).'; +applicationState.session.workflow.statusMessage = string(sprintf( ... + "Discovered %d RHS file(s).", height(rows))); +applicationState.session.workflow.lastAction = "Discovered RHS files"; +callbackContext.appendStatus( ... + applicationState.session.workflow.statusMessage); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshPreview.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshPreview.m new file mode 100644 index 000000000..a72d7c1c7 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshPreview.m @@ -0,0 +1,12 @@ +% App-owned implementation for rhs_preview.analysisRun.refreshPreview within the rhs_preview product workflow. +function applicationState = refreshPreview( ... + applicationState, callbackContext) +%REFRESHPREVIEW Reread the current bounded preview window. +[applicationState.session, ~, message] = ... + rhs_preview.analysisRun.readCurrentPreview( ... + applicationState.session, applicationState.project.parameters, ... + "Refresh preview window", false); +if strlength(message) > 0 + callbackContext.appendStatus(message); +end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/scrollWindow.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/scrollWindow.m new file mode 100644 index 000000000..92b649b45 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/scrollWindow.m @@ -0,0 +1,43 @@ +% App-owned implementation for rhs_preview.analysisRun.scrollWindow within the rhs_preview product workflow. +function applicationState = scrollWindow( ... + applicationState, scrollEvent, callbackContext) +%SCROLLWINDOW Zoom the lazily decoded window around the pointer anchor. +arguments + applicationState (1, 1) struct + scrollEvent (1, 1) labkit.app.event.IntervalScroll + callbackContext (1, 1) labkit.app.CallbackContext +end +parameters = applicationState.project.parameters; +context = rhs_preview.analysisRun.previewContext( ... + applicationState.session, parameters); +if ~rhs_preview.analysisRun.hasReadableChannel(context) + return; +end +bounds = rhs_preview.analysisRun.previewWindowBounds(context); +if ~bounds.hasIndexedDuration + return; +end +oldDuration = max(context.windowDurationSec, bounds.minDurationSec); +fraction = min(1, max(0, ... + (scrollEvent.Anchor - context.windowStartSec) / oldDuration)); +context.windowDurationSec = min( ... + rhs_preview.analysisRun.maxInteractivePreviewDurationSec(context), ... + max(bounds.minDurationSec, ... + oldDuration * (1.25 .^ scrollEvent.Count))); +context.windowStartSec = scrollEvent.Anchor - ... + fraction * context.windowDurationSec; +context.windowStartSec = ... + rhs_preview.analysisRun.clampWindowStartSec( ... + context.windowStartSec, context); +applicationState.session = rhs_preview.analysisRun.applyPreviewContext( ... + applicationState.session, context); +applicationState.session.view.autoWindow = false; +[applicationState.session, ~, message] = ... + rhs_preview.analysisRun.readCurrentPreview( ... + applicationState.session, parameters, ... + "Zoom preview window", false); +applicationState.session.workflow.statusMessage = "Preview zoom updated."; +if strlength(message) > 0 + callbackContext.appendStatus(message); +end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/suggestedPreviewDurationSec.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/suggestedPreviewDurationSec.m index a9760ec04..e55d916e8 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/suggestedPreviewDurationSec.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/suggestedPreviewDurationSec.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions. Inputs are RHS index, channel rows, and +% Expected caller: RHS Preview direct callbacks. Inputs are RHS index, channel rows, and % maximum plotted channels. Output is an adaptive initial preview duration. function durationSec = suggestedPreviewDurationSec(index, channelRows, maxPreviewChannels) %SUGGESTEDPREVIEWDURATIONSEC Estimate initial preview window length. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/summaryTableData.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/summaryTableData.m new file mode 100644 index 000000000..c1d472e89 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/summaryTableData.m @@ -0,0 +1,114 @@ +% Expected caller: RHS Preview direct callbacks and rhs_preview.analysisRun.buildWorkbenchLayout. Input is +% the app state struct. Output is a 2-column cell table for a resultTable. +% No UI handles or app state are mutated. +function data = summaryTableData(S) +%SUMMARYTABLEDATA Build RHS preview summary rows. + + S = normalizeState(S); + info = S.info; + data = { ... + 'RHS file', displayFile(S.rhsFile); ... + 'RHS folder', displayFile(S.rhsFolder); ... + 'Protocol', displayFile(S.protocolFile); ... + 'Status', char(S.statusMessage); ... + 'Sample rate', displayNumber(fieldValue(info, "sampleRateHz"), " Hz"); ... + 'Duration', displayNumber(fieldValue(info, "durationSec"), " s"); ... + 'Samples', displayInteger(fieldValue(info, "sampleCount")); ... + 'Blocks exact', displayLogical(fieldValue(info, "exactBlocks")); ... + 'Amplifier channels', displayInteger(channelCount(info, "amplifier")); ... + 'ADC channels', displayInteger(channelCount(info, "boardAdc")); ... + 'Digital inputs', displayInteger(channelCount(info, "boardDigIn")); ... + 'Filter files', displayInteger(filterCount(S)); ... + 'Good files', displayInteger(goodFilterCount(S)); ... + 'Preview family', char(S.family); ... + 'Last action', char(S.lastAction)}; +end + +function S = normalizeState(S) + if ~isstruct(S) + S = struct(); + end + defaults = struct( ... + "rhsFile", "", ... + "rhsFolder", "", ... + "protocolFile", "", ... + "family", "amplifier", ... + "filterRows", table(), ... + "info", [], ... + "statusMessage", "No RHS file selected.", ... + "lastAction", "Ready"); + names = fieldnames(defaults); + for k = 1:numel(names) + if ~isfield(S, names{k}) + S.(names{k}) = defaults.(names{k}); + end + end +end + +function n = filterCount(S) + n = NaN; + if isfield(S, "filterRows") && istable(S.filterRows) + n = height(S.filterRows); + end +end + +function n = goodFilterCount(S) + n = NaN; + if isfield(S, "filterRows") && istable(S.filterRows) && ... + ismember("label", S.filterRows.Properties.VariableNames) + n = sum(string(S.filterRows.label) == "good"); + end +end + +function value = fieldValue(S, name) + value = NaN; + if isstruct(S) && isfield(S, name) + value = S.(name); + end +end + +function n = channelCount(info, family) + n = NaN; + if isstruct(info) && isfield(info, "channelFamilies") && ... + isfield(info.channelFamilies, family) + n = numel(info.channelFamilies.(family)); + end +end + +function text = displayFile(filepath) + filepath = string(filepath); + if strlength(filepath) == 0 + text = 'none'; + return; + end + [~, name, ext] = fileparts(char(filepath)); + text = [name ext]; +end + +function text = displayNumber(value, unit) + if isnumeric(value) && isscalar(value) && isfinite(value) + text = sprintf("%.6g%s", double(value), unit); + else + text = 'n/a'; + end +end + +function text = displayInteger(value) + if isnumeric(value) && isscalar(value) && isfinite(value) + text = sprintf("%d", round(double(value))); + else + text = 'n/a'; + end +end + +function text = displayLogical(value) + if islogical(value) && isscalar(value) + if value + text = 'yes'; + else + text = 'no'; + end + else + text = 'n/a'; + end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/windowSummaryText.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/windowSummaryText.m index 0b19d1121..cae5d14c0 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/windowSummaryText.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/windowSummaryText.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions. Input is app state. Output is the +% Expected caller: RHS Preview direct callbacks. Input is app state. Output is the % user-facing preview-window summary. function text = windowSummaryText(S) %WINDOWSUMMARYTEXT Format current preview window. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/zoomToRoi.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/zoomToRoi.m new file mode 100644 index 000000000..7c1291948 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/zoomToRoi.m @@ -0,0 +1,31 @@ +% App-owned implementation for rhs_preview.analysisRun.zoomToRoi within the rhs_preview product workflow. +function applicationState = zoomToRoi( ... + applicationState, callbackContext) +%ZOOMTOROI Make the current ROI the lazily decoded preview window. +parameters = applicationState.project.parameters; +context = rhs_preview.analysisRun.previewContext( ... + applicationState.session, parameters); +if ~rhs_preview.analysisRun.hasValidRoi(context) + applicationState.session.workflow.statusMessage = ... + "Drag a preview ROI before using Zoom to ROI."; + return; +end +roi = sort(double(context.roiSec(:))).'; +bounds = rhs_preview.analysisRun.previewWindowBounds(context); +context.windowDurationSec = max(diff(roi), bounds.minDurationSec); +if bounds.hasIndexedDuration + context.windowDurationSec = min( ... + context.windowDurationSec, bounds.durationSec); +end +context.windowStartSec = roi(1); +applicationState.session = rhs_preview.analysisRun.applyPreviewContext( ... + applicationState.session, context); +applicationState.session.view.autoWindow = false; +[applicationState.session, ~, message] = ... + rhs_preview.analysisRun.readCurrentPreview( ... + applicationState.session, parameters, ... + "Zoom to ROI window", true); +if strlength(message) > 0 + callbackContext.appendStatus(message); +end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+debug/writeSamplePack.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+debug/writeSamplePack.m index d4f6a7c2e..30ff7de37 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+debug/writeSamplePack.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+debug/writeSamplePack.m @@ -1,20 +1,22 @@ -% Expected caller: rhs_preview.definitionActions during debug launch and unit tests. Input -% is a LabKit debug context. Output is a deterministic synthetic RHS sample -% pack. Side effects: writes anonymous debug RHS/protocol files and records a -% session manifest when available. -function pack = writeSamplePack(debugLog) +% Expected caller: RHS Preview direct callbacks during debug launch and unit tests. Input +% is a bounded diagnostic SampleContext. Output is a deterministic synthetic +% RHS sample pack. Side effects: writes anonymous debug RHS/protocol files +% beneath the diagnostic root. +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write RHS Preview debug acquisition files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "rhs_preview"); - sampleFolder = fullfile(char(folders.sampleFolder), "rhs_preview"); - rhsFolder = fullfile(sampleFolder, "acquisition"); - ensureFolder(rhsFolder); - - primaryPath = string(fullfile(rhsFolder, "rhs_representative_primary_debug.rhs")); - repeatPath = string(fullfile(rhsFolder, "rhs_representative_repeat_debug.rhs")); - edgePath = string(fullfile(rhsFolder, "rhs_valid_short_debug.rhs")); - malformedPath = string(fullfile(rhsFolder, "rhs_malformed_header_debug.rhs")); - protocolPath = string(fullfile(sampleFolder, "rhs_protocol_debug.json")); + primaryPath = sampleContext.samplePath( ... + "rhs_preview/acquisition/representative_primary.rhs"); + repeatPath = sampleContext.samplePath( ... + "rhs_preview/acquisition/representative_repeat.rhs"); + edgePath = sampleContext.samplePath( ... + "rhs_preview/acquisition/valid_short.rhs"); + malformedPath = sampleContext.samplePath( ... + "rhs_preview/acquisition/malformed_header.rhs"); + protocolPath = sampleContext.samplePath("rhs_preview/protocol.json"); channels = ["PrimaryChannel", "ReferenceChannel", "ReturnChannel", "AuxChannel"]; writeSyntheticRhs(primaryPath, struct("nBlocks", 18, "amplifierNames", channels, ... @@ -26,21 +28,25 @@ writeTextFile(malformedPath, ["not an rhs binary"; "boundary=malformed header"]); writeProtocol(protocolPath); - representative = [primaryPath; repeatPath]; - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_RHSPreview_app", ... - "description", "Anonymous RHS acquisition boundary pack for debug launch.", ... - "sampleFolder", string(sampleFolder), ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", struct( ... - "primaryRhs", primaryPath, ... - "rhsFiles", representative, ... - "rhsFolder", string(rhsFolder), ... - "protocolJson", protocolPath), ... - "boundaryFiles", struct("validShortRhs", edgePath, "malformedRhs", malformedPath)); - recordManifest(debugLog, manifest); - pack = manifest; + project = rhs_preview.projectSpec().Create(); + project.inputs.sources = [ ... + sampleContext.sourceRecord( ... + "recording", "recording", primaryPath, true), ... + sampleContext.sourceRecord( ... + "protocol", "protocol", protocolPath, false)]; + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-rhs-preview", InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact( ... + "representativePrimary", "recording", primaryPath), ... + sampleContext.artifact( ... + "representativeRepeat", "alternateRecording", repeatPath), ... + sampleContext.artifact( ... + "protocol", "protocol", protocolPath), ... + sampleContext.artifact( ... + "validShort", "boundaryInput", edgePath), ... + sampleContext.artifact("malformedHeader", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function writeProtocol(filepath) @@ -148,30 +154,6 @@ function writeQString(fid, value) fwrite(fid, uint16(value), "uint16"); end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeJson(filepath, payload) fid = fopen(char(filepath), "w"); if fid < 0 @@ -198,9 +180,3 @@ function writeTextFile(filepath, lines) value = opts.(fieldName); end end - -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/filterActionSection.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/filterActionSection.m new file mode 100644 index 000000000..dd423dc99 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/filterActionSection.m @@ -0,0 +1,14 @@ +% App-owned implementation for rhs_preview.resultFiles.filterActionSection within the rhs_preview product workflow. +function section = filterActionSection() +%FILTERACTIONSECTION Declare filter refresh and export actions. +actions = labkit.app.layout.group("filterActions", { ... + labkit.app.layout.button("refreshFolderFiles", ... + "Refresh Folder", @rhs_preview.analysisRun.refreshFilterFiles, ... + Tooltip="Rescan the selected recording folder and refresh the files matching the current RHS filter rules."), ... + labkit.app.layout.button("saveFilterRecord", ... + "Save Filter Record", @rhs_preview.resultFiles.saveFilterRecord, ... + Tooltip="Save the current reproducible RHS file-filter criteria as a JSON record.")}, ... + Layout="horizontal"); +section = labkit.app.layout.section( ... + "filterActionsSection", "Filter Actions", {actions}); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/protocolActionSection.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/protocolActionSection.m new file mode 100644 index 000000000..9deac6ed3 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/protocolActionSection.m @@ -0,0 +1,10 @@ +% App-owned implementation for rhs_preview.resultFiles.protocolActionSection within the rhs_preview product workflow. +function section = protocolActionSection() +%PROTOCOLACTIONSECTION Declare protocol-draft export. +actions = labkit.app.layout.group("protocolActions", { ... + labkit.app.layout.button("saveProtocol", ... + "Save Protocol Draft", @rhs_preview.resultFiles.saveProtocol, ... + Tooltip="Save the current channel roles, preview window, ROI, and filter references as a protocol JSON draft.")}); +section = labkit.app.layout.section( ... + "protocolActionsSection", "Protocol Actions", {actions}); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/protocolJsonStruct.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/protocolJsonStruct.m index 21b72e413..22005d247 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/protocolJsonStruct.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/protocolJsonStruct.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions and tests. Inputs are app state/channel +% Expected caller: RHS Preview direct callbacks and tests. Inputs are app state/channel % rows. Output is a jsonencode-compatible channel protocol draft. function payload = protocolJsonStruct(S) %PROTOCOLJSONSTRUCT Build a protocol draft from Preview channel-role rows. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveFilterRecord.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveFilterRecord.m new file mode 100644 index 000000000..25aa6846a --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveFilterRecord.m @@ -0,0 +1,59 @@ +% App-owned implementation for rhs_preview.resultFiles.saveFilterRecord within the rhs_preview product workflow. +function applicationState = saveFilterRecord( ... + applicationState, callbackContext) +%SAVEFILTERRECORD Write manual file labels and a standard result manifest. +rows = applicationState.session.cache.filterRows; +if ~istable(rows) || height(rows) == 0 + applicationState.session.workflow.statusMessage = ... + "Select RHS filter files before saving a filter record."; + return; +end +chosen = callbackContext.chooseOutputFile( ... + ["*.json", "Filter JSON"], "rhs_filter_record.json"); +if chosen.Cancelled + return; +end +[folder, name, extension] = outputParts(chosen.Value); +outputPath = fullfile(folder, name + extension); +model = struct( ... + "rhsFolder", commonParent(string(rows.filePath)), ... + "filterRows", rows); +rhs_preview.resultFiles.writeFilterRecordJson(model, outputPath); +output = labkit.app.result.File( ... + "rhsFilterRecord", "primary", name + extension, ... + MediaType="application/json"); +package = labkit.app.result.Package( ... + Outputs={output}, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct("recordingCount", height(rows), ... + "goodCount", sum(string(rows.label) == "good"), ... + "badCount", sum(string(rows.label) == "bad")), ... + ManifestName="rhs_filter_record.labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.lastFilterExport = struct( ... + "jsonPath", string(outputPath), ... + "manifestPath", string(written.Value)); +applicationState.session.workflow.statusMessage = ... + "Saved filter record."; +applicationState.session.workflow.lastAction = "Saved filter record"; +callbackContext.appendStatus( ... + "Saved filter record JSON: " + string(outputPath)); +end + +function [folder, name, extension] = outputParts(path) +[folder, name, extension] = fileparts(string(path)); +folder = string(folder); +name = string(name); +extension = string(extension); +if strlength(folder) == 0 + folder = string(pwd); +end +end + +function folder = commonParent(paths) +folder = ""; +if ~isempty(paths) + folder = string(fileparts(char(paths(1)))); +end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveProtocol.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveProtocol.m new file mode 100644 index 000000000..bd1881606 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveProtocol.m @@ -0,0 +1,54 @@ +% App-owned implementation for rhs_preview.resultFiles.saveProtocol within the rhs_preview product workflow. +function applicationState = saveProtocol( ... + applicationState, callbackContext) +%SAVEPROTOCOL Write the channel-role draft and standard result manifest. +rows = applicationState.session.cache.previewChannelRows; +if ~istable(rows) || height(rows) == 0 + applicationState.session.workflow.statusMessage = ... + "Select an RHS file before saving a protocol."; + return; +end +chosen = callbackContext.chooseOutputFile( ... + ["*.json", "Protocol JSON"], "rhs_protocol_draft.json"); +if chosen.Cancelled + return; +end +[folder, name, extension] = outputParts(chosen.Value); +outputPath = fullfile(folder, name + extension); +model = struct( ... + "previewChannelRows", rows, ... + "protocol", applicationState.session.cache.protocol); +rhs_preview.resultFiles.writeProtocolJson(model, outputPath); +protocol = rhs_preview.resultFiles.protocolJsonStruct(model); +applicationState.project.annotations.protocol = protocol; +applicationState.session.cache.protocol = protocol; +output = labkit.app.result.File( ... + "rhsProtocol", "primary", name + extension, ... + MediaType="application/json"); +package = labkit.app.result.Package( ... + Outputs={output}, ... + Inputs=struct("sources", applicationState.project.inputs.sources), ... + Parameters=applicationState.project.parameters, ... + Summary=struct("channelRoleCount", ... + numel(protocol.channels.roles)), ... + ManifestName="rhs_protocol_draft.labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.lastProtocolExport = struct( ... + "jsonPath", string(outputPath), ... + "manifestPath", string(written.Value)); +applicationState.session.workflow.statusMessage = ... + "Saved protocol draft."; +applicationState.session.workflow.lastAction = "Saved protocol"; +callbackContext.appendStatus( ... + "Saved protocol JSON: " + string(outputPath)); +end + +function [folder, name, extension] = outputParts(path) +[folder, name, extension] = fileparts(string(path)); +folder = string(folder); +name = string(name); +extension = string(extension); +if strlength(folder) == 0 + folder = string(pwd); +end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/writeFilterRecordJson.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/writeFilterRecordJson.m index 439e27b91..662dc33a7 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/writeFilterRecordJson.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/writeFilterRecordJson.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions. Input is app state and target JSON path. +% Expected caller: RHS Preview direct callbacks. Input is app state and target JSON path. % Output is the written path. Side effect is one manual filter record JSON. function outputPath = writeFilterRecordJson(S, outputPath) %WRITEFILTERRECORDJSON Write a Preview-authored RHS filter record. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/writeProtocolJson.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/writeProtocolJson.m index 454c4f834..bf612a1f2 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/writeProtocolJson.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/writeProtocolJson.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions. Input is an app state struct and target +% Expected caller: RHS Preview direct callbacks. Input is an app state struct and target % JSON path. Output is the written path. Side effect is one protocol JSON. function outputPath = writeProtocolJson(S, outputPath) %WRITEPROTOCOLJSON Write a Preview-authored protocol draft. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/displayFile.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/displayFile.m similarity index 79% rename from apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/displayFile.m rename to apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/displayFile.m index 79c1f7465..feb6e064f 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/displayFile.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/displayFile.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions and view helpers. Input is one path-like +% Expected caller: RHS Preview direct callbacks and view helpers. Input is one path-like % value. Output is a filename-only display string without local directories. function text = displayFile(filepath) %DISPLAYFILE Filename-only display label. diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterChanged.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterChanged.m new file mode 100644 index 000000000..b26d8e635 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterChanged.m @@ -0,0 +1,35 @@ +% App-owned implementation for rhs_preview.sourceFiles.filterChanged within the rhs_preview product workflow. +function applicationState = filterChanged( ... + applicationState, selection, callbackContext) +%FILTERCHANGED Persist label/comment ordering after bound source changes. +arguments + applicationState (1, 1) struct + selection (1, 1) labkit.app.event.ListSelection + callbackContext (1, 1) labkit.app.CallbackContext +end +rows = applicationState.session.cache.filterRows; +if ~istable(rows) || height(rows) == 0 + applicationState.project.annotations.filterLabels = strings(0, 1); + applicationState.project.annotations.filterComments = strings(0, 1); + applicationState.project.annotations.filterSourceIds = strings(0, 1); + applicationState.session.workflow.statusMessage = ... + "No RHS folder selected."; + applicationState.session.workflow.lastAction = ... + "Cleared RHS filter files"; + if isempty(selection.Indices) + callbackContext.appendStatus("Cleared RHS filter files."); + end + return; +end +applicationState.project.annotations.filterLabels = string(rows.label); +applicationState.project.annotations.filterComments = string(rows.comment); +sources = applicationState.project.inputs.sources; +mask = string({sources.role}) == "filterRecording"; +applicationState.project.annotations.filterSourceIds = ... + string({sources(mask).id}).'; +applicationState.session.workflow.statusMessage = string(sprintf( ... + "Discovered %d RHS file(s).", height(rows))); +applicationState.session.workflow.lastAction = "Discovered RHS files"; +callbackContext.appendStatus( ... + applicationState.session.workflow.statusMessage); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterSection.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterSection.m new file mode 100644 index 000000000..c5b12a6d7 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterSection.m @@ -0,0 +1,20 @@ +% App-owned implementation for rhs_preview.sourceFiles.filterSection within the rhs_preview product workflow. +function section = filterSection() +%FILTERSECTION Declare the editable RHS filter-file collection. +files = labkit.app.layout.fileList("rhsFolder", ... + Label="RHS filter files", ... + Filters=["*.rhs", "Intan RHS files"], ... + SelectionMode="multiple", ... + ChooseLabel="Add RHS files or folder", ... + ChooseTooltip="Add Intan RHS recordings to evaluate against the current reusable file-filter rules.", ... + FolderLabel="Add folder", ... + RecursiveFolderLabel="Add folder tree", ... + RemoveLabel="Remove selected", ClearLabel="Clear all", ... + EmptyText="No RHS files selected", ... + Bind="project.inputs.sources", ... + SourceRole="filterRecording", SourceIdPrefix="filter", ... + Required=true, ... + OnSelectionChanged=@rhs_preview.sourceFiles.filterChanged); +section = labkit.app.layout.section( ... + "folderSection", "RHS filter files", {files}); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/loadProtocol.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/loadProtocol.m index 09771c5d2..071022999 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/loadProtocol.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/loadProtocol.m @@ -1,4 +1,4 @@ -% Expected caller: rhs_preview.definitionActions. Input is an optional protocol JSON path. +% Expected caller: RHS Preview direct callbacks. Input is an optional protocol JSON path. % Output is the decoded protocol struct or an empty struct. No UI handles are % touched. function protocol = loadProtocol(protocolFile) diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/pathsForRole.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/pathsForRole.m index 83339bab8..9ea04dba2 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/pathsForRole.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/pathsForRole.m @@ -1,6 +1,6 @@ % Return resolved source paths for one RHS Preview source role. The App owns -% role selection and ordering; Runtime V2 owns portable-reference decoding. -function paths = pathsForRole(sources, role) +% role selection and ordering; App SDK runtime owns portable-reference decoding. +function paths = pathsForRole(sources, role, callbackContext) if isempty(sources) paths = strings(0, 1); return; @@ -10,6 +10,18 @@ 'rhs_preview:InvalidSourceRole', ... 'RHS Preview source role must be nonempty scalar text.'); selected = string({sources.role}) == role; - ids = string({sources(selected).id}); - paths = labkit.ui.runtime.sourcePaths(sources, ids); + selectedSources = sources(selected); + if nargin >= 3 + paths = callbackContext.resolveSourcePaths(selectedSources); + return; + end + paths = strings(numel(selectedSources), 1); + for k = 1:numel(selectedSources) + if isfield(selectedSources, "reference") + paths(k) = string( ... + selectedSources(k).reference.originalPath); + elseif isfield(selectedSources, "path") + paths(k) = string(selectedSources(k).path); + end + end end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/protocolChanged.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/protocolChanged.m new file mode 100644 index 000000000..67274b0e3 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/protocolChanged.m @@ -0,0 +1,35 @@ +% App-owned implementation for rhs_preview.sourceFiles.protocolChanged within the rhs_preview product workflow. +function applicationState = protocolChanged( ... + applicationState, selection, callbackContext) +%PROTOCOLCHANGED Persist the selected protocol and rebuild role rows. +arguments + applicationState (1, 1) struct + selection (1, 1) labkit.app.event.ListSelection + callbackContext (1, 1) labkit.app.CallbackContext +end +if isempty(selection.Indices) || ... + strlength(applicationState.session.cache.protocolPath) == 0 + applicationState.project.annotations.protocol = struct(); + applicationState.session.cache.protocol = struct(); +else + applicationState.project.annotations.protocol = ... + applicationState.session.cache.protocol; +end +applicationState.project.annotations.previewChannelRows = table(); +applicationState.session = rhs_preview.analysisRun.rebuildPreviewRows( ... + applicationState.session, applicationState.project.parameters, table()); +applicationState.project.annotations.previewChannelRows = ... + applicationState.session.cache.previewChannelRows; +if applicationState.session.view.autoWindow + applicationState.session = rhs_preview.analysisRun.applyAdaptiveWindow( ... + applicationState.session, applicationState.project.parameters); +end +[applicationState.session, ~, message] = ... + rhs_preview.analysisRun.readCurrentPreview( ... + applicationState.session, applicationState.project.parameters, ... + "Selected protocol", false); +applicationState.session.workflow.lastAction = "Selected protocol"; +if strlength(message) > 0 + callbackContext.appendStatus(message); +end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/protocolSection.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/protocolSection.m new file mode 100644 index 000000000..1ea215dc7 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/protocolSection.m @@ -0,0 +1,17 @@ +% App-owned implementation for rhs_preview.sourceFiles.protocolSection within the rhs_preview product workflow. +function section = protocolSection() +%PROTOCOLSECTION Declare the optional protocol JSON selector. +file = labkit.app.layout.fileList("protocolFile", ... + Label="Protocol JSON", ... + Filters=["*.json", "Protocol JSON"], ... + SelectionMode="single", MaxFiles=1, ... + ChooseLabel="Choose JSON", ... + ChooseTooltip="Choose an optional protocol JSON defining channel roles, preview context, and response ROI.", ... + EmptyText="No protocol selected", ... + Bind="project.inputs.sources", ... + SourceRole="protocol", SourceIdPrefix="protocol", ... + Required=false, ... + OnSelectionChanged=@rhs_preview.sourceFiles.protocolChanged); +section = labkit.app.layout.section( ... + "protocolSection", "Protocol (optional)", {file}); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/recordingChanged.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/recordingChanged.m new file mode 100644 index 000000000..40b2dc286 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/recordingChanged.m @@ -0,0 +1,26 @@ +% App-owned implementation for rhs_preview.sourceFiles.recordingChanged within the rhs_preview product workflow. +function applicationState = recordingChanged( ... + applicationState, selection, callbackContext) +%RECORDINGCHANGED Normalize family and durable rows after source rebuilding. +arguments + applicationState (1, 1) struct + selection (1, 1) labkit.app.event.ListSelection + callbackContext (1, 1) labkit.app.CallbackContext +end +if isempty(selection.Indices) || ... + strlength(applicationState.session.cache.rhsPath) == 0 + return; +end +normalized = rhs_preview.analysisRun.channelSelection( ... + applicationState.session.cache.info, ... + applicationState.project.parameters.family, ""); +applicationState.project.parameters.family = normalized.family; +applicationState.session = rhs_preview.analysisRun.rebuildPreviewRows( ... + applicationState.session, applicationState.project.parameters, ... + applicationState.project.annotations.previewChannelRows); +applicationState.project.annotations.previewChannelRows = ... + applicationState.session.cache.previewChannelRows; +callbackContext.appendStatus( ... + "Indexed RHS file: " + rhs_preview.sourceFiles.displayFile( ... + applicationState.session.cache.rhsPath)); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/rhsSection.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/rhsSection.m new file mode 100644 index 000000000..ed6e340b3 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/rhsSection.m @@ -0,0 +1,16 @@ +% App-owned implementation for rhs_preview.sourceFiles.rhsSection within the rhs_preview product workflow. +function section = rhsSection() +%RHSSECTION Declare the primary RHS recording selector. +file = labkit.app.layout.fileList("rhsFile", ... + Label="RHS file", ... + Filters=["*.rhs", "Intan RHS files"], ... + SelectionMode="single", MaxFiles=1, ... + ChooseLabel="Choose RHS", ... + ChooseTooltip="Choose an Intan RHS recording for channel inspection and bounded waveform preview.", ... + EmptyText="No RHS file selected", ... + Bind="project.inputs.sources", ... + SourceRole="recording", SourceIdPrefix="recording", ... + Required=true, ... + OnSelectionChanged=@rhs_preview.sourceFiles.recordingChanged); +section = labkit.app.layout.section("rhsSection", "RHS File", {file}); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 066b77434..000000000 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,193 +0,0 @@ -% Expected caller: rhs_preview.definitionActions. Inputs are app callback handles. Output -% is a data-only UI 5 workbench layout. -function layout = buildWorkbenchLayout(callbacks) -%BUILDWORKBENCHLAYOUT Build the RHS Preview UI layout. - - layout = labkit.ui.layout.workbench("rhs_preview", "RHS Preview", ... - "controlTabs", {setupTab(callbacks), protocolTab(callbacks), ... - filterTab(callbacks), reviewTab(callbacks), logTab()}, ... - "workspace", previewWorkspace()); -end - -function tab = setupTab(callbacks) - tab = labkit.ui.layout.tab("setup", "Setup", { ... - rhsSection(callbacks), ... - folderSection(callbacks), ... - previewOptionsSection(), ... - previewActionsSection(callbacks)}); -end - -function tab = protocolTab(callbacks) - tab = labkit.ui.layout.tab("protocol", "Protocol", { ... - protocolSection(callbacks), ... - protocolActionsSection(callbacks), ... - protocolRolesSection(callbacks)}); -end - -function tab = filterTab(callbacks) - tab = labkit.ui.layout.tab("filter", "Filter", { ... - filterActionsSection(callbacks), ... - filterTableSection(callbacks)}); -end - -function tab = reviewTab(~) - tab = labkit.ui.layout.tab("review", "Review", { ... - summarySection(), ... - detailsSection()}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("logPanel", "Log", ... - "value", {"Ready."})})}); -end - -function section = rhsSection(callbacks) - section = labkit.ui.layout.section("rhsSection", "RHS File", { ... - labkit.ui.layout.filePanel("rhsFile", "RHS file", ... - "mode", "single", ... - "filters", {'*.rhs', 'Intan RHS files'}, ... - "chooseLabel", "Choose RHS", ... - "status", "No RHS file selected", ... - "onChoose", callbacks.rhsChosen)}); -end - -function section = folderSection(callbacks) - section = labkit.ui.layout.section("folderSection", "RHS filter files", { ... - labkit.ui.layout.filePanel("rhsFolder", "RHS filter files", ... - "selectionMode", "multiple", ... - "filters", {'*.rhs', 'Intan RHS files'}, ... - "chooseLabel", "Add RHS files or folder", ... - "removeLabel", "Remove selected", ... - "clearLabel", "Clear all", ... - "status", "No RHS files selected", ... - "onChoose", callbacks.folderChosen, ... - "onRemove", callbacks.folderRemoved, ... - "onClear", callbacks.folderCleared)}); -end - -function section = protocolSection(callbacks) - section = labkit.ui.layout.section("protocolSection", "Protocol (optional)", { ... - labkit.ui.layout.filePanel("protocolFile", "Protocol JSON", ... - "mode", "single", ... - "filters", {'*.json', 'Protocol JSON'}, ... - "chooseLabel", "Choose JSON", ... - "status", "No protocol selected", ... - "onChoose", callbacks.protocolChosen)}); -end - -function section = previewOptionsSection() - section = labkit.ui.layout.section("previewOptionsSection", "Preview Window", { ... - labkit.ui.layout.field("channelFamily", "Family", ... - "kind", "dropdown", ... - "items", {"amplifier"}, ... - "value", "amplifier", ... - "enabled", false, ... - "Bind", "project.parameters.family", ... - "Event", "settingChanged"), ... - labkit.ui.layout.panner("windowStartPanner", "Pan", ... - "limits", [0 1], ... - "value", 0, ... - "enabled", false, ... - "stepFraction", 0.002, ... - "maxStep", 0.250, ... - "showTicks", false, ... - "Bind", "session.view.windowStartSec", ... - "Event", "settingChanged"), ... - labkit.ui.layout.field("windowSummary", "Window", ... - "kind", "readonly", ... - "value", "Select RHS to estimate preview length."), ... - labkit.ui.layout.field("maxPreviewChannels", "Max channels", ... - "kind", "number", ... - "value", 8, ... - "Bind", "project.parameters.maxPreviewChannels", ... - "Event", "settingChanged"), ... - labkit.ui.layout.field("statusField", "Status", ... - "kind", "readonly", ... - "value", "No RHS file selected.")}); -end - -function section = protocolRolesSection(callbacks) - section = labkit.ui.layout.section("protocolRolesSection", ... - "Protocol Channel Roles", { ... - labkit.ui.layout.resultTable("previewChannelsTable", ... - "Roles", ... - "columns", {"Plot", "Role", "Label", "Channel"}, ... - "data", rhs_preview.userInterface.previewChannelsTableData(struct()), ... - "columnEditable", [true true true false], ... - "columnFormat", {'logical', 'char', 'char', 'char'}, ... - "onCellEdit", callbacks.previewChannelEdited)}); -end - -function section = previewActionsSection(callbacks) - section = labkit.ui.layout.section("previewActionsSection", "Preview Actions", { ... - labkit.ui.layout.group("rhsActions", "", { ... - labkit.ui.layout.action("refreshPreviewWindow", ... - "Refresh Preview", ... - callbacks.refreshPreviewWindow, ... - "priority", "primary"), ... - labkit.ui.layout.action("zoomToRoiWindow", ... - "Zoom to ROI", ... - callbacks.zoomToRoi), ... - labkit.ui.layout.action("resetWorkflow", ... - "Reset", ... - callbacks.resetWorkflow)})}); -end - -function section = protocolActionsSection(callbacks) - section = labkit.ui.layout.section("protocolActionsSection", ... - "Protocol Actions", { ... - labkit.ui.layout.group("protocolActions", "", { ... - labkit.ui.layout.action("saveProtocol", ... - "Save Protocol Draft", ... - callbacks.saveProtocol, ... - "priority", "primary")})}); -end - -function section = filterActionsSection(callbacks) - section = labkit.ui.layout.section("filterActionsSection", "Filter Actions", { ... - labkit.ui.layout.group("filterActions", "", { ... - labkit.ui.layout.action("refreshFolderFiles", ... - "Refresh Folder", ... - callbacks.refreshFolderFiles, ... - "priority", "primary"), ... - labkit.ui.layout.action("saveFilterRecord", ... - "Save Filter Record", ... - callbacks.saveFilterRecord)})}); -end - -function section = filterTableSection(callbacks) - section = labkit.ui.layout.section("filterTableSection", "File Filter", { ... - labkit.ui.layout.resultTable("fileFilterTable", ... - "Files", ... - "columns", {"Label", "File", "Comment"}, ... - "data", rhs_preview.userInterface.fileFilterTableData(struct()), ... - "columnEditable", [true false true], ... - "columnFormat", {'char', 'char', 'char'}, ... - "onCellEdit", callbacks.fileFilterEdited)}); -end - -function section = summarySection() - section = labkit.ui.layout.section("summarySection", "Summary", { ... - labkit.ui.layout.resultTable("summaryTable", ... - "RHS Summary", ... - "columns", {"Field", "Value"}, ... - "data", rhs_preview.userInterface.summaryTableData(struct()))}); -end - -function section = detailsSection() - section = labkit.ui.layout.section("detailsSection", "Details", { ... - labkit.ui.layout.statusPanel("details", "Details", ... - "value", rhs_preview.userInterface.detailLines(struct()))}); -end - -function workspace = previewWorkspace() - workspace = labkit.ui.layout.workspace("workspace", "Preview", { ... - labkit.ui.layout.previewArea("preview", "Stacked Waveforms", ... - "layout", "single", ... - "axisIds", {"main"}, ... - "axisTitles", {"RHS Stacked Preview"}, ... - "xLabels", {"Time (s)"}, ... - "yLabels", {"Channel"})}); -end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/detailLines.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/detailLines.m deleted file mode 100644 index d2c532f5a..000000000 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/detailLines.m +++ /dev/null @@ -1,110 +0,0 @@ -% Expected caller: rhs_preview.definitionActions and rhs_preview.userInterface.buildWorkbenchLayout. Input is -% the app state struct. Output is a cell array of display lines for a status -% panel. No UI handles or app state are mutated. -function lines = detailLines(S) -%DETAILLINES Build RHS preview detail lines for display. - - S = normalizeState(S); - lines = { ... - char("RHS file: " + displayFile(S.rhsFile)); ... - char("Protocol: " + displayFile(S.protocolFile)); ... - char("Status: " + string(S.statusMessage)); ... - char("Preview family: " + string(S.family)); ... - char("Selected channels: " + selectedChannelsText(S)); ... - char("Filter rows: " + filterRowsText(S)); ... - char("Window: " + sprintf("%.6g", S.windowStartSec) + " s + " + ... - sprintf("%.6g", S.windowDurationSec) + " s"); ... - char("ROI: " + roiText(S)); ... - char("Channels: " + channelNamesText(S.info)); ... - char("Last action: " + string(S.lastAction))}; -end - -function S = normalizeState(S) - if ~isstruct(S) - S = struct(); - end - defaults = struct( ... - "rhsFile", "", ... - "protocolFile", "", ... - "family", "amplifier", ... - "previewChannelRows", table(), ... - "filterRows", table(), ... - "windowStartSec", 0, ... - "windowDurationSec", 0.050, ... - "roiSec", [NaN NaN], ... - "info", [], ... - "statusMessage", "No RHS file selected.", ... - "lastAction", "Ready"); - names = fieldnames(defaults); - for k = 1:numel(names) - if ~isfield(S, names{k}) - S.(names{k}) = defaults.(names{k}); - end - end -end - -function text = filterRowsText(S) - text = "none"; - if ~isfield(S, "filterRows") || ~istable(S.filterRows) || ... - height(S.filterRows) == 0 - return; - end - rows = S.filterRows; - goodCount = sum(string(rows.label) == "good"); - badCount = sum(string(rows.label) == "bad"); - text = string(sprintf("%d total, %d good, %d bad", ... - height(rows), goodCount, badCount)); -end - -function text = roiText(S) - roiSec = double(S.roiSec); - if numel(roiSec) ~= 2 || any(~isfinite(roiSec)) || diff(roiSec) <= 0 - text = "none"; - else - text = string(sprintf("%.6g to %.6g s", roiSec(1), roiSec(2))); - end -end - -function text = displayFile(filepath) - filepath = string(filepath); - if strlength(filepath) == 0 - text = "none"; - return; - end - [~, name, ext] = fileparts(char(filepath)); - text = string([name ext]); -end - -function text = channelNamesText(info) - text = "n/a"; - if ~isstruct(info) || ~isfield(info, "channelFamilies") - return; - end - channels = info.channelFamilies.amplifier; - if isempty(channels) - text = "none"; - return; - end - names = string({channels.nativeName}); - if numel(names) > 8 - names = [names(1:8), "..."]; - end - text = strjoin(names, ", "); -end - -function text = selectedChannelsText(S) - text = "none"; - if ~isfield(S, "previewChannelRows") || ~istable(S.previewChannelRows) || ... - height(S.previewChannelRows) == 0 - return; - end - rows = S.previewChannelRows(logical(S.previewChannelRows.preview), :); - if height(rows) == 0 - return; - end - names = string(rows.channel(:)).'; - if numel(names) > 8 - names = [names(1:8), "..."]; - end - text = string(sprintf("%d (%s)", height(rows), char(strjoin(names, ", ")))); -end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/drawStackedPreview.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/drawStackedPreview.m deleted file mode 100644 index 41c12d0ac..000000000 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/drawStackedPreview.m +++ /dev/null @@ -1,58 +0,0 @@ -% Expected caller: rhs_preview.definitionActions. Inputs are one axes handle and app state -% with an optional preview window. Side effect redraws stacked RHS waveforms. -function drawStackedPreview(ax, S) -%DRAWSTACKEDPREVIEW Draw time-aligned stacked RHS preview traces. - - labkit.ui.plot.clear(ax, "ResetScale", true); - title(ax, 'RHS Stacked Preview'); - xlabel(ax, 'Time (s)'); - ylabel(ax, 'Channel'); - grid(ax, 'on'); - ax.Box = 'on'; - - if isempty(S.preview) || isempty(S.preview.values) - textHandle = text(ax, 0.5, 0.5, 'Select channels and read a window', ... - 'Units', 'normalized', 'HorizontalAlignment', 'center', ... - 'Color', [0.35 0.35 0.35]); - textHandle.HitTest = 'off'; - ax.XLim = [0 1]; - ax.YLim = [0 1]; - ax.YTick = []; - return; - end - - timeSec = double(S.preview.timeSec(:)); - values = double(S.preview.values); - channels = string(S.preview.channels(:)); - nChannels = size(values, 2); - if isempty(timeSec) || nChannels == 0 - return; - end - - offsets = (1:nChannels).'; - hold(ax, 'on'); - colors = lines(max(nChannels, 1)); - for k = 1:nChannels - y = normalizeTrace(values(:, k)); - lineHandle = plot(ax, timeSec, y + offsets(k), 'LineWidth', 0.8, ... - 'Color', colors(k, :)); - lineHandle.HitTest = 'off'; - end - hold(ax, 'off'); - ax.YTick = offsets; - ax.YTickLabel = cellstr(channels); - ax.YLim = [0.25 nChannels + 0.75]; - if numel(timeSec) > 1 - ax.XLim = [timeSec(1) timeSec(end)]; - end -end - -function y = normalizeTrace(y) - y = fillmissing(double(y(:)), "linear", "EndValues", "nearest"); - y = y - median(y, "omitnan"); - scale = max(abs(y), [], "omitnan"); - if ~isfinite(scale) || scale <= 0 - scale = 1; - end - y = 0.40 .* (y ./ scale); -end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m deleted file mode 100644 index 3797fb2ac..000000000 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,123 +0,0 @@ -% Expected caller: Runtime V2. Input is canonical RHS Preview state. Output -% is deterministic controls, tables, waveform model, and controlled interval -% interaction with no UI registry access or side effects. -function view = presentWorkbench(state) - context = previewContext(state); - selection = rhs_preview.userInterface.channelSelection( ... - context.info, state.project.parameters.family, ""); - bounds = rhs_preview.analysisRun.previewWindowBounds(context); - hasChannels = rhs_preview.analysisRun.hasReadableChannel(context); - hasRows = height(context.previewChannelRows) > 0; - hasFilters = height(context.filterRows) > 0; - rhsPaths = rhs_preview.sourceFiles.pathsForRole( ... - state.project.inputs.sources, "recording"); - filterPaths = rhs_preview.sourceFiles.pathsForRole( ... - state.project.inputs.sources, "filterRecording"); - protocolPaths = rhs_preview.sourceFiles.pathsForRole( ... - state.project.inputs.sources, "protocol"); - - view = struct(); - view.controls.rhsFile = sourcePanel( ... - rhsPaths, "No RHS file selected"); - view.controls.rhsFolder = sourcePanel( ... - filterPaths, "No RHS files selected"); - view.controls.protocolFile = sourcePanel( ... - protocolPaths, "No protocol selected"); - view.controls.channelFamily = struct(); - view.controls.channelFamily.Items = cellstr(selection.families); - view.controls.channelFamily.Value = selection.family; - view.controls.channelFamily.Enabled = selection.hasChannels; - view.controls.windowStartPanner = struct( ... - "Limits", [0 max(bounds.maxStartSec, eps)], ... - "Enabled", bounds.hasIndexedDuration && bounds.maxStartSec > 0); - view.controls.windowSummary = valueSpec( ... - rhs_preview.analysisRun.windowSummaryText(context)); - view.controls.statusField = valueSpec(context.statusMessage); - view.controls.refreshPreviewWindow = enabledSpec(hasChannels); - view.controls.zoomToRoiWindow = enabledSpec( ... - hasChannels && rhs_preview.analysisRun.hasValidRoi(context)); - view.controls.saveProtocol = enabledSpec(hasRows); - view.controls.refreshFolderFiles = enabledSpec(hasFilters); - view.controls.saveFilterRecord = enabledSpec(hasFilters); - view.controls.previewChannelsTable = tableSpec( ... - rhs_preview.userInterface.previewChannelsTableData(context)); - view.controls.fileFilterTable = tableSpec( ... - rhs_preview.userInterface.fileFilterTableData(context)); - view.controls.summaryTable = tableSpec( ... - rhs_preview.userInterface.summaryTableData(context)); - view.controls.details = valueSpec( ... - rhs_preview.userInterface.detailLines(context)); - view.previews.preview = struct( ... - "Renderer", "stackedPreview", "Model", context); - if ~isempty(context.preview) && ~isempty(context.preview.timeSec) - view.interactions.previewRange = struct( ... - "Kind", "interval", "Targets", "preview", ... - "Value", context.roiSec, "Event", "previewRoiEdited", ... - "ScrollEvent", "previewWindowScrolled", ... - "ChangePolicy", "commit", ... - "Options", struct("color", [0.75 0.55 0.12], ... - "faceColor", [0.94 0.78 0.28], "faceAlpha", 0.22)); - end -end - -function spec = sourcePanel(paths, emptyStatus) - files = repmat(struct("id", "", "path", "", "status", ""), ... - 0, 1); - if ~isempty(paths) - files = repmat(struct("id", "", "path", "", "status", ""), ... - numel(paths), 1); - end - for k = 1:numel(paths) - files(k) = struct("id", "item" + string(k), ... - "path", paths(k), "status", ""); - end - status = string(emptyStatus); - if ~isempty(files) - status = string(numel(files)) + " file(s) selected"; - end - spec = struct(); - spec.Files = files; - spec.Status = status; -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = enabledSpec(value) - spec = struct("Enabled", logical(value)); -end - -function spec = tableSpec(value) - spec = struct(); - spec.Data = value; -end - -function context = previewContext(state) - context = struct( ... - "rhsFile", state.session.cache.rhsPath, ... - "rhsFolder", commonParent(rhs_preview.sourceFiles.pathsForRole( ... - state.project.inputs.sources, "filterRecording")), ... - "protocolFile", state.session.cache.protocolPath, ... - "protocol", state.project.annotations.protocol, ... - "family", state.project.parameters.family, ... - "previewChannelRows", state.session.cache.previewChannelRows, ... - "filterRows", state.session.cache.filterRows, ... - "windowStartSec", state.session.view.windowStartSec, ... - "windowDurationSec", state.session.view.windowDurationSec, ... - "roiSec", state.session.view.roiSec, ... - "autoWindow", state.session.view.autoWindow, ... - "maxPreviewChannels", state.project.parameters.maxPreviewChannels, ... - "info", state.session.cache.info, "index", state.session.cache.index, ... - "preview", state.session.cache.preview, ... - "statusMessage", state.session.workflow.statusMessage, ... - "lastAction", state.session.workflow.lastAction); -end - -function folder = commonParent(paths) - folder = ""; - if ~isempty(paths) - folder = string(fileparts(char(paths(1)))); - end -end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/summaryTableData.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/summaryTableData.m deleted file mode 100644 index ec74c223c..000000000 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+userInterface/summaryTableData.m +++ /dev/null @@ -1,114 +0,0 @@ -% Expected caller: rhs_preview.definitionActions and rhs_preview.userInterface.buildWorkbenchLayout. Input is -% the app state struct. Output is a 2-column cell table for a resultTable. -% No UI handles or app state are mutated. -function data = summaryTableData(S) -%SUMMARYTABLEDATA Build RHS preview summary rows. - - S = normalizeState(S); - info = S.info; - data = { ... - 'RHS file', displayFile(S.rhsFile); ... - 'RHS folder', displayFile(S.rhsFolder); ... - 'Protocol', displayFile(S.protocolFile); ... - 'Status', char(S.statusMessage); ... - 'Sample rate', displayNumber(fieldValue(info, "sampleRateHz"), " Hz"); ... - 'Duration', displayNumber(fieldValue(info, "durationSec"), " s"); ... - 'Samples', displayInteger(fieldValue(info, "sampleCount")); ... - 'Blocks exact', displayLogical(fieldValue(info, "exactBlocks")); ... - 'Amplifier channels', displayInteger(channelCount(info, "amplifier")); ... - 'ADC channels', displayInteger(channelCount(info, "boardAdc")); ... - 'Digital inputs', displayInteger(channelCount(info, "boardDigIn")); ... - 'Filter files', displayInteger(filterCount(S)); ... - 'Good files', displayInteger(goodFilterCount(S)); ... - 'Preview family', char(S.family); ... - 'Last action', char(S.lastAction)}; -end - -function S = normalizeState(S) - if ~isstruct(S) - S = struct(); - end - defaults = struct( ... - "rhsFile", "", ... - "rhsFolder", "", ... - "protocolFile", "", ... - "family", "amplifier", ... - "filterRows", table(), ... - "info", [], ... - "statusMessage", "No RHS file selected.", ... - "lastAction", "Ready"); - names = fieldnames(defaults); - for k = 1:numel(names) - if ~isfield(S, names{k}) - S.(names{k}) = defaults.(names{k}); - end - end -end - -function n = filterCount(S) - n = NaN; - if isfield(S, "filterRows") && istable(S.filterRows) - n = height(S.filterRows); - end -end - -function n = goodFilterCount(S) - n = NaN; - if isfield(S, "filterRows") && istable(S.filterRows) && ... - ismember("label", S.filterRows.Properties.VariableNames) - n = sum(string(S.filterRows.label) == "good"); - end -end - -function value = fieldValue(S, name) - value = NaN; - if isstruct(S) && isfield(S, name) - value = S.(name); - end -end - -function n = channelCount(info, family) - n = NaN; - if isstruct(info) && isfield(info, "channelFamilies") && ... - isfield(info.channelFamilies, family) - n = numel(info.channelFamilies.(family)); - end -end - -function text = displayFile(filepath) - filepath = string(filepath); - if strlength(filepath) == 0 - text = 'none'; - return; - end - [~, name, ext] = fileparts(char(filepath)); - text = [name ext]; -end - -function text = displayNumber(value, unit) - if isnumeric(value) && isscalar(value) && isfinite(value) - text = sprintf("%.6g%s", double(value), unit); - else - text = 'n/a'; - end -end - -function text = displayInteger(value) - if isnumeric(value) && isscalar(value) && isfinite(value) - text = sprintf("%d", round(double(value))); - else - text = 'n/a'; - end -end - -function text = displayLogical(value) - if islogical(value) && isscalar(value) - if value - text = 'yes'; - else - text = 'no'; - end - else - text = 'n/a'; - end -end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/buildLayout.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/buildLayout.m new file mode 100644 index 000000000..a4f2259c3 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/buildLayout.m @@ -0,0 +1,39 @@ +% App-owned implementation for rhs_preview.workbench.buildLayout within the rhs_preview product workflow. +function layout = buildLayout() +%BUILDLAYOUT Assemble the complete five-page RHS Preview product surface. +controls = { ... + labkit.app.layout.tab("setup", "Setup", { ... + rhs_preview.sourceFiles.rhsSection(), ... + rhs_preview.sourceFiles.filterSection(), ... + rhs_preview.analysisRun.previewOptionsSection(), ... + rhs_preview.analysisRun.previewActionsSection()}), ... + labkit.app.layout.tab("protocol", "Protocol", { ... + rhs_preview.sourceFiles.protocolSection(), ... + rhs_preview.resultFiles.protocolActionSection(), ... + rhs_preview.analysisRun.protocolRolesSection()}), ... + labkit.app.layout.tab("filter", "Filter", { ... + rhs_preview.resultFiles.filterActionSection(), ... + rhs_preview.analysisRun.filterTableSection()}), ... + labkit.app.layout.tab("review", "Review", { ... + labkit.app.layout.section("summarySection", "Summary", { ... + labkit.app.layout.dataTable("summaryTable", ... + Title="RHS Summary", Columns=["Field", "Value"])}), ... + labkit.app.layout.section("detailsSection", "Details", { ... + labkit.app.layout.statusPanel("details", Title="Details")})}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("logPanel", Title="Log")})})}; +interaction = labkit.app.interaction.interval( ... + "previewRange", @rhs_preview.analysisRun.editRange, ... + OnScrolled=@rhs_preview.analysisRun.scrollWindow, ... + Style=struct("color", [0.75 0.55 0.12], ... + "faceColor", [0.94 0.78 0.28], "faceAlpha", 0.22)); +preview = labkit.app.layout.plotArea( ... + "preview", @rhs_preview.analysisRun.drawPreview, ... + Title="Stacked Waveforms", ... + AxisIds="main", AxisTitles="RHS Stacked Preview", ... + XLabels="Time (s)", YLabels="Channel", ... + ScrollZoomAxes="x", Interactions={interaction}); +workspace = labkit.app.layout.workspace(preview, Title="Preview"); +layout = labkit.app.layout.workbench(controls, Workspace=workspace); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/present.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/present.m new file mode 100644 index 000000000..827bad658 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/present.m @@ -0,0 +1,81 @@ +% App-owned implementation for rhs_preview.workbench.present within the rhs_preview product workflow. +function view = present(applicationState) +%PRESENT Build the complete derived RHS Preview visible state. +context = presentationContext(applicationState); +selection = rhs_preview.analysisRun.channelSelection( ... + context.info, context.family, ""); +bounds = rhs_preview.analysisRun.previewWindowBounds(context); +hasChannels = rhs_preview.analysisRun.hasReadableChannel(context); +hasRows = height(context.previewChannelRows) > 0; +hasFilters = height(context.filterRows) > 0; +hasPreview = isstruct(context.preview) && ... + isfield(context.preview, "timeSec") && ... + ~isempty(context.preview.timeSec); + +view = labkit.app.view.Snapshot() ... + .choices("channelFamily", selection.families) ... + .value("channelFamily", selection.family) ... + .enabled("channelFamily", selection.hasChannels) ... + .limits("windowStartPanner", [0 max(bounds.maxStartSec, eps)]) ... + .enabled("windowStartPanner", ... + bounds.hasIndexedDuration && bounds.maxStartSec > 0) ... + .text("windowSummary", ... + rhs_preview.analysisRun.windowSummaryText(context)) ... + .text("statusField", context.statusMessage) ... + .enabled("refreshPreviewWindow", hasChannels) ... + .enabled("zoomToRoiWindow", ... + hasChannels && rhs_preview.analysisRun.hasValidRoi(context)) ... + .enabled("saveProtocol", hasRows) ... + .enabled("refreshFolderFiles", hasFilters) ... + .enabled("saveFilterRecord", hasFilters) ... + .tableData("previewChannelsTable", ... + rhs_preview.analysisRun.previewChannelsTableData(context), ... + Columns=["Plot", "Role", "Label", "Channel"], ... + ColumnEditable=[true true true false]) ... + .tableData("fileFilterTable", ... + rhs_preview.analysisRun.fileFilterTableData(context), ... + Columns=["Label", "File", "Comment"], ... + ColumnEditable=[true false true]) ... + .tableData("summaryTable", ... + rhs_preview.analysisRun.summaryTableData(context), ... + Columns=["Field", "Value"]) ... + .text("details", join(string( ... + rhs_preview.analysisRun.detailLines(context)), newline)) ... + .renderPlot("preview", context) ... + .interval("previewRange", context.roiSec, Enabled=hasPreview); +end + +function context = presentationContext(applicationState) +session = applicationState.session; +project = applicationState.project; +filterPaths = strings(0, 1); +if istable(session.cache.filterRows) && ... + ismember("filePath", session.cache.filterRows.Properties.VariableNames) + filterPaths = string(session.cache.filterRows.filePath); +end +context = struct( ... + "rhsFile", session.cache.rhsPath, ... + "rhsFolder", commonParent(filterPaths), ... + "protocolFile", session.cache.protocolPath, ... + "protocol", session.cache.protocol, ... + "family", project.parameters.family, ... + "maxPreviewChannels", project.parameters.maxPreviewChannels, ... + "previewChannelRows", session.cache.previewChannelRows, ... + "filterRows", session.cache.filterRows, ... + "windowStartSec", session.view.windowStartSec, ... + "windowDurationSec", session.view.windowDurationSec, ... + "roiSec", session.view.roiSec, ... + "autoWindow", session.view.autoWindow, ... + "info", session.cache.info, ... + "index", session.cache.index, ... + "preview", session.cache.preview, ... + "statusMessage", session.workflow.statusMessage, ... + "lastAction", session.workflow.lastAction); +end + +function folder = commonParent(paths) +folder = ""; +if ~isempty(paths) + folder = string(fileparts(char(paths(1)))); +end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/resetWorkflow.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/resetWorkflow.m new file mode 100644 index 000000000..92cc868e3 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/resetWorkflow.m @@ -0,0 +1,10 @@ +% App-owned implementation for rhs_preview.workbench.resetWorkflow within the rhs_preview product workflow. +function applicationState = resetWorkflow( ... + applicationState, callbackContext) +%RESETWORKFLOW Restore a new RHS Preview project and transient session. +schema = rhs_preview.projectSpec(); +applicationState.project = schema.Create(); +applicationState.session = rhs_preview.createSession( ... + applicationState.project, callbackContext); +callbackContext.appendStatus("Reset RHS Preview state."); +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/createSession.m b/apps/neurophysiology/rhs_preview/+rhs_preview/createSession.m index c533bfcbf..84fe7a066 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/createSession.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/createSession.m @@ -1,14 +1,16 @@ % Rebuild transient indexing, decoded preview windows, filter rows, selection, % time view, status, and workflow log from one validated RHS Preview project. -function session = createSession(project) +function session = createSession(project, callbackContext) session = emptySession(); session.cache.protocol = project.annotations.protocol; session.cache.previewChannelRows = project.annotations.previewChannelRows; protocolPaths = rhs_preview.sourceFiles.pathsForRole( ... - project.inputs.sources, "protocol"); + project.inputs.sources, "protocol", callbackContext); rhsPaths = rhs_preview.sourceFiles.pathsForRole( ... - project.inputs.sources, "recording"); + project.inputs.sources, "recording", callbackContext); filterPaths = rhs_preview.sourceFiles.pathsForRole( ... + project.inputs.sources, "filterRecording", callbackContext); + filterIds = sourceIdsForRole( ... project.inputs.sources, "filterRecording"); if ~isempty(protocolPaths) session.cache.protocolPath = protocolPaths(1); @@ -24,14 +26,11 @@ end session.cache.index = index; session.cache.info = index.info; - if isempty(session.cache.previewChannelRows) - session.cache.previewChannelRows = rhs_preview.analysisRun.channelRows( ... - index.info, project.parameters.family, ... - project.parameters.maxPreviewChannels, session.cache.protocol); - end - session.view.windowDurationSec = ... - rhs_preview.analysisRun.suggestedPreviewDurationSec(index, ... - session.cache.previewChannelRows, project.parameters.maxPreviewChannels); + session = rhs_preview.analysisRun.rebuildPreviewRows( ... + session, project.parameters, ... + project.annotations.previewChannelRows); + session = rhs_preview.analysisRun.applyAdaptiveWindow( ... + session, project.parameters); session = readInitialPreview(session, project.parameters); session.workflow.statusMessage = string(status.message); if strlength(session.workflow.statusMessage) == 0 @@ -42,7 +41,7 @@ session.cache.filterRows = ... rhs_preview.analysisRun.discoverFilterRows(filterPaths); session.cache.filterRows = applyFilterAnnotations( ... - session.cache.filterRows, project.annotations); + session.cache.filterRows, project.annotations, filterIds); end end @@ -58,52 +57,47 @@ end function session = readInitialPreview(session, parameters) - context = contextFromSession(session, parameters); - selected = selectedChannels(context.previewChannelRows, ... - parameters.maxPreviewChannels); - if isempty(selected) + if ~rhs_preview.analysisRun.hasReadableChannel( ... + rhs_preview.analysisRun.previewContext(session, parameters)) return; end - [context, ~] = rhs_preview.analysisRun.readPreviewWindow( ... - context, selected, "Opened project", false); - session = applyContext(session, context); + session = rhs_preview.analysisRun.readCurrentPreview( ... + session, parameters, "Opened project", false); end -function context = contextFromSession(session, parameters) - context = struct("rhsFile", session.cache.rhsPath, ... - "family", parameters.family, ... - "maxPreviewChannels", parameters.maxPreviewChannels, ... - "previewChannelRows", session.cache.previewChannelRows, ... - "windowStartSec", session.view.windowStartSec, ... - "windowDurationSec", session.view.windowDurationSec, ... - "roiSec", session.view.roiSec, "index", session.cache.index, ... - "preview", session.cache.preview, ... - "statusMessage", session.workflow.statusMessage, ... - "lastAction", session.workflow.lastAction); +function rows = applyFilterAnnotations(rows, annotations, sourceIds) +labels = string(annotations.filterLabels(:)); +comments = string(annotations.filterComments(:)); +count = min([numel(labels), numel(comments)]); +if count == 0 + return; end - -function session = applyContext(session, context) - session.view.windowStartSec = context.windowStartSec; - session.view.windowDurationSec = context.windowDurationSec; - session.view.roiSec = context.roiSec; - session.cache.preview = context.preview; - session.workflow.statusMessage = context.statusMessage; - session.workflow.lastAction = context.lastAction; +if isfield(annotations, "filterSourceIds") + savedIds = string(annotations.filterSourceIds(:)); +else + savedIds = strings(0, 1); end - -function selected = selectedChannels(rows, limit) - selected = strings(0, 1); - if istable(rows) && height(rows) > 0 - selected = string(rows.channel(logical(rows.preview))); - selected = selected(1:min(numel(selected), limit)); +if numel(savedIds) == count + for row = 1:min(height(rows), numel(sourceIds)) + match = find(savedIds == sourceIds(row), 1); + if isempty(match) + continue; + end + rows.label(row) = labels(match); + rows.comment(row) = comments(match); end + return; +end +count = min(height(rows), count); +rows.label(1:count) = labels(1:count); +rows.comment(1:count) = comments(1:count); end -function rows = applyFilterAnnotations(rows, annotations) - count = min([height(rows), numel(annotations.filterLabels), ... - numel(annotations.filterComments)]); - if count > 0 - rows.label(1:count) = annotations.filterLabels(1:count); - rows.comment(1:count) = annotations.filterComments(1:count); - end +function ids = sourceIdsForRole(sources, role) +ids = strings(0, 1); +if isempty(sources) + return; +end +mask = string({sources.role}) == string(role); +ids = string({sources(mask).id}).'; end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m b/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m index 8be5ead41..6ea435830 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m @@ -1,23 +1,12 @@ % App-owned runtime definition for labkit_RHSPreview_app. % Expected caller: the public app entrypoint. Output is a declarative LabKit % app definition; side effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_RHSPreview_app", ... - "Id", "rhs_preview", ... - "Title", "RHS Preview", ... - "DisplayName", "RHS Preview", ... - "Family", "Neurophysiology", ... - "AppVersion", "1.4.6", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "rhs", ">=1.0 <2"), ... - "Project", rhs_preview.projectSpec(), ... - "CreateSession", @rhs_preview.createSession, ... - "Layout", @rhs_preview.userInterface.buildWorkbenchLayout, ... - "Actions", rhs_preview.definitionActions(), ... - "Present", @rhs_preview.userInterface.presentWorkbench, ... - "Renderers", struct("stackedPreview", ... - @rhs_preview.userInterface.drawStackedPreview), ... - "DebugSample", @rhs_preview.debug.writeSamplePack); +function app = definition() + app = labkit.app.Definition(Entrypoint="labkit_RHSPreview_app", ... + AppId="rhs_preview", Title="RHS Preview", DisplayName="RHS Preview", ... + Family="Neurophysiology", AppVersion="1.5.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2", "rhs", ">=1.0 <2"), ... + ProjectSchema=rhs_preview.projectSpec(), CreateSession=@rhs_preview.createSession, ... + Workbench=rhs_preview.workbench.buildLayout(), PresentWorkbench=@rhs_preview.workbench.present, ... + BuildDebugSample=@rhs_preview.debug.writeSamplePack); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m b/apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m deleted file mode 100644 index f89b71e37..000000000 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/definitionActions.m +++ /dev/null @@ -1,472 +0,0 @@ -% App-owned Runtime V2 action table for RHS Preview. Handlers own RHS source -% indexing, lazy window reads, protocol/filter drafting, and semantic interval -% events without closure state, UI registry access, or figure callbacks. -function actions = definitionActions() - actions = struct( ... - "rhsChosen", @onRhsChosen, ... - "folderChosen", @onFolderChosen, ... - "folderRemoved", @onFolderRemoved, ... - "folderCleared", @onFolderCleared, ... - "protocolChosen", @onProtocolChosen, ... - "settingChanged", @onSettingChanged, ... - "previewChannelEdited", @onPreviewChannelEdited, ... - "fileFilterEdited", @onFileFilterEdited, ... - "refreshPreviewWindow", @onRefreshPreviewWindow, ... - "refreshFolderFiles", @onRefreshFolderFiles, ... - "previewRoiEdited", @onPreviewRoiEdited, ... - "previewWindowScrolled", @onPreviewWindowScrolled, ... - "zoomToRoi", @onZoomToRoi, ... - "saveProtocol", @onSaveProtocol, ... - "saveFilterRecord", @onSaveFilterRecord, ... - "resetWorkflow", @onResetWorkflow); -end - -function state = onRhsChosen(state, event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - if isempty(paths) - return; - end - filepath = paths(1); - [index, status] = labkit.rhs.indexFile(filepath); - if ~status.ok - state.session.workflow.statusMessage = string(status.message); - state = services.workflow.log(state, ... - "RHS inspect failed: " + string(status.message)); - return; - end - state.project.inputs.sources = services.project.upsertSource( ... - state.project.inputs.sources, "rhs", "recording", filepath, true); - state.session.cache.rhsPath = filepath; - state.session.cache.index = index; - state.session.cache.info = index.info; - selection = rhs_preview.userInterface.channelSelection( ... - index.info, state.project.parameters.family, ""); - state.project.parameters.family = selection.family; - state.project.annotations.previewChannelRows = ... - rhs_preview.analysisRun.channelRows(index.info, selection.family, ... - state.project.parameters.maxPreviewChannels, ... - state.project.annotations.protocol); - state.session.cache.previewChannelRows = ... - state.project.annotations.previewChannelRows; - state.session.view.autoWindow = true; - state.session.view.windowStartSec = 0; - state = applyAdaptiveWindow(state); - state = readPreview(state, "Auto preview window", services, true, false); - state.session.workflow.statusMessage = string(status.message); - if strlength(state.session.workflow.statusMessage) == 0 - state.session.workflow.statusMessage = "RHS header indexed."; - end - state.session.workflow.lastAction = "Indexed RHS file"; - state = services.workflow.log(state, sprintf( ... - 'Indexed %s: %.3f s, %d amplifier channel(s).', ... - rhs_preview.userInterface.displayFile(filepath), index.durationSec, ... - numel(index.info.channelFamilies.amplifier))); -end - -function state = onProtocolChosen(state, event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - if isempty(paths) - return; - end - filepath = paths(1); - protocol = rhs_preview.sourceFiles.loadProtocol(filepath); - state.project.inputs.sources = services.project.upsertSource( ... - state.project.inputs.sources, ... - "protocol", "protocol", filepath, false); - state.project.annotations.protocol = protocol; - state.session.cache.protocolPath = filepath; - state.session.cache.protocol = protocol; - state = rebuildPreviewRows(state); - state.session.workflow.lastAction = "Selected protocol"; - state = services.workflow.log(state, ... - "Selected protocol: " + rhs_preview.userInterface.displayFile(filepath)); -end - -function state = onFolderChosen(state, event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - if isempty(paths) - return; - end - try - rows = rhs_preview.analysisRun.discoverFilterRows( ... - paths, state.session.cache.filterRows); - catch ME - services.diagnostics.report("RHS task scan failed", ME); - state.session.workflow.statusMessage = string(ME.message); - return; - end - state.session.cache.filterRows = rows; - state.project.inputs.sources = reconcileFilterSources( ... - state.project.inputs.sources, rows.filePath, services); - state = storeFilterAnnotations(state); - state.session.workflow.statusMessage = sprintf( ... - 'Discovered %d RHS file(s).', height(rows)); - state.session.workflow.lastAction = "Discovered RHS files"; - state = services.workflow.log(state, state.session.workflow.statusMessage); -end - -function state = onFolderRemoved(state, event, services) - rows = state.session.cache.filterRows; - indices = services.events.indices(event, "removedFiles", height(rows)); - if isempty(indices) - return; - end - rows(indices, :) = []; - if height(rows) > 0 - rows.recordingId = "R" + compose("%03d", (1:height(rows)).'); - end - state.session.cache.filterRows = rows; - sources = roleSources(state.project.inputs.sources, "filterRecording"); - sources(indices) = []; - state.project.inputs.sources = replaceRole( ... - state.project.inputs.sources, "filterRecording", sources); - state = storeFilterAnnotations(state); - state.session.workflow.statusMessage = sprintf( ... - 'Removed %d RHS filter task(s).', numel(indices)); - state.session.workflow.lastAction = "Removed RHS filter files"; - state = services.workflow.log(state, state.session.workflow.statusMessage); -end - -function state = onFolderCleared(state, ~, services) - state.project.inputs.sources = replaceRole(state.project.inputs.sources, ... - "filterRecording", labkit.ui.runtime.emptySourceRecords()); - state.project.annotations.filterLabels = strings(0, 1); - state.project.annotations.filterComments = strings(0, 1); - state.session.cache.filterRows = table(); - state.session.workflow.statusMessage = "No RHS folder selected."; - state.session.workflow.lastAction = "Cleared RHS folder"; - state = services.workflow.log(state, "Cleared RHS filter files."); -end - -function state = onSettingChanged(state, event, services) - target = string(event.target); - if any(target == ["channelFamily", "maxPreviewChannels"]) - state = rebuildPreviewRows(state); - if state.session.view.autoWindow - state = applyAdaptiveWindow(state); - end - end - if hasReadableChannel(state) - state = readPreview(state, settingLabel(target), services, false, false); - end - state.session.workflow.lastAction = "Updated preview settings"; -end - -function state = onPreviewChannelEdited(state, event, services) - rows = rhs_preview.analysisRun.applyPreviewChannelsTableData( ... - state.session.cache.previewChannelRows, event.value); - state.session.cache.previewChannelRows = rows; - state.project.annotations.previewChannelRows = rows; - if state.session.view.autoWindow - state = applyAdaptiveWindow(state); - end - if hasReadableChannel(state) - state = readPreview(state, ... - "Updated preview channel window", services, false, false); - end - state.session.workflow.lastAction = "Updated preview channels"; -end - -function state = onFileFilterEdited(state, event, ~) - state.session.cache.filterRows = ... - rhs_preview.analysisRun.applyFileFilterTableData( ... - state.session.cache.filterRows, event.value); - state = storeFilterAnnotations(state); - state.session.workflow.statusMessage = "File filter updated."; - state.session.workflow.lastAction = "Updated file filter"; -end - -function state = onRefreshPreviewWindow(state, ~, services) - state = readPreview(state, "Refresh preview window", services, true, false); -end - -function state = onRefreshFolderFiles(state, ~, services) - filterSources = roleSources(state.project.inputs.sources, "filterRecording"); - if isempty(filterSources) - state.session.workflow.statusMessage = "Select RHS filter files first."; - return; - end - paths = labkit.ui.runtime.sourcePaths(filterSources); - try - state.session.cache.filterRows = ... - rhs_preview.analysisRun.discoverFilterRows( ... - paths, state.session.cache.filterRows); - catch ME - services.diagnostics.report("Folder scan failed", ME); - state.session.workflow.statusMessage = string(ME.message); - return; - end - state.project.inputs.sources = reconcileFilterSources( ... - state.project.inputs.sources, ... - state.session.cache.filterRows.filePath, services); - state = storeFilterAnnotations(state); - state.session.workflow.statusMessage = sprintf( ... - 'Discovered %d RHS file(s).', height(state.session.cache.filterRows)); - state = services.workflow.log(state, state.session.workflow.statusMessage); -end - -function state = onPreviewRoiEdited(state, event, ~) - context = previewContext(state); - state.session.view.roiSec = ... - rhs_preview.analysisRun.clampRoi(event.value, context.preview.timeSec); - state.session.workflow.statusMessage = sprintf('ROI %.6g to %.6g s.', ... - state.session.view.roiSec(1), state.session.view.roiSec(2)); - state.session.workflow.lastAction = "Updated preview ROI"; -end - -function state = onPreviewWindowScrolled(state, event, services) - if ~hasReadableChannel(state) || ~isstruct(event.value) - return; - end - context = previewContext(state); - bounds = rhs_preview.analysisRun.previewWindowBounds(context); - if ~bounds.hasIndexedDuration - return; - end - count = finiteScalar(event.value.count, 0); - anchor = finiteScalar(event.value.anchor, ... - context.windowStartSec + context.windowDurationSec / 2); - oldDuration = max(context.windowDurationSec, bounds.minDurationSec); - fraction = min(1, max(0, (anchor - context.windowStartSec) / oldDuration)); - factor = 1.25 .^ count; - context.windowDurationSec = min( ... - rhs_preview.analysisRun.maxInteractivePreviewDurationSec(context), ... - max(bounds.minDurationSec, oldDuration * factor)); - context.windowStartSec = anchor - fraction * context.windowDurationSec; - context.windowStartSec = ... - rhs_preview.analysisRun.clampWindowStartSec(context.windowStartSec, context); - state = applyPreviewContext(state, context); - state.session.view.autoWindow = false; - state = readPreview(state, "Zoom preview window", services, false, false); - state.session.workflow.statusMessage = "Preview zoom updated."; -end - -function state = onZoomToRoi(state, ~, services) - context = previewContext(state); - if ~rhs_preview.analysisRun.hasValidRoi(context) - state.session.workflow.statusMessage = ... - "Drag a preview ROI before using Zoom to ROI."; - return; - end - roi = sort(double(state.session.view.roiSec(:))).'; - bounds = rhs_preview.analysisRun.previewWindowBounds(context); - state.session.view.windowDurationSec = max(diff(roi), bounds.minDurationSec); - if bounds.hasIndexedDuration - state.session.view.windowDurationSec = min( ... - state.session.view.windowDurationSec, bounds.durationSec); - end - state.session.view.windowStartSec = roi(1); - state.session.view.autoWindow = false; - state = readPreview(state, "Zoom to ROI window", services, true, true); -end - -function state = onSaveProtocol(state, ~, services) - if height(state.session.cache.previewChannelRows) == 0 - state.session.workflow.statusMessage = ... - "Select an RHS file before saving a protocol."; - return; - end - [out, cancelled] = services.dialogs.outputFile( ... - {'*.json', 'Protocol JSON'}, 'Save RHS protocol', ... - 'rhs_protocol_draft.json'); - if cancelled - return; - end - context = previewContext(state); - rhs_preview.resultFiles.writeProtocolJson(context, out); - state.project.annotations.protocol = ... - rhs_preview.resultFiles.protocolJsonStruct(context); - state.session.cache.protocol = state.project.annotations.protocol; - [manifest, ~] = writeJsonManifest(state, services, out, ... - "rhsProtocol", "rhs_protocol_draft.labkit.json"); - state.project.results.lastProtocolExport = struct( ... - "jsonPath", string(out), "manifestPath", string(manifest)); - state = services.workflow.log(state, "Saved protocol JSON: " + string(out)); -end - -function state = onSaveFilterRecord(state, ~, services) - if height(state.session.cache.filterRows) == 0 - state.session.workflow.statusMessage = ... - "Select RHS filter files before saving a filter record."; - return; - end - [out, cancelled] = services.dialogs.outputFile( ... - {'*.json', 'Filter JSON'}, 'Save RHS filter record', ... - 'rhs_filter_record.json'); - if cancelled - return; - end - context = previewContext(state); - rhs_preview.resultFiles.writeFilterRecordJson(context, out); - [manifest, ~] = writeJsonManifest(state, services, out, ... - "rhsFilterRecord", "rhs_filter_record.labkit.json"); - state.project.results.lastFilterExport = struct( ... - "jsonPath", string(out), "manifestPath", string(manifest)); - state = services.workflow.log(state, ... - "Saved filter record JSON: " + string(out)); -end - -function state = onResetWorkflow(~, ~, services) - state = services.project.newState(); - state = services.workflow.log(state, "Reset RHS Preview state."); -end - -function state = rebuildPreviewRows(state) - context = previewContext(state); - selection = rhs_preview.userInterface.channelSelection( ... - context.info, state.project.parameters.family, ""); - state.project.parameters.family = selection.family; - rows = rhs_preview.analysisRun.channelRows(context.info, selection.family, ... - state.project.parameters.maxPreviewChannels, ... - state.project.annotations.protocol); - state.project.annotations.previewChannelRows = rows; - state.session.cache.previewChannelRows = rows; -end - -function state = applyAdaptiveWindow(state) - duration = rhs_preview.analysisRun.suggestedPreviewDurationSec( ... - state.session.cache.index, state.session.cache.previewChannelRows, ... - state.project.parameters.maxPreviewChannels); - if isfinite(duration) && duration > 0 - state.session.view.windowDurationSec = duration; - context = previewContext(state); - state.session.view.windowStartSec = ... - rhs_preview.analysisRun.clampWindowStartSec( ... - context.windowStartSec, context); - end -end - -function state = readPreview(state, label, services, logRead, preserveRoi) - context = previewContext(state); - selected = selectedChannels(context.previewChannelRows, ... - context.maxPreviewChannels); - [context, ok, message] = rhs_preview.analysisRun.readPreviewWindow( ... - context, selected, label, preserveRoi); - state = applyPreviewContext(state, context); - if strlength(message) > 0 && (logRead || ~ok) - state = services.workflow.log(state, message); - end -end - -function context = previewContext(state) - context = struct( ... - "rhsFile", state.session.cache.rhsPath, ... - "rhsFolder", commonParent(rhs_preview.sourceFiles.pathsForRole( ... - state.project.inputs.sources, "filterRecording")), ... - "protocolFile", state.session.cache.protocolPath, ... - "protocol", state.project.annotations.protocol, ... - "family", state.project.parameters.family, ... - "previewChannelRows", state.session.cache.previewChannelRows, ... - "filterRows", state.session.cache.filterRows, ... - "windowStartSec", state.session.view.windowStartSec, ... - "windowDurationSec", state.session.view.windowDurationSec, ... - "roiSec", state.session.view.roiSec, ... - "autoWindow", state.session.view.autoWindow, ... - "maxPreviewChannels", state.project.parameters.maxPreviewChannels, ... - "info", state.session.cache.info, "index", state.session.cache.index, ... - "preview", state.session.cache.preview, ... - "statusMessage", state.session.workflow.statusMessage, ... - "lastAction", state.session.workflow.lastAction); -end - -function state = applyPreviewContext(state, context) - state.session.view.windowStartSec = context.windowStartSec; - state.session.view.windowDurationSec = context.windowDurationSec; - state.session.view.roiSec = context.roiSec; - state.session.cache.preview = context.preview; - state.session.workflow.statusMessage = context.statusMessage; - state.session.workflow.lastAction = context.lastAction; -end - -function state = storeFilterAnnotations(state) - rows = state.session.cache.filterRows; - if height(rows) == 0 - state.project.annotations.filterLabels = strings(0, 1); - state.project.annotations.filterComments = strings(0, 1); - else - state.project.annotations.filterLabels = string(rows.label); - state.project.annotations.filterComments = string(rows.comment); - end -end - -function selected = selectedChannels(rows, limit) - selected = strings(0, 1); - if istable(rows) && height(rows) > 0 - selected = string(rows.channel(logical(rows.preview))); - selected = selected(1:min(numel(selected), limit)); - end -end - -function tf = hasReadableChannel(state) - tf = rhs_preview.analysisRun.hasReadableChannel(previewContext(state)); -end - -function label = settingLabel(target) - if target == "windowStartPanner" - label = "Panned preview window"; - else - label = "Updated preview window"; - end -end - -function [manifestPath, report] = writeJsonManifest( ... - state, services, outputPath, id, manifestName) - [folder, name, extension] = fileparts(outputPath); - output = services.results.output(id, "primary", ... - string(name) + string(extension), "application/json"); - spec = struct("Outputs", output, ... - "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, ... - "Summary", struct(), "ManifestName", manifestName); - [manifestPath, report] = services.results.writeManifest(folder, spec); -end - -function sources = roleSources(sources, role) - if isempty(sources) - return; - end - sources = sources(string({sources.role}) == string(role)); -end - -function sources = replaceRole(sources, role, replacements) - if ~isempty(sources) - sources(string({sources.role}) == string(role)) = []; - end - if isempty(replacements) - return; - end - assert(all(string({replacements.role}) == string(role)), ... - 'rhs_preview:InvalidSourceRole', ... - 'Replacement source records must match role %s.', string(role)); - sources = [sources(:); replacements(:)].'; -end - -function sources = reconcileFilterSources(sources, paths, services) - existing = roleSources(sources, "filterRecording"); - replacements = services.project.reconcileSources( ... - existing, paths, "filterRecording", "filter", true); - sources = replaceRole(sources, "filterRecording", replacements); -end - -function value = finiteScalar(value, fallback) - value = double(value); - if isempty(value) || ~isscalar(value) || ~isfinite(value) - value = fallback; - end -end - -function folder = commonParent(paths) - folder = ""; - if ~isempty(paths) - folder = string(fileparts(char(paths(1)))); - end -end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/projectSpec.m b/apps/neurophysiology/rhs_preview/+rhs_preview/projectSpec.m index dcbf2c6a5..ff89f9ad3 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/projectSpec.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/projectSpec.m @@ -1,18 +1,14 @@ -% App-owned durable RHS Preview contract. Runtime V2 applies the single +% App-owned durable RHS Preview contract. App SDK runtime applies the single % source-schema migration, then validates preview settings, role-based source % collections, durable annotations, and compact export state. function spec = projectSpec() - spec = struct( ... - "Version", 2, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", @migrateProject); + spec = labkit.app.project.Schema(Version=2, Create=@createProject, Validate=@validateProject, Migrate=@migrateProject); end function project = createProject() project = struct(); project.inputs = struct( ... - "sources", labkit.ui.runtime.emptySourceRecords()); + "sources", struct([])); project.parameters = struct( ... "family", "amplifier", ... "maxPreviewChannels", 8); @@ -20,7 +16,8 @@ "protocol", struct(), ... "previewChannelRows", table(), ... "filterLabels", strings(0, 1), ... - "filterComments", strings(0, 1)); + "filterComments", strings(0, 1), ... + "filterSourceIds", strings(0, 1)); project.results = struct( ... "lastProtocolExport", [], ... "lastFilterExport", []); @@ -44,8 +41,12 @@ assert(isfield(project.inputs, 'sources'), ... 'rhs_preview:InvalidProject', 'RHS Preview sources are incomplete.'); sources = project.inputs.sources; - ids = string({sources.id}); - roles = string({sources.role}); + ids = strings(1, 0); + roles = strings(1, 0); + if ~isempty(sources) + ids = string({sources.id}); + roles = string({sources.role}); + end assert(all(strlength(ids) > 0) && numel(unique(ids)) == numel(ids) && ... all(ismember(roles, ["recording", "protocol", "filterRecording"])) && ... nnz(roles == "recording") <= 1 && nnz(roles == "protocol") <= 1, ... @@ -65,6 +66,11 @@ isstring(annotations.filterLabels) && ... isstring(annotations.filterComments), ... 'rhs_preview:InvalidProject', 'RHS Preview annotations are invalid.'); + if isfield(annotations, 'filterSourceIds') + assert(isstring(annotations.filterSourceIds), ... + 'rhs_preview:InvalidProject', ... + 'RHS Preview filter source identities are invalid.'); + end assert(all(isfield(project.results, ... {'lastProtocolExport', 'lastFilterExport'})), ... 'rhs_preview:InvalidProject', 'RHS Preview result state is invalid.'); diff --git a/apps/neurophysiology/rhs_preview/labkit_RHSPreview_app.m b/apps/neurophysiology/rhs_preview/labkit_RHSPreview_app.m index 4170f174f..7e1d2e24d 100644 --- a/apps/neurophysiology/rhs_preview/labkit_RHSPreview_app.m +++ b/apps/neurophysiology/rhs_preview/labkit_RHSPreview_app.m @@ -1,6 +1,5 @@ function varargout = labkit_RHSPreview_app(varargin) %LABKIT_RHSPREVIEW_APP Launch the RHS Preview app. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @rhs_preview.definition, varargin{:}); + [varargout{1:nargout}] = rhs_preview.definition().launch(varargin{:}); end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+debug/writeSamplePack.m b/apps/statistics/ttest_wizard/+ttest_wizard/+debug/writeSamplePack.m index 013d1c846..70c1547ae 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+debug/writeSamplePack.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+debug/writeSamplePack.m @@ -1,5 +1,5 @@ % App debug-sample writer; returns synthetic paths and writes only temporary artifacts. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write an anonymous four-group CSV for debug launch. % % Expected caller: Runtime DebugSample lifecycle and App isolation tests. @@ -7,44 +7,21 @@ % one representative synthetic CSV and an output folder. Side effects are % limited to ignored temporary debug artifacts. - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, 'sampleFolder') - sampleFolder = string(debugLog.sampleFolder); - end - if isfield(debugLog, 'outputFolder') - outputFolder = string(debugLog.outputFolder); - end + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, ... - "LabKit-MATLAB-Workbench", "debug", ... - "ttest_wizard", "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, ... - "LabKit-MATLAB-Workbench", "debug", ... - "ttest_wizard", "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - filepath = string(fullfile(sampleFolder, ... - "synthetic_group_table.csv")); + filepath = sampleContext.samplePath("ttest_wizard/group_table.csv"); writecell({ ... 'Reference', 'Treatment 1', 'Treatment 2', 'Treatment 3'; ... 1.2, 1.7, 1.1, 1.5; ... 1.4, 1.8, 1.2, 1.6; ... 1.3, 2.0, 1.4, 1.5; ... 1.5, 1.9, 1.3, 1.7}, filepath); - pack = struct( ... - "sampleFolder", sampleFolder, ... - "outputFolder", outputFolder, ... - "representativeFiles", filepath); -end - -function ensureFolder(folder) - if exist(char(folder), 'dir') ~= 7 - mkdir(char(folder)); - end + project = ttest_wizard.projectSpec().Create(); + project.inputs.sources = sampleContext.sourceRecord( ... + "table1", "table", filepath, true); + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-groups", InitialProject=project, ... + Artifacts={sampleContext.artifact( ... + "groupTable", "table", filepath)}); end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/addSelectedSourceValues.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/addSelectedSourceValues.m new file mode 100644 index 000000000..d233a8e53 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/addSelectedSourceValues.m @@ -0,0 +1,49 @@ +% App-owned implementation for ttest_wizard.groupData.addSelectedSourceValues within the ttest_wizard product workflow. +function state = addSelectedSourceValues(state, context) +%ADDSELECTEDSOURCEVALUES Append selected numeric source cells to one group. +% +% Expected caller: captureGroup button. The callback reads the source-table +% cache and selection, updates durable groups, and reports the accepted count. + +arguments + state (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end + +source = state.session.cache.source; +indices = state.session.selection.sourceCells; +if ~source.ok || isempty(indices) + context.alert("Select cells in the opened table first.", "Select data"); + return; +end +selected = ttest_wizard.sourceTable.extractNumericSelection( ... + source.cells, indices); +state.session.selection.selectionMessage = selected.message; +if ~selected.ok + context.alert(selected.message, "Selected cells cannot be used"); + return; +end + +groups = state.project.inputs.groups; +target = string(state.project.parameters.captureTarget); +groupIndex = find(string({groups.label}) == target, 1); +if isempty(groupIndex) + label = ttest_wizard.sourceTable.suggestGroupLabel( ... + source.cells, indices, [groups.label]); + group = ttest_wizard.groupData.emptyGroup(label); + group.sourceDisplayName = source.displayName; + group.sheet = source.sheet; + groups(end + 1, 1) = group; + groupIndex = numel(groups); +end +groups(groupIndex).values = [ ... + groups(groupIndex).values(:); selected.values(:)]; +groups(groupIndex).cellAddresses = [ ... + groups(groupIndex).cellAddresses(:); selected.addresses(:)]; +state.project.inputs.groups = groups; +state.project.parameters.captureTarget = "(new group)"; +state.project.results.lastDataExport = ""; +context.appendStatus(sprintf( ... + 'Added %d value(s) to %s.', ... + selected.acceptedCount, groups(groupIndex).label)); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/assignSelectedRows.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/assignSelectedRows.m new file mode 100644 index 000000000..a8a2fc8c3 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/assignSelectedRows.m @@ -0,0 +1,60 @@ +% App-owned implementation for ttest_wizard.groupData.assignSelectedRows within the ttest_wizard product workflow. +function state = assignSelectedRows(state, context) +%ASSIGNSELECTEDROWS Move selected observations into the chosen group. +% +% Expected caller: assignRowsToGroup button. The callback derives visible +% observation rows from the current groups and clears the selection afterward. + +arguments + state (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end + +target = string(state.session.selection.batchGroupTarget); +selectedRows = selectedObservationRows(state); +if target == "(select group)" || isempty(selectedRows) + return; +end +state.project.inputs.groups = reassignRows( ... + state.project.inputs.groups, selectedRows, target); +state.session.selection.analysisCells = zeros(0, 2); +state.project.results.lastDataExport = ""; +context.appendStatus(sprintf( ... + 'Changed %d selected row(s) to %s.', numel(selectedRows), target)); +end + +function groups = reassignRows(groups, selectedRows, target) +visibleRows = cell(numel(groups), 1); +movedValueParts = repmat({zeros(0, 1)}, numel(groups), 1); +movedAddressParts = repmat({strings(0, 1)}, numel(groups), 1); +rowOffset = 0; +for groupIndex = 1:numel(groups) + count = numel(groups(groupIndex).values); + visibleRows{groupIndex} = rowOffset + (1:count); + rowOffset = rowOffset + count; + move = ismember(visibleRows{groupIndex}, selectedRows); + if groups(groupIndex).label ~= target + movedValueParts{groupIndex} = groups(groupIndex).values(move); + addresses = string(groups(groupIndex).cellAddresses(:)); + if numel(addresses) == count + movedAddressParts{groupIndex} = addresses(move); + groups(groupIndex).cellAddresses = addresses(~move); + end + groups(groupIndex).values = groups(groupIndex).values(~move); + end +end +movedValues = vertcat(movedValueParts{:}); +movedAddresses = vertcat(movedAddressParts{:}); +targetIndex = find([groups.label] == target, 1); +groups(targetIndex).values = [groups(targetIndex).values(:); movedValues]; +groups(targetIndex).cellAddresses = [ ... + string(groups(targetIndex).cellAddresses(:)); movedAddresses]; +groups = groups(arrayfun(@(group) ~isempty(group.values), groups)); +end + +function rows = selectedObservationRows(state) +rows = unique(state.session.selection.analysisCells(:, 1), 'stable'); +observationCount = sum(arrayfun( ... + @(group) numel(group.values), state.project.inputs.groups)); +rows = rows(rows >= 1 & rows <= observationCount); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/clearAll.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/clearAll.m new file mode 100644 index 000000000..063edfba8 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/clearAll.m @@ -0,0 +1,19 @@ +% App-owned implementation for ttest_wizard.groupData.clearAll within the ttest_wizard product workflow. +function state = clearAll(state, context) +%CLEARALL Remove every analysis group and reset related transient choices. +% +% Expected caller: clearGroups button. + +arguments + state (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end + +state.project.inputs.groups = repmat( ... + ttest_wizard.groupData.emptyGroup("Group 1"), 0, 1); +state.project.parameters.captureTarget = "(new group)"; +state.session.selection.analysisCells = zeros(0, 2); +state.session.selection.batchGroupTarget = "(select group)"; +state.project.results.lastDataExport = ""; +context.appendStatus("Cleared all analysis data."); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/deleteSelectedRows.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/deleteSelectedRows.m new file mode 100644 index 000000000..a43eaeb03 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/deleteSelectedRows.m @@ -0,0 +1,55 @@ +% App-owned implementation for ttest_wizard.groupData.deleteSelectedRows within the ttest_wizard product workflow. +function state = deleteSelectedRows(state, context) +%DELETESELECTEDROWS Remove selected observations from durable group data. +% +% Expected caller: deleteSelectedRows button. Empty groups and now-invalid +% capture/assignment choices are removed or reset atomically. + +arguments + state (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end + +selectedRows = selectedObservationRows(state); +if isempty(selectedRows) + return; +end +groups = deleteRows(state.project.inputs.groups, selectedRows); +state.project.inputs.groups = groups; +state.session.selection.analysisCells = zeros(0, 2); +labels = string({groups.label}); +if ~any(labels == string(state.project.parameters.captureTarget)) + state.project.parameters.captureTarget = "(new group)"; +end +if ~any(labels == string(state.session.selection.batchGroupTarget)) + state.session.selection.batchGroupTarget = "(select group)"; +end +state.project.results.lastDataExport = ""; +context.appendStatus(sprintf( ... + 'Deleted %d selected row(s).', numel(selectedRows))); +end + +function groups = deleteRows(groups, selectedRows) +rowOffset = 0; +for groupIndex = 1:numel(groups) + count = numel(groups(groupIndex).values); + visibleRows = rowOffset + (1:count); + rowOffset = rowOffset + count; + remove = ismember(visibleRows, selectedRows); + groups(groupIndex).values = groups(groupIndex).values(~remove); + addresses = string(groups(groupIndex).cellAddresses(:)); + if numel(addresses) == count + groups(groupIndex).cellAddresses = addresses(~remove); + else + groups(groupIndex).cellAddresses = strings(0, 1); + end +end +groups = groups(arrayfun(@(group) ~isempty(group.values), groups)); +end + +function rows = selectedObservationRows(state) +rows = unique(state.session.selection.analysisCells(:, 1), 'stable'); +observationCount = sum(arrayfun( ... + @(group) numel(group.values), state.project.inputs.groups)); +rows = rows(rows >= 1 & rows <= observationCount); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/emptyGroup.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/emptyGroup.m new file mode 100644 index 000000000..b7e970db4 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/emptyGroup.m @@ -0,0 +1,17 @@ +% App-owned implementation for ttest_wizard.groupData.emptyGroup within the ttest_wizard product workflow. +function group = emptyGroup(label) +%EMPTYGROUP Create one canonical empty T-Test Wizard group. +% +% Inputs: +% label - Scalar group label text. +% +% Outputs: +% group - Scalar project-compatible group struct with no observations. + +group = struct( ... + "label", string(label), ... + "values", zeros(0, 1), ... + "sourceDisplayName", "", ... + "sheet", "", ... + "cellAddresses", strings(0, 1)); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/layoutSection.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/layoutSection.m new file mode 100644 index 000000000..561462ce9 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/layoutSection.m @@ -0,0 +1,36 @@ +% App-owned implementation for ttest_wizard.groupData.layoutSection within the ttest_wizard product workflow. +function section = layoutSection() +%LAYOUTSECTION Build controls that create, move, and delete observations. +% +% Expected caller: workbench.buildLayout. + +section = labkit.app.layout.section( ... + "assignSection", "Add Selected Values", { ... + labkit.app.layout.field("captureTarget", ... + Label="Source selection to", Kind="choice", ... + Choices="(new group)", Value="(new group)", ... + Bind="project.parameters.captureTarget"), ... + labkit.app.layout.button("captureGroup", ... + "Add selected source cells", ... + @ttest_wizard.groupData.addSelectedSourceValues, ... + Enabled=false, ... + Tooltip="Copy the selected numeric source cells into a new or existing comparison group."), ... + labkit.app.layout.field("batchGroupTarget", ... + Label="Selected data rows to", Kind="choice", ... + Choices="(select group)", Value="(select group)", ... + Bind="session.selection.batchGroupTarget"), ... + labkit.app.layout.button("assignRowsToGroup", ... + "Change selected rows", ... + @ttest_wizard.groupData.assignSelectedRows, ... + Enabled=false, ... + Tooltip="Reassign the selected observations to the chosen statistical group."), ... + labkit.app.layout.button("deleteSelectedRows", ... + "Delete selected rows", ... + @ttest_wizard.groupData.deleteSelectedRows, ... + Enabled=false, ... + Tooltip="Remove the selected observations from the grouped data used by the t-tests."), ... + labkit.app.layout.button("clearGroups", ... + "Clear all data", @ttest_wizard.groupData.clearAll, ... + Enabled=false, ... + Tooltip="Remove all captured observations, groups, and derived comparison results.")}); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/present.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/present.m new file mode 100644 index 000000000..960441f3d --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/present.m @@ -0,0 +1,69 @@ +% App-owned implementation for ttest_wizard.groupData.present within the ttest_wizard product workflow. +function view = present(groups, captureTarget, batchTarget, ... + analysisCells, sourceReady, sourceCells) +%PRESENT Describe editable groups and the actions available for selections. +% +% Inputs: +% groups - Ordered durable group struct array. +% captureTarget - Current destination for source values. +% batchTarget - Current destination for selected analysis rows. +% analysisCells - Selected analysis-table cell coordinates. +% sourceReady - Logical scalar indicating a decoded source table. +% sourceCells - Selected source-table cell coordinates. +% +% Outputs: +% view - Snapshot fragment for group controls and dataTable. + +captureChoices = ["(new group)", [groups.label]]; +captureValue = legalChoice(captureTarget, captureChoices); +batchChoices = ["(select group)", [groups.label]]; +batchValue = legalChoice(batchTarget, batchChoices); +view = labkit.app.view.Snapshot() ... + .choices("captureTarget", captureChoices) ... + .value("captureTarget", captureValue) ... + .enabled("captureGroup", sourceReady && ~isempty(sourceCells)) ... + .choices("batchGroupTarget", batchChoices) ... + .value("batchGroupTarget", batchValue) ... + .enabled("batchGroupTarget", ~isempty(groups)) ... + .enabled("assignRowsToGroup", ... + ~isempty(groups) && ~isempty(analysisCells) && ... + batchValue ~= "(select group)") ... + .enabled("deleteSelectedRows", ... + hasSelectedObservationRows(analysisCells, groups)) ... + .enabled("clearGroups", ~isempty(groups)) ... + .tableData("dataTable", observationRows(groups), ... + Columns=["Group", "Value"], ColumnEditable=[true true]) ... + .tableCellSelection("dataTable", ... + labkit.app.event.TableCellSelection(analysisCells)); +end + +function value = legalChoice(value, choices) +value = string(value); +if ~any(choices == value) + value = choices(1); +end +end + +function data = observationRows(groups) +valueCount = sum(arrayfun(@(group) numel(group.values), groups)); +blankRows = 8; +data = cell(valueCount + blankRows, 2); +row = 0; +for groupIndex = 1:numel(groups) + for valueIndex = 1:numel(groups(groupIndex).values) + row = row + 1; + data{row, 1} = char(groups(groupIndex).label); + data{row, 2} = groups(groupIndex).values(valueIndex); + end +end +for k = row + 1:size(data, 1) + data{k, 1} = ''; + data{k, 2} = ''; +end +end + +function tf = hasSelectedObservationRows(selected, groups) +observationCount = sum(arrayfun(@(group) numel(group.values), groups)); +tf = isnumeric(selected) && size(selected, 2) == 2 && ... + any(selected(:, 1) >= 1 & selected(:, 1) <= observationCount); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/replaceFromTableEdit.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/replaceFromTableEdit.m new file mode 100644 index 000000000..5fdab9595 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/replaceFromTableEdit.m @@ -0,0 +1,101 @@ +% App-owned implementation for ttest_wizard.groupData.replaceFromTableEdit within the ttest_wizard product workflow. +function state = replaceFromTableEdit(state, edit, context) +%REPLACEFROMTABLEEDIT Validate and store the complete editable group table. +% +% Expected caller: dataTable OnCellEdited. The typed edit carries the full +% displayed table; this callback rebuilds the durable ordered groups while +% preserving source metadata for labels that still exist. + +arguments + state (1, 1) struct + edit (1, 1) labkit.app.event.TableCellEdit + context (1, 1) labkit.app.CallbackContext +end + +data = edit.Data; +if ~iscell(data) || size(data, 2) < 2 + context.alert( ... + "The data table must contain Group and Value columns.", ... + "Edit analysis data"); + return; +end +[groups, ok, message] = groupsFromRows( ... + data(:, 1), data(:, 2), state.project.inputs.groups); +if ~ok + context.alert(message, "Edit analysis data"); + return; +end +state.project.inputs.groups = groups; +if ~any([groups.label] == string(state.project.parameters.captureTarget)) + state.project.parameters.captureTarget = "(new group)"; +end +state.project.results.lastDataExport = ""; +context.appendStatus(sprintf( ... + 'Updated analysis data: %d group(s), %d value(s).', ... + numel(groups), sum(arrayfun( ... + @(group) numel(group.values), groups)))); +end + +function [groups, ok, message] = groupsFromRows( ... + labels, values, priorGroups) +groups = repmat(ttest_wizard.groupData.emptyGroup("Group 1"), 0, 1); +ok = true; +message = ""; +priorLabel = ""; +for row = 1:numel(values) + label = strip(string(labels{row})); + value = values{row}; + if isBlankValue(value) && strlength(label) == 0 + continue; + end + if strlength(label) == 0 + label = priorLabel; + end + if strlength(label) == 0 + ok = false; + message = sprintf( ... + 'Row %d needs a group name before its value.', row); + return; + end + [number, valid] = finiteScalar(value); + if ~valid + ok = false; + message = sprintf( ... + 'Row %d Value must be one finite number.', row); + return; + end + groupIndex = find(strcmpi(label, [groups.label]), 1); + if isempty(groupIndex) + group = ttest_wizard.groupData.emptyGroup(label); + priorIndex = find(strcmpi(label, [priorGroups.label]), 1); + if ~isempty(priorIndex) + group = priorGroups(priorIndex); + group.values = zeros(0, 1); + group.cellAddresses = strings(0, 1); + else + group.sourceDisplayName = "Manual data table"; + end + groups(end + 1, 1) = group; + groupIndex = numel(groups); + end + groups(groupIndex).values(end + 1, 1) = number; + priorLabel = groups(groupIndex).label; +end +end + +function tf = isBlankValue(value) +tf = isempty(value) || ... + ((ischar(value) || (isstring(value) && isscalar(value))) && ... + strlength(strip(string(value))) == 0); +end + +function [number, valid] = finiteScalar(value) +if (isnumeric(value) || islogical(value)) && isscalar(value) + number = double(value); +elseif ischar(value) || (isstring(value) && isscalar(value)) + number = str2double(strip(string(value))); +else + number = NaN; +end +valid = isfinite(number); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/selectRows.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/selectRows.m new file mode 100644 index 000000000..c0b78d232 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/selectRows.m @@ -0,0 +1,15 @@ +% App-owned implementation for ttest_wizard.groupData.selectRows within the ttest_wizard product workflow. +function state = selectRows(state, selection, context) +%SELECTROWS Remember editable analysis-table cells selected by the user. +% +% Expected caller: dataTable OnCellSelectionChanged. This transition stores +% only the typed cell coordinates used by later assign and delete actions. + +arguments + state (1, 1) struct + selection (1, 1) labkit.app.event.TableCellSelection + context (1, 1) labkit.app.CallbackContext +end + +state.session.selection.analysisCells = double(selection.CellIndices); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/workspaceTable.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/workspaceTable.m new file mode 100644 index 000000000..0552de283 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/workspaceTable.m @@ -0,0 +1,12 @@ +% App-owned implementation for ttest_wizard.groupData.workspaceTable within the ttest_wizard product workflow. +function tableNode = workspaceTable() +%WORKSPACETABLE Build the editable analysis-data workspace node. +% +% Expected caller: workbench.buildLayout. + +tableNode = labkit.app.layout.dataTable("dataTable", ... + Title="Analysis data — type or paste Group and Value", ... + Columns=["Group", "Value"], ColumnEditable=[true true], ... + OnCellEdited=@ttest_wizard.groupData.replaceFromTableEdit, ... + OnCellSelectionChanged=@ttest_wizard.groupData.selectRows); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportComparisons.m b/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportComparisons.m new file mode 100644 index 000000000..53dc85f6d --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportComparisons.m @@ -0,0 +1,41 @@ +% App-owned implementation for ttest_wizard.resultFiles.exportComparisons within the ttest_wizard product workflow. +function state = exportComparisons(state, context) +%EXPORTCOMPARISONS Choose and write a CSV of the latest comparison family. +% +% Expected caller: exportResult button. + +arguments + state (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end + +results = state.project.results.current; +if isempty(results) + context.alert( ... + "Run comparisons before exporting results.", "Export results"); + return; +end +chosen = context.chooseOutputFile( ... + ["*.csv", "CSV table (*.csv)"], ... + fullfile(pwd, "ttest_results.csv")); +if chosen.Cancelled + return; +end +filepath = ensureCsvExtension(string(chosen.Value)); +try + ttest_wizard.resultFiles.writeResultCsv(filepath, results); +catch ME + context.reportError("Export t-test results", ME); + context.alert(ME.message, "Export results"); + return; +end +state.project.results.lastResultExport = filepath; +context.appendStatus("Exported t-test results: " + filepath); +end + +function filepath = ensureCsvExtension(filepath) +[folder, name, extension] = fileparts(filepath); +if strlength(string(extension)) == 0 + filepath = string(fullfile(folder, name + ".csv")); +end +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportGroupData.m b/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportGroupData.m new file mode 100644 index 000000000..80074e85f --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportGroupData.m @@ -0,0 +1,40 @@ +% App-owned implementation for ttest_wizard.resultFiles.exportGroupData within the ttest_wizard product workflow. +function state = exportGroupData(state, context) +%EXPORTGROUPDATA Choose and write a portable CSV of current group values. +% +% Expected caller: exportData button. + +arguments + state (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end + +groups = state.project.inputs.groups; +if isempty(groups) + context.alert("Enter group data before exporting.", "Export data"); + return; +end +chosen = context.chooseOutputFile( ... + ["*.csv", "CSV table (*.csv)"], ... + fullfile(pwd, "ttest_group_data.csv")); +if chosen.Cancelled + return; +end +filepath = ensureCsvExtension(string(chosen.Value)); +try + ttest_wizard.sourceTable.writeGroupCsv(filepath, groups); +catch ME + context.reportError("Export group data", ME); + context.alert(ME.message, "Export data"); + return; +end +state.project.results.lastDataExport = filepath; +context.appendStatus("Exported group data: " + filepath); +end + +function filepath = ensureCsvExtension(filepath) +[folder, name, extension] = fileparts(filepath); +if strlength(string(extension)) == 0 + filepath = string(fullfile(folder, name + ".csv")); +end +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/layoutSection.m b/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/layoutSection.m new file mode 100644 index 000000000..9f2ee906e --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/layoutSection.m @@ -0,0 +1,23 @@ +% App-owned implementation for ttest_wizard.resultFiles.layoutSection within the ttest_wizard product workflow. +function section = layoutSection() +%LAYOUTSECTION Build portable group-data and comparison-result exports. +% +% Expected caller: workbench.buildLayout. + +section = labkit.app.layout.section( ... + "exportSection", "Portable CSV", { ... + labkit.app.layout.button("exportData", ... + "Export group data CSV", ... + @ttest_wizard.resultFiles.exportGroupData, Enabled=false, ... + Tooltip="Export the numeric observations and their assigned groups without altering the statistical values."), ... + labkit.app.layout.button("exportResult", ... + "Export comparison results CSV", ... + @ttest_wizard.resultFiles.exportComparisons, Enabled=false, ... + Tooltip="Export group-versus-reference differences, p-values, significance calls, and comparison status."), ... + labkit.app.layout.field("lastDataExport", ... + Label="Last data export", Kind="readonly", ... + Value="Not exported"), ... + labkit.app.layout.field("lastResultExport", ... + Label="Last result export", Kind="readonly", ... + Value="Not exported")}); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/present.m b/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/present.m new file mode 100644 index 000000000..59f61e96f --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/present.m @@ -0,0 +1,22 @@ +% App-owned implementation for ttest_wizard.resultFiles.present within the ttest_wizard product workflow. +function view = present(groups, results, lastDataExport, lastResultExport) +%PRESENT Describe export availability and last destinations. +% +% Outputs: +% view - Snapshot fragment for result-file controls. + +view = labkit.app.view.Snapshot() ... + .enabled("exportData", ~isempty(groups)) ... + .enabled("exportResult", ~isempty(results)) ... + .text("lastDataExport", exportText(lastDataExport)) ... + .text("lastResultExport", exportText(lastResultExport)); +end + +function text = exportText(value) +value = string(value); +if strlength(value) == 0 + text = "Not exported"; +else + text = value; +end +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/choices.m b/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/choices.m new file mode 100644 index 000000000..1fff215c5 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/choices.m @@ -0,0 +1,10 @@ +% App plot-choice owner; returns stable visible plot labels without side effects. +function value = choices() +%CHOICES Return user-facing T-Test Wizard plot choices. +% +% Expected callers: project defaults, layout, presenter, and plot renderer. +% The output owns stable visible plot type labels. Side effects are none. + + value = struct(); + value.types = ["Mean bars with SD", "Box plot"]; +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/drawComparison.m b/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/drawComparison.m new file mode 100644 index 000000000..1fb5c7d60 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/drawComparison.m @@ -0,0 +1,236 @@ +% App renderer; redraws one managed axes as a multi-group mean/SD comparison. +function drawComparison(axesById, model) +%DRAWCOMPARISON Draw grouped bars and first-group significance brackets. +% +% Expected caller: Runtime registered renderer. ax is a managed UI axes; +% model is prepared by resultPlot.present and contains copied group/result +% snapshots plus plot-only settings. The visual contract follows the selected +% reference style: white background, black boxed axes, pastel bars, SD error +% bars, no grid, and stacked first-versus-each significance brackets. Side +% effects are limited to redrawing ax. +ax = axesById.main; + + clearAxes(ax); + if ~isstruct(model) || ~isscalar(model) || ... + ~isfield(model, 'ready') || ~model.ready + message = "No completed comparisons to plot."; + if isstruct(model) && isfield(model, 'message') + message = string(model.message); + end + showMessage(ax, message); + return; + end + + groups = model.groups; + results = model.results; + parameters = model.parameters; + count = numel(groups); + x = 1:count; + colors = groupColors(count); + plotChoices = ttest_wizard.resultPlot.choices(); + boxPlotLabel = plotChoices.types(2); + hold(ax, 'on'); + + plotType = string(parameters.type); + drawGroups(ax, groups, model, parameters, colors, plotType, boxPlotLabel); + if parameters.showPoints + for groupIndex = 1:count + values = double(groups(groupIndex).values(:)); + scatter(ax, groupIndex + deterministicJitter(numel(values)), ... + values, 24, colors(groupIndex, :), 'filled', ... + 'MarkerEdgeColor', 'black', 'LineWidth', 0.5, ... + 'HitTest', 'off'); + end + end + + values = double(vertcat(groups.values)); + if plotType == boxPlotLabel + dataLow = min(values); + dataTop = max(values); + else + dataLow = min([0; values]); + dataTop = max(model.means); + if parameters.showSummary + dataTop = max(model.means + model.standardDeviations); + end + end + if parameters.showPoints + dataTop = max(dataTop, max(values)); + end + span = max(dataTop - dataLow, max(1, abs(dataTop))); + basePad = max(0.075 * span, eps); + levelStep = max(0.12 * span, eps); + capHeight = max(0.022 * span, eps); + textPad = max(0.024 * span, eps); + annotationTop = dataTop; + if parameters.showPValue + for resultIndex = 1:numel(results) + if ~results(resultIndex).ok + continue; + end + y = dataTop + basePad + (resultIndex - 1) * levelStep; + annotationTop = max(annotationTop, y + capHeight + textPad); + end + end + upperPad = 0.12 * span; + if plotType ~= boxPlotLabel && dataLow >= 0 + lowerLimit = 0; + else + lowerLimit = dataLow - 0.08 * span; + end + upperLimit = annotationTop + upperPad; + ax.YLim = [lowerLimit, upperLimit]; + ax.YTick = readableTicks(lowerLimit, upperLimit); + if parameters.showPValue + for resultIndex = 1:numel(results) + if ~results(resultIndex).ok + continue; + end + y = dataTop + basePad + (resultIndex - 1) * levelStep; + drawSignificanceBracket(ax, 1, resultIndex + 1, y, ... + capHeight, textPad, significanceText(results(resultIndex))); + end + end + + ax.XLim = [0.5 count + 0.5]; + ax.XTick = x; + ax.XTickLabel = cellstr(string({groups.label})); + ax.XTickLabelRotation = 0; + ax.TickLabelInterpreter = 'none'; + ax.FontName = 'Helvetica'; + ax.FontSize = 14; + ax.LineWidth = 1.2; + ax.Color = 'white'; + ax.Box = 'on'; + ax.XGrid = 'off'; + ax.YGrid = 'off'; + ax.TickLength = [0.018 0.018]; + ax.YAxis.Exponent = 0; + ylabel(ax, char(string(parameters.yLabel)), 'FontSize', 18); + title(ax, char(string(parameters.title))); + hold(ax, 'off'); +end + +function clearAxes(ax) + delete(allchild(ax)); + cla(ax); + ax.Visible = "on"; + ax.XLimMode = "auto"; + ax.YLimMode = "auto"; + ax.XScale = "linear"; + ax.YScale = "linear"; + ax.XTickMode = "auto"; + ax.YTickMode = "auto"; +end + +function showMessage(ax, message) + text(ax, 0.5, 0.5, string(message), ... + Units="normalized", HorizontalAlignment="center", ... + VerticalAlignment="middle", Interpreter="none", HitTest="off"); + ax.XLim = [0 1]; + ax.YLim = [0 1]; + ax.XTick = []; + ax.YTick = []; + ax.Box = "off"; +end + +function drawGroups(ax, groups, model, parameters, colors, plotType, ... + boxPlotLabel) + count = numel(groups); + x = 1:count; + if plotType == boxPlotLabel + for groupIndex = 1:count + values = double(groups(groupIndex).values(:)); + boxchart(ax, repmat(groupIndex, numel(values), 1), values, ... + 'BoxWidth', 0.48, ... + 'BoxFaceColor', colors(groupIndex, :), ... + 'BoxFaceAlpha', 0.75, ... + 'BoxEdgeColor', 'black', ... + 'BoxMedianLineColor', 'black', ... + 'WhiskerLineColor', 'black', ... + 'MarkerColor', colors(groupIndex, :), ... + 'LineWidth', 1.1, ... + 'HitTest', 'off'); + end + return; + end + + bars = bar(ax, x, model.means, 0.48, ... + 'FaceColor', 'flat', 'EdgeColor', 'black', ... + 'LineWidth', 1.1, 'HitTest', 'off'); + bars.CData = colors; + if parameters.showSummary + errorbar(ax, x, model.means, model.standardDeviations, ... + 'LineStyle', 'none', 'Color', 'black', ... + 'LineWidth', 1.1, 'CapSize', 8, 'HitTest', 'off'); + end +end + +function ticks = readableTicks(lowerLimit, upperLimit) + span = upperLimit - lowerLimit; + rawStep = span / 4; + magnitude = 10 ^ floor(log10(rawStep)); + scaled = rawStep / magnitude; + if scaled <= 1 + step = magnitude; + elseif scaled <= 2 + step = 2 * magnitude; + elseif scaled <= 2.5 + step = 2.5 * magnitude; + elseif scaled <= 5 + step = 5 * magnitude; + else + step = 10 * magnitude; + end + first = ceil(lowerLimit / step) * step; + last = floor(upperLimit / step) * step; + ticks = first:step:last; +end + +function colors = groupColors(count) + palette = [ ... + 157 211 156 + 245 195 138 + 169 216 232 + 255 248 154] / 255; + colors = zeros(count, 3); + for k = 1:count + colors(k, :) = palette(mod(k - 1, size(palette, 1)) + 1, :); + end +end + +function offsets = deterministicJitter(count) + if count <= 1 + offsets = zeros(count, 1); + else + phase = (1:count).'; + % Constant: golden-angle radians distribute deterministic marker jitter. + offsets = 0.075 * sin(phase * 2.399963229728653); + offsets = offsets - mean(offsets); + end +end + +function drawSignificanceBracket(ax, x1, x2, y, height, textPad, label) + line(ax, [x1 x1 x2 x2], ... + [y y + height y + height y], ... + 'Color', 'black', 'LineWidth', 1.2, 'HitTest', 'off'); + text(ax, (x1 + x2) / 2, y + height + textPad, label, ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'bottom', ... + 'FontSize', 16, 'FontWeight', 'bold', 'HitTest', 'off'); +end + +function textValue = significanceText(result) + % Constant: conventional star thresholds for reported p-values. + if result.pValue < 1e-4 + textValue = '****'; + elseif result.pValue < 1e-3 + textValue = '***'; + elseif result.pValue < 1e-2 + textValue = '**'; + elseif result.pValue < result.alpha + textValue = '*'; + else + textValue = 'NS'; + end +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/layoutSection.m b/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/layoutSection.m new file mode 100644 index 000000000..b6a20c71a --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/layoutSection.m @@ -0,0 +1,30 @@ +% App-owned implementation for ttest_wizard.resultPlot.layoutSection within the ttest_wizard product workflow. +function section = layoutSection() +%LAYOUTSECTION Build plot-style controls for the latest comparison family. +% +% Expected caller: workbench.buildLayout. Ordinary plot-style changes are +% direct bindings and need no no-op callback. + +plotChoices = ttest_wizard.resultPlot.choices(); +section = labkit.app.layout.section("plotSection", "Plot Style", { ... + labkit.app.layout.field("plotFreshness", ... + Label="Plot status", Kind="readonly", ... + Value="Run comparisons to create the plot."), ... + labkit.app.layout.field("plotType", Label="Plot", ... + Kind="choice", Choices=plotChoices.types, ... + Value=plotChoices.types(1), ... + Bind="project.parameters.plot.type"), ... + labkit.app.layout.field("showPoints", ... + Label="Overlay individual values", Kind="logical", ... + Value=false, Bind="project.parameters.plot.showPoints"), ... + labkit.app.layout.field("showSummary", ... + Label="Show SD bars (bar plot)", Kind="logical", ... + Value=true, Bind="project.parameters.plot.showSummary"), ... + labkit.app.layout.field("showPValue", ... + Label="Show significance brackets", Kind="logical", ... + Value=true, Bind="project.parameters.plot.showPValue"), ... + labkit.app.layout.field("plotTitle", Label="Title", ... + Bind="project.parameters.plot.title"), ... + labkit.app.layout.field("yLabel", Label="Y-axis label", ... + Value="Value", Bind="project.parameters.plot.yLabel")}); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/present.m b/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/present.m new file mode 100644 index 000000000..3b310a75f --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/present.m @@ -0,0 +1,64 @@ +% App-owned implementation for ttest_wizard.resultPlot.present within the ttest_wizard product workflow. +function view = present(results, parameters, resultsCurrent) +%PRESENT Describe plot freshness, renderer model, and style availability. +% +% Inputs: +% results - Latest comparison result struct array. +% parameters - Current plot-parameter struct. +% resultsCurrent - Logical scalar indicating data/test freshness. +% +% Outputs: +% view - Snapshot fragment for plot controls and resultPlot. + +hasResult = ~isempty(results) && any([results.ok]); +view = labkit.app.view.Snapshot() ... + .text("plotFreshness", freshnessText(results, resultsCurrent)) ... + .renderPlot("resultPlot", plotModel(results, parameters)); +controlIds = ["plotType", "showPoints", "showSummary", ... + "showPValue", "plotTitle", "yLabel"]; +for id = controlIds + view = view.enabled(id, hasResult); +end +end + +function text = freshnessText(results, isCurrent) +if isempty(results) || ~any([results.ok]) + text = "No plot yet. Run comparisons after entering at least two groups."; +elseif isCurrent + text = "Current - plot and results match the analysis data."; +else + text = "OUT OF DATE - data or test settings changed. " + ... + "Run / refresh comparisons."; +end +end + +function model = plotModel(results, parameters) +ready = ~isempty(results) && any([results.ok]); +model = struct( ... + "ready", ready, ... + "message", "Enter data and run comparisons to create the plot.", ... + "groups", repmat(struct( ... + "label", "", "values", zeros(0, 1)), 0, 1), ... + "results", results, ... + "parameters", parameters, ... + "means", zeros(0, 1), ... + "standardDeviations", zeros(0, 1)); +if ~ready + return; +end +model.groups = snapshotsFromResults(results); +model.means = arrayfun(@(group) mean(group.values), model.groups); +model.standardDeviations = arrayfun( ... + @(group) std(group.values, 0), model.groups); +end + +function groups = snapshotsFromResults(results) +groups = repmat(struct("label", "", "values", zeros(0, 1)), ... + numel(results) + 1, 1); +groups(1).label = results(1).labelA; +groups(1).values = results(1).vectorA; +for k = 1:numel(results) + groups(k + 1).label = results(k).labelB; + groups(k + 1).values = results(k).vectorB; +end +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/workspacePlot.m b/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/workspacePlot.m new file mode 100644 index 000000000..ab26aa6c9 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+resultPlot/workspacePlot.m @@ -0,0 +1,11 @@ +% App-owned implementation for ttest_wizard.resultPlot.workspacePlot within the ttest_wizard product workflow. +function plotNode = workspacePlot() +%WORKSPACEPLOT Build the comparison-plot workspace node and renderer link. +% +% Expected caller: workbench.buildLayout. + +plotNode = labkit.app.layout.plotArea( ... + "resultPlot", @ttest_wizard.resultPlot.drawComparison, ... + Title="Statistical comparison plot", ... + AxisIds="main", AxisTitles=""); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/changeWorksheet.m b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/changeWorksheet.m new file mode 100644 index 000000000..5e5cd2e91 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/changeWorksheet.m @@ -0,0 +1,37 @@ +% App-owned implementation for ttest_wizard.sourceTable.changeWorksheet within the ttest_wizard product workflow. +function state = changeWorksheet(state, requested, context) +%CHANGEWORKSHEET Load the requested worksheet and reset its cell selection. +% +% Expected caller: sourceSheet OnValueChanged. The callback depends only on +% the current source cache, portable source record, and selected worksheet; +% it preserves existing analysis groups. + +arguments + state (1, 1) struct + requested + context (1, 1) labkit.app.CallbackContext +end + +source = state.session.cache.source; +if ~source.ok || isempty(state.project.inputs.sources) + return; +end +requested = string(requested); +if ~isscalar(requested) || ~any(requested == source.sheetNames) + requested = source.sheetNames(1); +end +paths = context.resolveSourcePaths(state.project.inputs.sources); +try + source = ttest_wizard.sourceTable.readSourceTable(paths(1), requested); +catch ME + context.reportError("Change worksheet", ME); + context.alert(ME.message, "Worksheet"); + return; +end +state.project.inputs.sourceSheet = source.sheet; +state.session.cache.source = source; +state.session.selection.sourceCells = zeros(0, 2); +state.session.selection.selectionMessage = ... + "Select numeric cells in the new worksheet."; +context.appendStatus("Worksheet: " + source.sheet); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/layoutSection.m b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/layoutSection.m new file mode 100644 index 000000000..04693187d --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/layoutSection.m @@ -0,0 +1,30 @@ +% App-owned implementation for ttest_wizard.sourceTable.layoutSection within the ttest_wizard product workflow. +function section = layoutSection() +%LAYOUTSECTION Build source-file, worksheet, and selection controls. +% +% Expected caller: workbench.buildLayout. The returned immutable section owns +% only source-table acquisition and selection feedback. + +section = labkit.app.layout.section( ... + "sourceSection", "Source Table", { ... + labkit.app.layout.fileList("sourceFile", ... + Label="CSV or workbook", ... + Filters=["*.csv;*.tsv;*.xlsx;*.xls", ... + "Tables (*.csv, *.tsv, *.xlsx, *.xls)"], ... + SelectionMode="single", MaxFiles=1, ... + ChooseLabel="Open table", EmptyText="No table open", ... + ChooseTooltip="Open a CSV, TSV, or worksheet containing the numeric observations to assign into comparison groups.", ... + Bind="project.inputs.sources", ... + SourceRole="table", SourceIdPrefix="table", Required=true), ... + labkit.app.layout.field("sourceSheet", ... + Label="Worksheet", Kind="choice", ... + Choices="(no source)", Value="(no source)", ... + Bind="project.inputs.sourceSheet", ... + OnValueChanged=@ttest_wizard.sourceTable.changeWorksheet), ... + labkit.app.layout.field("sourceSummary", ... + Label="Table", Kind="readonly", ... + Value="Open a CSV or workbook"), ... + labkit.app.layout.field("selectionSummary", ... + Label="Selected cells", Kind="readonly", ... + Value="Select numeric cells in the upper data table.")}); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/present.m b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/present.m new file mode 100644 index 000000000..ed32af0b9 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/present.m @@ -0,0 +1,36 @@ +% App-owned implementation for ttest_wizard.sourceTable.present within the ttest_wizard product workflow. +function view = present(source, selectedCells, selectionMessage) +%PRESENT Describe the visible source worksheet and cell selection. +% +% Inputs: +% source - Decoded source-table cache value. +% selectedCells - Numeric N-by-2 cell coordinates. +% selectionMessage - Scalar selection summary text. +% +% Outputs: +% view - Snapshot fragment for source controls and sourceGrid. + +[data, columns, rows] = tablePresentation(source); +view = labkit.app.view.Snapshot() ... + .choices("sourceSheet", source.sheetNames) ... + .value("sourceSheet", source.sheet) ... + .enabled("sourceSheet", source.ok && numel(source.sheetNames) > 1) ... + .text("sourceSummary", source.message) ... + .text("selectionSummary", selectionMessage) ... + .tableData("sourceGrid", data, ... + Columns=columns, RowNames=rows) ... + .tableCellSelection("sourceGrid", ... + labkit.app.event.TableCellSelection(selectedCells)); +end + +function [data, columns, rows] = tablePresentation(source) +if source.ok && source.rowCount > 0 && source.columnCount > 0 + data = source.cells; + columns = source.columnNames; + rows = source.rowNames; +else + data = {'Open a CSV or workbook from the Data controls.'}; + columns = {'A'}; + rows = {'1'}; +end +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/selectCells.m b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/selectCells.m new file mode 100644 index 000000000..01ffeea30 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/selectCells.m @@ -0,0 +1,26 @@ +% App-owned implementation for ttest_wizard.sourceTable.selectCells within the ttest_wizard product workflow. +function state = selectCells(state, selection, context) +%SELECTCELLS Remember source-table cells and summarize numeric usability. +% +% Expected caller: sourceGrid OnCellSelectionChanged. Selection is the typed +% SDK payload; context is declared for the fixed callback contract but this +% transition performs no runtime side effect. + +arguments + state (1, 1) struct + selection (1, 1) labkit.app.event.TableCellSelection + context (1, 1) labkit.app.CallbackContext +end + +indices = selection.CellIndices; +state.session.selection.sourceCells = double(indices); +source = state.session.cache.source; +if ~source.ok || isempty(indices) + state.session.selection.selectionMessage = ... + "Select numeric cells in the opened table."; + return; +end +selected = ttest_wizard.sourceTable.extractNumericSelection( ... + source.cells, indices); +state.session.selection.selectionMessage = selected.message; +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/suggestGroupLabel.m b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/suggestGroupLabel.m index 98fb94712..3b7285c6b 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/suggestGroupLabel.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/suggestGroupLabel.m @@ -1,3 +1,4 @@ +% App-owned implementation for ttest_wizard.sourceTable.suggestGroupLabel within the ttest_wizard product workflow. function label = suggestGroupLabel(cells, indices, existingLabels) %SUGGESTGROUPLABEL Infer a concise group label from layered table headers. % diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/workspaceTable.m b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/workspaceTable.m new file mode 100644 index 000000000..42c3c2b4e --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/workspaceTable.m @@ -0,0 +1,11 @@ +% App-owned implementation for ttest_wizard.sourceTable.workspaceTable within the ttest_wizard product workflow. +function tableNode = workspaceTable() +%WORKSPACETABLE Build the selectable source-table workspace node. +% +% Expected caller: workbench.buildLayout. + +tableNode = labkit.app.layout.dataTable("sourceGrid", ... + Title="Opened table — select numeric cells here", ... + Columns="A", RowNames="1", ... + OnCellSelectionChanged=@ttest_wizard.sourceTable.selectCells); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/layoutSections.m b/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/layoutSections.m new file mode 100644 index 000000000..deb270d03 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/layoutSections.m @@ -0,0 +1,43 @@ +% App-owned implementation for ttest_wizard.testRun.layoutSections within the ttest_wizard product workflow. +function sections = layoutSections() +%LAYOUTSECTIONS Build test settings and latest-result controls. +% +% Expected caller: workbench.buildLayout. Output is a row cell array of the +% two adjacent immutable sections owned by the test-run capability. + +choices = ttest_wizard.testRun.choices(); +settings = labkit.app.layout.section( ... + "testSection", "Run T-Tests", { ... + labkit.app.layout.field("testMethod", Label="Test", ... + Kind="choice", Choices=choices.methodLabels, ... + Value=choices.methodLabels(1), ... + Bind="project.parameters.testMethod", ... + OnValueChanged=@ttest_wizard.testRun.validateSettings), ... + labkit.app.layout.field("alternative", ... + Label="Question (reference minus comparison)", ... + Kind="choice", Choices=choices.alternativeLabels, ... + Value=choices.alternativeLabels(1), ... + Bind="project.parameters.alternative", ... + OnValueChanged=@ttest_wizard.testRun.validateSettings), ... + labkit.app.layout.field("alpha", Label="Alpha", ... + Kind="numeric", Value=0.05, Limits=[eps 1 - eps], ... + Bind="project.parameters.alpha", ... + OnValueChanged=@ttest_wizard.testRun.validateSettings), ... + labkit.app.layout.statusPanel("pendingTest", ... + Title="What will run", ... + Text="Add at least two groups first."), ... + labkit.app.layout.button("runComparisons", ... + "Run / refresh comparisons", ... + @ttest_wizard.testRun.runComparisons, Enabled=false, ... + Tooltip="Compare every group with the reference using the selected t-test, alternative hypothesis, and alpha threshold.")}); +results = labkit.app.layout.section( ... + "resultSection", "Latest Results", { ... + labkit.app.layout.statusPanel("resultStatus", ... + Title="Result family", ... + Text="No comparisons have been run."), ... + labkit.app.layout.dataTable("resultTable", ... + Title="Each group versus reference", ... + Columns=["Comparison", "Difference", "p", ... + "Significance", "Status"])}); +sections = {settings, results}; +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/present.m b/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/present.m new file mode 100644 index 000000000..634ba2a7d --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/present.m @@ -0,0 +1,113 @@ +% App-owned implementation for ttest_wizard.testRun.present within the ttest_wizard product workflow. +function view = present(groups, results, options, resultsCurrent) +%PRESENT Describe test readiness and the latest comparison family. +% +% Inputs: +% groups - Ordered durable group struct array. +% results - Latest comparison result struct array. +% options - Current method, alternative, and alpha struct. +% resultsCurrent - Logical scalar freshness result. +% +% Outputs: +% view - Snapshot fragment for test and result controls. + +view = labkit.app.view.Snapshot() ... + .text("pendingTest", pendingText(groups, options.method)) ... + .enabled("runComparisons", canRun(groups)) ... + .text("resultStatus", join( ... + resultStatusText(results, resultsCurrent), newline)) ... + .tableData("resultTable", resultTableData(results), ... + Columns=["Comparison", "Difference", "p", ... + "Significance", "Status"]); +end + +function text = pendingText(groups, method) +if numel(groups) < 2 + text = sprintf( ... + '%d group(s) ready. Enter at least two; the first is reference.', ... + numel(groups)); + return; +end +counts = arrayfun(@(group) numel(group.values), groups); +method = string(method); +if contains(method, "Paired") && any(counts(2:end) ~= counts(1)) + text = sprintf( ... + 'Paired testing needs every group to match reference samples (%d).', ... + counts(1)); +else + text = sprintf( ... + 'Run %d comparison(s) against %s using %s.', ... + numel(groups) - 1, groups(1).label, method); +end +end + +function tf = canRun(groups) +tf = numel(groups) >= 2 && ... + all(arrayfun(@(group) numel(group.values) >= 2, groups)); +end + +function text = resultStatusText(results, isCurrent) +if isempty(results) + text = "No comparisons have been run."; + return; +end +text = string(sprintf( ... + '%d of %d comparison(s) completed against %s.', ... + sum([results.ok]), numel(results), results(1).labelA)); +if ~isCurrent + text = [text; ... + "Data or test settings changed; run again to refresh."]; +end +end + +function data = resultTableData(results) +if isempty(results) + data = {'Not run', '', '', '', ''}; + return; +end +data = cell(numel(results), 5); +for k = 1:numel(results) + result = results(k); + data(k, :) = { ... + char(result.labelB + " vs " + result.labelA), ... + formatNumber(result.meanDifference), ... + formatNumber(result.pValue), ... + char(significanceText(result)), ... + char(result.status)}; +end +end + +function text = significanceText(result) +% Conventional star thresholds for reported p-values. +% Constant: conventional p-value display thresholds for four, three, and two stars. +fourStarThreshold = 1e-4; +threeStarThreshold = 1e-3; +twoStarThreshold = 1e-2; +if ~result.ok + text = ""; +elseif result.pValue < fourStarThreshold + text = "****"; +elseif result.pValue < threeStarThreshold + text = "***"; +elseif result.pValue < twoStarThreshold + text = "**"; +elseif result.pValue < result.alpha + text = "*"; +else + text = "NS"; +end +end + +function value = formatNumber(value) +if isempty(value) || isnan(value) + value = ''; +elseif isinf(value) + if value > 0 + value = 'Inf'; + else + value = '-Inf'; + end +else + value = sprintf('%.10g', value); +end +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/runComparisons.m b/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/runComparisons.m new file mode 100644 index 000000000..04931775a --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/runComparisons.m @@ -0,0 +1,37 @@ +% App-owned implementation for ttest_wizard.testRun.runComparisons within the ttest_wizard product workflow. +function state = runComparisons(state, context) +%RUNCOMPARISONS Compute every first-group comparison from current settings. +% +% Expected caller: runComparisons button. Scientific calculation remains in +% runGroupTTests; this boundary callback stores the immutable result family +% and reports partial failures. + +arguments + state (1, 1) struct + context (1, 1) labkit.app.CallbackContext +end + +groups = state.project.inputs.groups; +if numel(groups) < 2 + context.alert( ... + "Enter at least two groups before running comparisons.", ... + "T-tests"); + return; +end +options = struct( ... + "method", state.project.parameters.testMethod, ... + "alternative", state.project.parameters.alternative, ... + "alpha", state.project.parameters.alpha); +results = ttest_wizard.testRun.runGroupTTests(groups, options); +state.project.results.current = results; +state.project.results.lastResultExport = ""; +okCount = sum([results.ok]); +context.appendStatus(sprintf( ... + 'Completed %d of %d comparison(s) against %s.', ... + okCount, numel(results), groups(1).label)); +if okCount < numel(results) + failed = results(~[results.ok]); + context.alert(strjoin(unique([failed.message]), newline), ... + "Some t-tests were not completed"); +end +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/validateSettings.m b/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/validateSettings.m new file mode 100644 index 000000000..92cc166ec --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/validateSettings.m @@ -0,0 +1,22 @@ +% App-owned implementation for ttest_wizard.testRun.validateSettings within the ttest_wizard product workflow. +function state = validateSettings(state, newValue, context) +%VALIDATESETTINGS Keep the user-entered significance level in its legal range. +% +% Expected caller: test-setting OnValueChanged callbacks. The changed value +% is already applied by the binding; this callback validates the complete +% settings and repairs only an invalid alpha. + +arguments + state (1, 1) struct + newValue + context (1, 1) labkit.app.CallbackContext +end + +alpha = double(state.project.parameters.alpha); +if ~isscalar(alpha) || ~isfinite(alpha) || alpha <= 0 || alpha >= 1 + state.project.parameters.alpha = 0.05; + context.alert( ... + "Alpha must be between zero and one. It was reset to 0.05.", ... + "Test settings"); +end +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m b/apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 657443133..000000000 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,191 +0,0 @@ -% App layout factory; builds a compact data-test-plot workbench. -function layout = buildWorkbenchLayout(callbacks, ~) -%BUILDWORKBENCHLAYOUT Build the semantic multi-group T-Test Wizard workbench. -% -% Expected caller: ttest_wizard.definition. Inputs are framework-generated -% callbacks and optional initial state. The output keeps file/group commands -% and test/plot controls on the left, with native Data and Plot workspace pages -% on the right. It performs no IO, calculation, or handle creation. - - layout = labkit.ui.layout.workbench( ... - "ttestWizardApp", "T-Test Wizard", ... - "controlTabs", { ... - dataTab(callbacks), ... - analysisTab(callbacks), ... - exportTab(callbacks), ... - logTab()}, ... - "workspace", mainWorkspace(callbacks), ... - "usageTitle", "Fast workflow", ... - "usage", { ... - "1. Open/paste data and assign values to groups.", ... - "2. Keep the reference group first and run comparisons.", ... - "3. Switch the workspace to Plot and adjust presentation.", ... - "4. Rerun after any data or test change to refresh the plot."}); -end - -function tab = dataTab(callbacks) - tab = labkit.ui.layout.tab("data", "1 Data", { ... - labkit.ui.layout.section("sourceSection", "Source Table", { ... - labkit.ui.layout.filePanel("sourceFile", "CSV or workbook", ... - "mode", "single", ... - "filters", {'*.csv;*.tsv;*.xlsx;*.xls', ... - 'Tables (*.csv, *.tsv, *.xlsx, *.xls)'}, ... - "chooseLabel", "Open table", ... - "emptyText", "No table open", ... - "onChoose", callbacks.openSource, ... - "onClear", callbacks.clearSource), ... - labkit.ui.layout.field("sourceSheet", "Worksheet", ... - "kind", "dropdown", "items", {'(no source)'}, ... - "value", "(no source)", ... - "Bind", "project.inputs.sourceSheet", ... - "Event", "sheetChanged"), ... - labkit.ui.layout.field("sourceSummary", "Table", ... - "kind", "readonly", "value", "Open a CSV or workbook"), ... - labkit.ui.layout.field( ... - "selectionSummary", "Selected cells", ... - "kind", "readonly", ... - "value", "Select numeric cells in the upper data table.")}), ... - labkit.ui.layout.section("assignSection", "Add Selected Values", { ... - labkit.ui.layout.field("captureTarget", "Source selection to", ... - "kind", "dropdown", "items", {'(new group)'}, ... - "value", "(new group)", ... - "Bind", "project.parameters.captureTarget"), ... - labkit.ui.layout.action( ... - "captureGroup", "Add selected source cells", ... - callbacks.captureGroup, "enabled", false, ... - "priority", "primary"), ... - labkit.ui.layout.field( ... - "batchGroupTarget", "Selected data rows to", ... - "kind", "dropdown", "items", {'(select group)'}, ... - "value", "(select group)", ... - "Bind", "session.selection.batchGroupTarget"), ... - labkit.ui.layout.action( ... - "assignRowsToGroup", "Change selected rows", ... - callbacks.assignRowsToGroup, "enabled", false), ... - labkit.ui.layout.action( ... - "deleteSelectedRows", "Delete selected rows", ... - callbacks.deleteSelectedRows, "enabled", false), ... - labkit.ui.layout.action( ... - "clearGroups", "Clear all data", ... - callbacks.clearGroups, "enabled", false)})}); -end - -function tab = analysisTab(callbacks) - choices = ttest_wizard.testRun.choices(); - plotChoices = ttest_wizard.userInterface.plotChoices(); - tab = labkit.ui.layout.tab("analysis", "2 Test & Plot", { ... - labkit.ui.layout.section("testSection", "Run T-Tests", { ... - labkit.ui.layout.field("testMethod", "Test", ... - "kind", "dropdown", ... - "items", cellstr(choices.methodLabels), ... - "value", choices.methodLabels(1), ... - "Bind", "project.parameters.testMethod", ... - "Event", "testSettingsChanged"), ... - labkit.ui.layout.field("alternative", ... - "Question (reference minus comparison)", ... - "kind", "dropdown", ... - "items", cellstr(choices.alternativeLabels), ... - "value", choices.alternativeLabels(1), ... - "Bind", "project.parameters.alternative", ... - "Event", "testSettingsChanged"), ... - labkit.ui.layout.field("alpha", "Alpha", ... - "kind", "number", "value", 0.05, ... - "Bind", "project.parameters.alpha", ... - "Event", "testSettingsChanged"), ... - labkit.ui.layout.statusPanel( ... - "pendingTest", "What will run", ... - "value", "Add at least two groups first."), ... - labkit.ui.layout.action( ... - "runComparisons", "Run / refresh comparisons", ... - callbacks.runComparisons, "enabled", false, ... - "priority", "primary")}), ... - labkit.ui.layout.section("resultSection", "Latest Results", { ... - labkit.ui.layout.statusPanel( ... - "resultStatus", "Result family", ... - "value", "No comparisons have been run."), ... - labkit.ui.layout.resultTable( ... - "resultTable", "Each group versus reference", ... - "columns", {'Comparison', 'Difference', 'p', ... - 'Significance', 'Status'}, ... - "data", {'Not run', '', '', '', ''})}), ... - labkit.ui.layout.section("plotSection", "Plot Style", { ... - labkit.ui.layout.field("plotFreshness", "Plot status", ... - "kind", "readonly", ... - "value", "Run comparisons to create the plot."), ... - labkit.ui.layout.field("plotType", "Plot", ... - "kind", "dropdown", "items", cellstr(plotChoices.types), ... - "value", plotChoices.types(1), ... - "Bind", "project.parameters.plot.type", ... - "Event", "plotChanged"), ... - labkit.ui.layout.field("showPoints", ... - "Overlay individual values", ... - "kind", "checkbox", "value", false, ... - "Bind", "project.parameters.plot.showPoints", ... - "Event", "plotChanged"), ... - labkit.ui.layout.field("showSummary", "Show SD bars (bar plot)", ... - "kind", "checkbox", "value", true, ... - "Bind", "project.parameters.plot.showSummary", ... - "Event", "plotChanged"), ... - labkit.ui.layout.field("showPValue", ... - "Show significance brackets", ... - "kind", "checkbox", "value", true, ... - "Bind", "project.parameters.plot.showPValue", ... - "Event", "plotChanged"), ... - labkit.ui.layout.field("plotTitle", "Title", ... - "kind", "text", "value", "", ... - "Bind", "project.parameters.plot.title", ... - "Event", "plotChanged"), ... - labkit.ui.layout.field("yLabel", "Y-axis label", ... - "kind", "text", "value", "Value", ... - "Bind", "project.parameters.plot.yLabel", ... - "Event", "plotChanged")})}); -end - -function tab = exportTab(callbacks) - tab = labkit.ui.layout.tab("export", "3 Export", { ... - labkit.ui.layout.section("exportSection", "Portable CSV", { ... - labkit.ui.layout.action( ... - "exportData", "Export group data CSV", ... - callbacks.exportData, "enabled", false), ... - labkit.ui.layout.action( ... - "exportResult", "Export comparison results CSV", ... - callbacks.exportResult, "enabled", false), ... - labkit.ui.layout.field("lastDataExport", ... - "Last data export", "kind", "readonly", ... - "value", "Not exported"), ... - labkit.ui.layout.field("lastResultExport", ... - "Last result export", "kind", "readonly", ... - "value", "Not exported")})}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log", ... - "value", {'Ready.'})})}); -end - -function workspace = mainWorkspace(callbacks) - dataPage = labkit.ui.layout.tab("dataPage", "Data", { ... - labkit.ui.layout.resultTable( ... - "sourceGrid", ... - "Opened table — select numeric cells here", ... - "columns", {'A'}, "rowName", {'1'}, ... - "data", {'Open a CSV or workbook'}, ... - "onSelectionChange", callbacks.sourceSelectionChanged), ... - labkit.ui.layout.resultTable( ... - "dataTable", ... - "Analysis data — type or paste Group and Value", ... - "columns", {'Group', 'Value'}, ... - "data", {'', ''}, ... - "columnEditable", [true true], ... - "onCellEdit", callbacks.groupsEdited, ... - "onSelectionChange", callbacks.analysisSelectionChanged)}); - plotPage = labkit.ui.layout.tab("plotPage", "Plot", { ... - labkit.ui.layout.previewArea( ... - "resultPlot", "Statistical comparison plot", ... - "layout", "single", "axisIds", {'main'}, ... - "axisTitles", {'All groups vs reference'})}); - workspace = labkit.ui.layout.workspace( ... - "workspace", "Data and Plot", {dataPage, plotPage}); -end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/drawResultPreview.m b/apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/drawResultPreview.m deleted file mode 100644 index 982d7bd71..000000000 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/drawResultPreview.m +++ /dev/null @@ -1,212 +0,0 @@ -% App renderer; redraws one managed axes as a multi-group mean/SD comparison. -function drawResultPreview(ax, model) -%DRAWRESULTPREVIEW Draw grouped bars and first-group significance brackets. -% -% Expected caller: Runtime registered renderer. ax is a managed UI axes; -% model is prepared by presentWorkbench and contains copied group/result -% snapshots plus plot-only settings. The visual contract follows the selected -% reference style: white background, black boxed axes, pastel bars, SD error -% bars, no grid, and stacked first-versus-each significance brackets. Side -% effects are limited to redrawing ax. - - labkit.ui.plot.clear(ax); - if ~isstruct(model) || ~isscalar(model) || ... - ~isfield(model, 'ready') || ~model.ready - message = "No completed comparisons to plot."; - if isstruct(model) && isfield(model, 'message') - message = string(model.message); - end - labkit.ui.plot.message(ax, message); - return; - end - - groups = model.groups; - results = model.results; - parameters = model.parameters; - count = numel(groups); - x = 1:count; - colors = groupColors(count); - plotChoices = ttest_wizard.userInterface.plotChoices(); - boxPlotLabel = plotChoices.types(2); - hold(ax, 'on'); - - plotType = string(parameters.type); - drawGroups(ax, groups, model, parameters, colors, plotType, boxPlotLabel); - if parameters.showPoints - for groupIndex = 1:count - values = double(groups(groupIndex).values(:)); - scatter(ax, groupIndex + deterministicJitter(numel(values)), ... - values, 24, colors(groupIndex, :), 'filled', ... - 'MarkerEdgeColor', 'black', 'LineWidth', 0.5, ... - 'HitTest', 'off'); - end - end - - values = double(vertcat(groups.values)); - if plotType == boxPlotLabel - dataLow = min(values); - dataTop = max(values); - else - dataLow = min([0; values]); - dataTop = max(model.means); - if parameters.showSummary - dataTop = max(model.means + model.standardDeviations); - end - end - if parameters.showPoints - dataTop = max(dataTop, max(values)); - end - span = max(dataTop - dataLow, max(1, abs(dataTop))); - basePad = max(0.075 * span, eps); - levelStep = max(0.12 * span, eps); - capHeight = max(0.022 * span, eps); - textPad = max(0.024 * span, eps); - annotationTop = dataTop; - if parameters.showPValue - for resultIndex = 1:numel(results) - if ~results(resultIndex).ok - continue; - end - y = dataTop + basePad + (resultIndex - 1) * levelStep; - annotationTop = max(annotationTop, y + capHeight + textPad); - end - end - upperPad = 0.12 * span; - if plotType ~= boxPlotLabel && dataLow >= 0 - lowerLimit = 0; - else - lowerLimit = dataLow - 0.08 * span; - end - upperLimit = annotationTop + upperPad; - ax.YLim = [lowerLimit, upperLimit]; - ax.YTick = readableTicks(lowerLimit, upperLimit); - if parameters.showPValue - for resultIndex = 1:numel(results) - if ~results(resultIndex).ok - continue; - end - y = dataTop + basePad + (resultIndex - 1) * levelStep; - drawSignificanceBracket(ax, 1, resultIndex + 1, y, ... - capHeight, textPad, significanceText(results(resultIndex))); - end - end - - ax.XLim = [0.5 count + 0.5]; - ax.XTick = x; - ax.XTickLabel = cellstr(string({groups.label})); - ax.XTickLabelRotation = 0; - ax.TickLabelInterpreter = 'none'; - ax.FontName = 'Helvetica'; - ax.FontSize = 14; - ax.LineWidth = 1.2; - ax.Color = 'white'; - ax.Box = 'on'; - ax.XGrid = 'off'; - ax.YGrid = 'off'; - ax.TickLength = [0.018 0.018]; - ax.YAxis.Exponent = 0; - ylabel(ax, char(string(parameters.yLabel)), 'FontSize', 18); - title(ax, char(string(parameters.title))); - hold(ax, 'off'); -end - -function drawGroups(ax, groups, model, parameters, colors, plotType, ... - boxPlotLabel) - count = numel(groups); - x = 1:count; - if plotType == boxPlotLabel - for groupIndex = 1:count - values = double(groups(groupIndex).values(:)); - boxchart(ax, repmat(groupIndex, numel(values), 1), values, ... - 'BoxWidth', 0.48, ... - 'BoxFaceColor', colors(groupIndex, :), ... - 'BoxFaceAlpha', 0.75, ... - 'BoxEdgeColor', 'black', ... - 'BoxMedianLineColor', 'black', ... - 'WhiskerLineColor', 'black', ... - 'MarkerColor', colors(groupIndex, :), ... - 'LineWidth', 1.1, ... - 'HitTest', 'off'); - end - return; - end - - bars = bar(ax, x, model.means, 0.48, ... - 'FaceColor', 'flat', 'EdgeColor', 'black', ... - 'LineWidth', 1.1, 'HitTest', 'off'); - bars.CData = colors; - if parameters.showSummary - errorbar(ax, x, model.means, model.standardDeviations, ... - 'LineStyle', 'none', 'Color', 'black', ... - 'LineWidth', 1.1, 'CapSize', 8, 'HitTest', 'off'); - end -end - -function ticks = readableTicks(lowerLimit, upperLimit) - span = upperLimit - lowerLimit; - rawStep = span / 4; - magnitude = 10 ^ floor(log10(rawStep)); - scaled = rawStep / magnitude; - if scaled <= 1 - step = magnitude; - elseif scaled <= 2 - step = 2 * magnitude; - elseif scaled <= 2.5 - step = 2.5 * magnitude; - elseif scaled <= 5 - step = 5 * magnitude; - else - step = 10 * magnitude; - end - first = ceil(lowerLimit / step) * step; - last = floor(upperLimit / step) * step; - ticks = first:step:last; -end - -function colors = groupColors(count) - palette = [ ... - 157 211 156 - 245 195 138 - 169 216 232 - 255 248 154] / 255; - colors = zeros(count, 3); - for k = 1:count - colors(k, :) = palette(mod(k - 1, size(palette, 1)) + 1, :); - end -end - -function offsets = deterministicJitter(count) - if count <= 1 - offsets = zeros(count, 1); - else - phase = (1:count).'; - % Constant: golden-angle radians distribute deterministic marker jitter. - offsets = 0.075 * sin(phase * 2.399963229728653); - offsets = offsets - mean(offsets); - end -end - -function drawSignificanceBracket(ax, x1, x2, y, height, textPad, label) - line(ax, [x1 x1 x2 x2], ... - [y y + height y + height y], ... - 'Color', 'black', 'LineWidth', 1.2, 'HitTest', 'off'); - text(ax, (x1 + x2) / 2, y + height + textPad, label, ... - 'HorizontalAlignment', 'center', ... - 'VerticalAlignment', 'bottom', ... - 'FontSize', 16, 'FontWeight', 'bold', 'HitTest', 'off'); -end - -function textValue = significanceText(result) - % Constant: conventional star thresholds for reported p-values. - if result.pValue < 1e-4 - textValue = '****'; - elseif result.pValue < 1e-3 - textValue = '***'; - elseif result.pValue < 1e-2 - textValue = '**'; - elseif result.pValue < result.alpha - textValue = '*'; - else - textValue = 'NS'; - end -end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/plotChoices.m b/apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/plotChoices.m deleted file mode 100644 index e3f839725..000000000 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/plotChoices.m +++ /dev/null @@ -1,10 +0,0 @@ -% App plot-choice owner; returns stable visible plot labels without side effects. -function value = plotChoices() -%PLOTCHOICES Return user-facing T-Test Wizard plot choices. -% -% Expected callers: project defaults, layout, presenter, and plot renderer. -% The output owns stable visible plot type labels. Side effects are none. - - value = struct(); - value.types = ["Mean bars with SD", "Box plot"]; -end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m b/apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m deleted file mode 100644 index 27f793594..000000000 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,303 +0,0 @@ -% App presenter; maps editable groups and result freshness to the workbench. -function view = presentWorkbench(state) -%PRESENTWORKBENCH Present source data, analysis rows, results, and plot state. -% -% Expected caller: Runtime. Input is canonical App state. Output updates -% source cells, the editable Group/Value table, capture-target choices, -% comparison results, plot freshness, export state, and one prepared mean/SD -% plot model. Side effects are none. - - source = state.session.cache.source; - groups = state.project.inputs.groups; - results = state.project.results.current; - options = currentTestOptions(state); - resultsCurrent = ttest_wizard.testRun.resultsMatchGroups( ... - results, groups, options); - - view = struct(); - view.controls.sourceFile = sourceFileSpec(state, source); - view.controls.sourceSheet = sourceSheetSpec(source); - view.controls.sourceSummary = valueSpec(source.message); - view.controls.selectionSummary = valueSpec( ... - state.session.selection.selectionMessage); - view.controls.captureTarget = captureTargetSpec( ... - state.project.parameters.captureTarget, groups); - view.controls.captureGroup = enabledSpec( ... - source.ok && ~isempty(state.session.selection.sourceCells)); - view.controls.batchGroupTarget = batchGroupTargetSpec( ... - state.session.selection.batchGroupTarget, groups); - view.controls.assignRowsToGroup = enabledSpec( ... - ~isempty(groups) && ~isempty(state.session.selection.analysisCells) && ... - state.session.selection.batchGroupTarget ~= "(select group)"); - view.controls.deleteSelectedRows = enabledSpec( ... - hasSelectedObservationRows(state, groups)); - view.controls.clearGroups = enabledSpec(~isempty(groups)); - view.controls.dataTable = tableSpec(observationRows(groups)); - view.controls.pendingTest = valueSpec(pendingTestText(state)); - view.controls.runComparisons = enabledSpec(canRun(groups)); - view.controls.resultStatus = valueSpec( ... - resultStatusText(results, resultsCurrent)); - view.controls.resultTable = tableSpec(resultTableData(results)); - view.controls.plotFreshness = valueSpec( ... - plotFreshnessText(results, resultsCurrent)); - - hasResult = ~isempty(results) && any([results.ok]); - plotControlIds = ["plotType", "showPoints", "showSummary", ... - "showPValue", "plotTitle", "yLabel"]; - for k = 1:numel(plotControlIds) - view.controls.(char(plotControlIds(k))) = enabledSpec(hasResult); - end - view.controls.exportData = enabledSpec(~isempty(groups)); - view.controls.exportResult = enabledSpec(~isempty(results)); - view.controls.lastDataExport = valueSpec(exportText( ... - state.project.results.lastDataExport)); - view.controls.lastResultExport = valueSpec(exportText( ... - state.project.results.lastResultExport)); - - [sourceData, columnNames, rowNames] = sourceTablePresentation(source); - view.controls.sourceGrid = struct( ... - "ColumnName", {columnNames}, ... - "RowName", {rowNames}, ... - "Data", {sourceData}); - view.previews.resultPlot = struct( ... - "Renderer", "resultPreview", ... - "Model", plotModel(results, state.project.parameters.plot)); -end - -function spec = sourceFileSpec(state, source) - files = struct("id", {}, "path", {}, "status", {}); - paths = labkit.ui.runtime.sourcePaths(state.project.inputs.sources); - if ~isempty(paths) - files = struct("id", "item1", "path", paths(1), "status", ""); - end - spec = struct("Files", files); - if source.ok - spec.Status = source.displayName; - else - spec.Status = "No table open"; - end -end - -function spec = sourceSheetSpec(source) - items = cellstr(source.sheetNames); - spec = struct( ... - "Items", {items}, ... - "Value", source.sheet, ... - "Enabled", source.ok && numel(items) > 1); -end - -function spec = captureTargetSpec(value, groups) - items = ["(new group)", [groups.label]]; - value = string(value); - if ~any(items == value) - value = items(1); - end - spec = struct("Items", {cellstr(items)}, "Value", value); -end - -function spec = batchGroupTargetSpec(value, groups) - items = ["(select group)", [groups.label]]; - value = string(value); - if ~any(items == value) - value = items(1); - end - spec = struct( ... - "Items", {cellstr(items)}, ... - "Value", value, ... - "Enabled", ~isempty(groups)); -end - -function [data, columns, rows] = sourceTablePresentation(source) - if source.ok && source.rowCount > 0 && source.columnCount > 0 - data = source.cells; - columns = source.columnNames; - rows = source.rowNames; - else - data = {'Open a CSV or workbook from the Data controls.'}; - columns = {'A'}; - rows = {'1'}; - end -end - -function data = observationRows(groups) - valueCount = sum(arrayfun(@(group) numel(group.values), groups)); - blankRows = 8; - data = cell(valueCount + blankRows, 2); - row = 0; - for groupIndex = 1:numel(groups) - for valueIndex = 1:numel(groups(groupIndex).values) - row = row + 1; - data{row, 1} = char(groups(groupIndex).label); - data{row, 2} = groups(groupIndex).values(valueIndex); - end - end - for k = row + 1:size(data, 1) - data{k, 1} = ''; - data{k, 2} = ''; - end -end - -function text = pendingTestText(state) - groups = state.project.inputs.groups; - if numel(groups) < 2 - text = sprintf( ... - '%d group(s) ready. Enter at least two; the first is reference.', ... - numel(groups)); - return; - end - counts = arrayfun(@(group) numel(group.values), groups); - method = string(state.project.parameters.testMethod); - if contains(method, "Paired") && any(counts(2:end) ~= counts(1)) - text = sprintf( ... - 'Paired testing needs every group to match reference samples (%d).', ... - counts(1)); - else - text = sprintf( ... - 'Run %d comparison(s) against %s using %s.', ... - numel(groups) - 1, groups(1).label, method); - end -end - -function tf = canRun(groups) - tf = numel(groups) >= 2 && ... - all(arrayfun(@(group) numel(group.values) >= 2, groups)); -end - -function tf = hasSelectedObservationRows(state, groups) - selected = state.session.selection.analysisCells; - observationCount = sum(arrayfun(@(group) numel(group.values), groups)); - tf = isnumeric(selected) && size(selected, 2) == 2 && ... - any(selected(:, 1) >= 1 & selected(:, 1) <= observationCount); -end - -function text = resultStatusText(results, isCurrent) - if isempty(results) - text = "No comparisons have been run."; - return; - end - text = string(sprintf( ... - '%d of %d comparison(s) completed against %s.', ... - sum([results.ok]), numel(results), results(1).labelA)); - if ~isCurrent - text = [text; ... - "Data or test settings changed; run again to refresh."]; - end -end - -function data = resultTableData(results) - if isempty(results) - data = {'Not run', '', '', '', ''}; - return; - end - data = cell(numel(results), 5); - for k = 1:numel(results) - result = results(k); - data(k, :) = { ... - char(result.labelB + " vs " + result.labelA), ... - formatNumber(result.meanDifference), ... - formatNumber(result.pValue), ... - char(significanceText(result)), ... - char(result.status)}; - end -end - -function text = plotFreshnessText(results, isCurrent) - if isempty(results) || ~any([results.ok]) - text = "No plot yet. Run comparisons after entering at least two groups."; - elseif isCurrent - text = "Current - plot and results match the analysis data."; - else - text = "OUT OF DATE - data or test settings changed. " + ... - "Run / refresh comparisons."; - end -end - -function text = significanceText(result) - % Constant: conventional star thresholds for reported p-values. - if ~result.ok - text = ""; - elseif result.pValue < 1e-4 - text = "****"; - elseif result.pValue < 1e-3 - text = "***"; - elseif result.pValue < 1e-2 - text = "**"; - elseif result.pValue < result.alpha - text = "*"; - else - text = "NS"; - end -end - -function model = plotModel(results, plotParameters) - ready = ~isempty(results) && any([results.ok]); - model = struct( ... - "ready", ready, ... - "message", "Enter data and run comparisons to create the plot.", ... - "groups", repmat(struct( ... - "label", "", "values", zeros(0, 1)), 0, 1), ... - "results", results, ... - "parameters", plotParameters, ... - "means", zeros(0, 1), ... - "standardDeviations", zeros(0, 1)); - if ~ready - return; - end - model.groups = snapshotsFromResults(results); - model.means = arrayfun(@(group) mean(group.values), model.groups); - model.standardDeviations = arrayfun( ... - @(group) std(group.values, 0), model.groups); -end - -function groups = snapshotsFromResults(results) - groups = repmat(struct("label", "", "values", zeros(0, 1)), ... - numel(results) + 1, 1); - groups(1).label = results(1).labelA; - groups(1).values = results(1).vectorA; - for k = 1:numel(results) - groups(k + 1).label = results(k).labelB; - groups(k + 1).values = results(k).vectorB; - end -end - -function options = currentTestOptions(state) - options = struct( ... - "method", state.project.parameters.testMethod, ... - "alternative", state.project.parameters.alternative, ... - "alpha", state.project.parameters.alpha); -end - -function spec = valueSpec(value) - spec = struct("Value", value); -end - -function spec = tableSpec(value) - spec = struct("Data", {value}); -end - -function spec = enabledSpec(value) - spec = struct("Enabled", logical(value)); -end - -function text = exportText(value) - value = string(value); - if strlength(value) == 0 - text = "Not exported"; - else - text = value; - end -end - -function value = formatNumber(value) - if isempty(value) || isnan(value) - value = ''; - elseif isinf(value) - if value > 0 - value = 'Inf'; - else - value = '-Inf'; - end - else - value = sprintf('%.10g', value); - end -end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+workbench/buildLayout.m b/apps/statistics/ttest_wizard/+ttest_wizard/+workbench/buildLayout.m new file mode 100644 index 000000000..a529e54ac --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+workbench/buildLayout.m @@ -0,0 +1,40 @@ +% App-owned implementation for ttest_wizard.workbench.buildLayout within the ttest_wizard product workflow. +function layout = buildLayout() +%BUILDLAYOUT Compose the T-Test Wizard workflow capabilities. +% +% Expected caller: ttest_wizard.definition. Each workflow package owns its +% controls, callbacks, and workspace surface. This function shows the product +% structure in user order and performs no IO, calculation, or handle creation. + +dataControls = { ... + ttest_wizard.sourceTable.layoutSection(), ... + ttest_wizard.groupData.layoutSection()}; +testControls = [ ... + ttest_wizard.testRun.layoutSections(), ... + {ttest_wizard.resultPlot.layoutSection()}]; +exportControls = {ttest_wizard.resultFiles.layoutSection()}; +controls = { ... + labkit.app.layout.tab("data", "1 Data", dataControls), ... + labkit.app.layout.tab("analysis", "2 Test & Plot", testControls), ... + labkit.app.layout.tab("export", "3 Export", exportControls), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("appLog")})})}; + +workspace = labkit.app.layout.workspace(Title="Data and Plot"); +workspace = workspace.page("dataPage", "Data", { ... + ttest_wizard.sourceTable.workspaceTable(), ... + ttest_wizard.groupData.workspaceTable()}); +workspace = workspace.page( ... + "plotPage", "Plot", ttest_wizard.resultPlot.workspacePlot()); +workspace = workspace.initialPage("dataPage"); + +layout = labkit.app.layout.workbench( ... + controls, Workspace=workspace, ... + UsageTitle="Fast workflow", ... + Usage=[ ... + "1. Open/paste data and assign values to groups.", ... + "2. Keep the reference group first and run comparisons.", ... + "3. Switch the workspace to Plot and adjust presentation.", ... + "4. Rerun after any data or test change to refresh the plot."]); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+workbench/present.m b/apps/statistics/ttest_wizard/+ttest_wizard/+workbench/present.m new file mode 100644 index 000000000..e287f03f4 --- /dev/null +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+workbench/present.m @@ -0,0 +1,40 @@ +% App-owned implementation for ttest_wizard.workbench.present within the ttest_wizard product workflow. +function view = present(state) +%PRESENT Compose feature-owned T-Test Wizard view fragments. +% +% Expected caller: LabKit App Runtime. This boundary function makes every +% feature dependency explicit, then composes immutable fragments. It performs +% no IO, calculation, native-handle access, or workflow mutation. + +arguments + state (1, 1) struct +end + +source = state.session.cache.source; +selection = state.session.selection; +groups = state.project.inputs.groups; +parameters = state.project.parameters; +results = state.project.results.current; +testOptions = struct( ... + "method", parameters.testMethod, ... + "alternative", parameters.alternative, ... + "alpha", parameters.alpha); +resultsCurrent = ttest_wizard.testRun.resultsMatchGroups( ... + results, groups, testOptions); + +view = labkit.app.view.Snapshot() ... + .include(ttest_wizard.sourceTable.present( ... + source, selection.sourceCells, selection.selectionMessage)) ... + .include(ttest_wizard.groupData.present( ... + groups, parameters.captureTarget, ... + selection.batchGroupTarget, selection.analysisCells, ... + source.ok, selection.sourceCells)) ... + .include(ttest_wizard.testRun.present( ... + groups, results, testOptions, resultsCurrent)) ... + .include(ttest_wizard.resultPlot.present( ... + results, parameters.plot, resultsCurrent)) ... + .include(ttest_wizard.resultFiles.present( ... + groups, results, ... + state.project.results.lastDataExport, ... + state.project.results.lastResultExport)); +end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/createSession.m b/apps/statistics/ttest_wizard/+ttest_wizard/createSession.m index b534950d8..a92beb46f 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/createSession.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/createSession.m @@ -1,17 +1,22 @@ % App session factory; rebuilds transient table, selection, and workspace state. -function session = createSession(project) +function session = createSession(project, context) %CREATESESSION Rebuild transient source-grid and selection state. % % Expected caller: Runtime through ttest_wizard.definition. The optional -% current source is read strictly into a transient cell grid; copied groups -% remain durable in project state. Existing unreadable sources throw -% so project loading cannot silently erase the table. Reading is the only side -% effect. +% current source is resolved through the runtime context and read strictly into +% a transient cell grid; copied groups remain durable in project state. +% Existing unreadable sources throw so project loading cannot silently erase +% the table. Reading is the only side effect. + + arguments + project (1, 1) struct + context (1, 1) labkit.app.CallbackContext + end source = ttest_wizard.sourceTable.emptySource(); if ~isempty(project.inputs.sources) - filepath = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources(1)); + paths = context.resolveSourcePaths(project.inputs.sources); + filepath = paths(1); source = ttest_wizard.sourceTable.readSourceTable( ... filepath, project.inputs.sourceSheet); end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/definition.m b/apps/statistics/ttest_wizard/+ttest_wizard/definition.m index 5c4cc8eea..d674eca0a 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/definition.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/definition.m @@ -1,26 +1,23 @@ -% App product factory; returns the complete runtime definition without side effects. -function def = definition() -%DEFINITION Return the T-Test Wizard runtime product contract. +% App product factory; returns the complete SDK definition without side effects. +function app = definition() +%DEFINITION Return the T-Test Wizard App SDK contract. % % Expected caller: labkit_TTestWizard_app. The output declares the product -% version, project/session factories, semantic layout, actions, presenter, +% version, project/session factories, semantic layout, handlers, view builder, % plot renderer, and synthetic debug sample. Side effects are none. - def = labkit.ui.runtime.define( ... - "Command", "labkit_TTestWizard_app", ... - "Id", "ttest_wizard", ... - "Title", "T-Test Wizard", ... - "DisplayName", "T-Test Wizard", ... - "Family", "Statistics", ... - "AppVersion", "1.0.1", ... - "Updated", "2026-07-19", ... - "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ... - "Project", ttest_wizard.projectSpec(), ... - "CreateSession", @ttest_wizard.createSession, ... - "Layout", @ttest_wizard.userInterface.buildWorkbenchLayout, ... - "Actions", ttest_wizard.definitionActions(), ... - "Present", @ttest_wizard.userInterface.presentWorkbench, ... - "Renderers", struct("resultPreview", ... - @ttest_wizard.userInterface.drawResultPreview), ... - "DebugSample", @ttest_wizard.debug.writeSamplePack); + app = labkit.app.Definition( ... + Entrypoint="labkit_TTestWizard_app", ... + AppId="ttest_wizard", ... + Title="T-Test Wizard", ... + DisplayName="T-Test Wizard", ... + Family="Statistics", ... + AppVersion="1.1.1", ... + Updated="2026-07-20", ... + Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + ProjectSchema=ttest_wizard.projectSpec(), ... + CreateSession=@ttest_wizard.createSession, ... + Workbench=ttest_wizard.workbench.buildLayout(), ... + PresentWorkbench=@ttest_wizard.workbench.present, ... + BuildDebugSample=@ttest_wizard.debug.writeSamplePack); end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m b/apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m deleted file mode 100644 index 3b713854c..000000000 --- a/apps/statistics/ttest_wizard/+ttest_wizard/definitionActions.m +++ /dev/null @@ -1,469 +0,0 @@ -% App workflow registry; returns handlers for table, analysis, plot, and export. -function actions = definitionActions() -%DEFINITIONACTIONS Return T-Test Wizard semantic workflow handlers. -% -% Expected caller: ttest_wizard.definition. Handlers own source loading, -% visible cell selection, editable ordered groups, first-group comparisons, -% result freshness, and CSV export. They do not access the UI registry. - - actions = struct( ... - "openSource", @onOpenSource, ... - "clearSource", @onClearSource, ... - "sheetChanged", @onSheetChanged, ... - "sourceSelectionChanged", @onSourceSelectionChanged, ... - "analysisSelectionChanged", @onAnalysisSelectionChanged, ... - "captureGroup", @onCaptureGroup, ... - "assignRowsToGroup", @onAssignRowsToGroup, ... - "deleteSelectedRows", @onDeleteSelectedRows, ... - "groupsEdited", @onGroupsEdited, ... - "clearGroups", @onClearGroups, ... - "testSettingsChanged", @onTestSettingsChanged, ... - "runComparisons", @onRunComparisons, ... - "plotChanged", @onPlotChanged, ... - "exportData", @onExportData, ... - "exportResult", @onExportResult); -end - -function state = onOpenSource(state, event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - if isempty(paths) - return; - end - filepath = paths(end); - try - source = ttest_wizard.sourceTable.readSourceTable(filepath); - catch ME - services.diagnostics.report("Open source table", ME); - services.dialogs.alert(ME.message, "Open table"); - state = services.workflow.log(state, ... - "Could not open table: " + string(ME.message)); - return; - end - state.project.inputs.sources = services.project.reconcileSources( ... - state.project.inputs.sources, filepath, "table", "table", true); - state.project.inputs.sourceSheet = source.sheet; - state.session.cache.source = source; - state.session.selection.sourceCells = zeros(0, 2); - state.session.selection.selectionMessage = ... - "Select numeric cells in the opened table."; - state = services.workflow.log(state, ... - "Opened table: " + source.displayName + " | " + source.message); -end - -function state = onClearSource(state, ~, services) - state.project.inputs.sources = services.project.reconcileSources( ... - state.project.inputs.sources, strings(0, 1), ... - "table", "table", true); - state.project.inputs.sourceSheet = "(no source)"; - state.session.cache.source = ttest_wizard.sourceTable.emptySource(); - state.session.selection.sourceCells = zeros(0, 2); - state.session.selection.selectionMessage = ... - "Open a table or enter data directly below."; - state = services.workflow.log(state, ... - "Cleared the source table; analysis data were kept."); -end - -function state = onSheetChanged(state, event, services) - source = state.session.cache.source; - if ~source.ok || isempty(state.project.inputs.sources) - return; - end - requested = string(event.value); - if ~isscalar(requested) || ~any(requested == source.sheetNames) - requested = source.sheetNames(1); - end - filepath = labkit.ui.runtime.sourcePaths( ... - state.project.inputs.sources(1)); - try - source = ttest_wizard.sourceTable.readSourceTable( ... - filepath, requested); - catch ME - services.diagnostics.report("Change worksheet", ME); - services.dialogs.alert(ME.message, "Worksheet"); - return; - end - state.project.inputs.sourceSheet = source.sheet; - state.session.cache.source = source; - state.session.selection.sourceCells = zeros(0, 2); - state.session.selection.selectionMessage = ... - "Select numeric cells in the new worksheet."; - state = services.workflow.log(state, "Worksheet: " + source.sheet); -end - -function state = onSourceSelectionChanged(state, event, services) - indices = services.events.entries(event, "indices"); - if ~isnumeric(indices) || size(indices, 2) ~= 2 - indices = zeros(0, 2); - end - state.session.selection.sourceCells = double(indices); - source = state.session.cache.source; - if ~source.ok || isempty(indices) - state.session.selection.selectionMessage = ... - "Select numeric cells in the opened table."; - return; - end - selection = ttest_wizard.sourceTable.extractNumericSelection( ... - source.cells, indices); - state.session.selection.selectionMessage = selection.message; -end - -function state = onAnalysisSelectionChanged(state, event, services) - indices = services.events.entries(event, "indices"); - if ~isnumeric(indices) || size(indices, 2) ~= 2 - indices = zeros(0, 2); - end - state.session.selection.analysisCells = double(indices); -end - -function state = onCaptureGroup(state, ~, services) - source = state.session.cache.source; - indices = state.session.selection.sourceCells; - if ~source.ok || isempty(indices) - services.dialogs.alert( ... - "Select cells in the opened table first.", "Select data"); - return; - end - selection = ttest_wizard.sourceTable.extractNumericSelection( ... - source.cells, indices); - state.session.selection.selectionMessage = selection.message; - if ~selection.ok - services.dialogs.alert(selection.message, ... - "Selected cells cannot be used"); - return; - end - - groups = state.project.inputs.groups; - target = string(state.project.parameters.captureTarget); - groupIndex = find(string({groups.label}) == target, 1); - if isempty(groupIndex) - label = ttest_wizard.sourceTable.suggestGroupLabel( ... - source.cells, indices, [groups.label]); - group = emptyGroup(label); - group.sourceDisplayName = source.displayName; - group.sheet = source.sheet; - groups(end + 1, 1) = group; - groupIndex = numel(groups); - end - groups(groupIndex).values = [ ... - groups(groupIndex).values(:); selection.values(:)]; - groups(groupIndex).cellAddresses = [ ... - groups(groupIndex).cellAddresses(:); selection.addresses(:)]; - state.project.inputs.groups = groups; - state.project.parameters.captureTarget = "(new group)"; - state = dataChanged(state); - state = services.workflow.log(state, sprintf( ... - 'Added %d value(s) to %s.', ... - selection.acceptedCount, groups(groupIndex).label)); -end - -function state = onAssignRowsToGroup(state, ~, services) - target = string(state.session.selection.batchGroupTarget); - selectedRows = selectedObservationRows(state); - if target == "(select group)" || isempty(selectedRows) - return; - end - groups = reassignObservationRows( ... - state.project.inputs.groups, selectedRows, target); - state.project.inputs.groups = groups; - state.session.selection.analysisCells = zeros(0, 2); - state = dataChanged(state); - state = services.workflow.log(state, sprintf( ... - 'Changed %d selected row(s) to %s.', ... - numel(selectedRows), target)); -end - -function state = onDeleteSelectedRows(state, ~, services) - selectedRows = selectedObservationRows(state); - if isempty(selectedRows) - return; - end - state.project.inputs.groups = deleteObservationRows( ... - state.project.inputs.groups, selectedRows); - state.session.selection.analysisCells = zeros(0, 2); - remainingLabels = string({state.project.inputs.groups.label}); - if ~any(remainingLabels == string( ... - state.project.parameters.captureTarget)) - state.project.parameters.captureTarget = "(new group)"; - end - if ~any(remainingLabels == string( ... - state.session.selection.batchGroupTarget)) - state.session.selection.batchGroupTarget = "(select group)"; - end - state = dataChanged(state); - state = services.workflow.log(state, sprintf( ... - 'Deleted %d selected row(s).', numel(selectedRows))); -end - -function state = onGroupsEdited(state, event, services) - data = event.value; - if ~iscell(data) || size(data, 2) < 2 - services.dialogs.alert( ... - "The data table must contain Group and Value columns.", ... - "Edit analysis data"); - return; - end - [groups, ok, message] = groupsFromRows( ... - data(:, 1), data(:, 2), state.project.inputs.groups); - if ~ok - services.dialogs.alert(message, "Edit analysis data"); - return; - end - state.project.inputs.groups = groups; - if ~any([groups.label] == string( ... - state.project.parameters.captureTarget)) - state.project.parameters.captureTarget = "(new group)"; - end - state = dataChanged(state); - state = services.workflow.log(state, sprintf( ... - 'Updated analysis data: %d group(s), %d value(s).', ... - numel(groups), sum(arrayfun( ... - @(group) numel(group.values), groups)))); -end - -function state = onClearGroups(state, ~, services) - state.project.inputs.groups = repmat(emptyGroup("Group 1"), 0, 1); - state.project.parameters.captureTarget = "(new group)"; - state.session.selection.analysisCells = zeros(0, 2); - state.session.selection.batchGroupTarget = "(select group)"; - state = dataChanged(state); - state = services.workflow.log(state, "Cleared all analysis data."); -end - -function state = dataChanged(state) - state.project.results.lastDataExport = ""; -end - -function state = onTestSettingsChanged(state, ~, services) - alpha = double(state.project.parameters.alpha); - if ~isscalar(alpha) || ~isfinite(alpha) || alpha <= 0 || alpha >= 1 - state.project.parameters.alpha = 0.05; - services.dialogs.alert( ... - "Alpha must be between zero and one. It was reset to 0.05.", ... - "Test settings"); - end -end - -function state = onRunComparisons(state, ~, services) - groups = state.project.inputs.groups; - if numel(groups) < 2 - services.dialogs.alert( ... - "Enter at least two groups before running comparisons.", ... - "T-tests"); - return; - end - results = ttest_wizard.testRun.runGroupTTests( ... - groups, currentTestOptions(state)); - state.project.results.current = results; - state.project.results.lastResultExport = ""; - okCount = sum([results.ok]); - state = services.workflow.log(state, sprintf( ... - 'Completed %d of %d comparison(s) against %s.', ... - okCount, numel(results), groups(1).label)); - if okCount < numel(results) - failed = results(~[results.ok]); - services.dialogs.alert(strjoin(unique([failed.message]), newline), ... - "Some t-tests were not completed"); - end -end - -function state = onPlotChanged(state, ~, ~) - % Plot-only settings intentionally do not change the result family. -end - -function state = onExportData(state, ~, services) - groups = state.project.inputs.groups; - if isempty(groups) - services.dialogs.alert( ... - "Enter group data before exporting.", "Export data"); - return; - end - [filepath, cancelled] = services.dialogs.outputFile( ... - {'*.csv', 'CSV table (*.csv)'}, ... - "Export group data", "ttest_group_data.csv"); - if cancelled - return; - end - filepath = ensureCsvExtension(filepath); - try - ttest_wizard.sourceTable.writeGroupCsv(filepath, groups); - catch ME - services.diagnostics.report("Export group data", ME); - services.dialogs.alert(ME.message, "Export data"); - return; - end - state.project.results.lastDataExport = string(filepath); - state = services.workflow.log(state, ... - "Exported group data: " + string(filepath)); -end - -function state = onExportResult(state, ~, services) - results = state.project.results.current; - if isempty(results) - services.dialogs.alert( ... - "Run comparisons before exporting results.", "Export results"); - return; - end - [filepath, cancelled] = services.dialogs.outputFile( ... - {'*.csv', 'CSV table (*.csv)'}, ... - "Export t-test results", "ttest_results.csv"); - if cancelled - return; - end - filepath = ensureCsvExtension(filepath); - try - ttest_wizard.resultFiles.writeResultCsv(filepath, results); - catch ME - services.diagnostics.report("Export t-test results", ME); - services.dialogs.alert(ME.message, "Export results"); - return; - end - state.project.results.lastResultExport = string(filepath); - state = services.workflow.log(state, ... - "Exported t-test results: " + string(filepath)); -end - -function [groups, ok, message] = groupsFromRows( ... - labels, values, priorGroups) - groups = repmat(emptyGroup("Group 1"), 0, 1); - ok = true; - message = ""; - priorLabel = ""; - for row = 1:numel(values) - label = strip(string(labels{row})); - value = values{row}; - if isBlankValue(value) && strlength(label) == 0 - continue; - end - if strlength(label) == 0 - label = priorLabel; - end - if strlength(label) == 0 - ok = false; - message = sprintf( ... - 'Row %d needs a group name before its value.', row); - return; - end - [number, valid] = finiteScalar(value); - if ~valid - ok = false; - message = sprintf( ... - 'Row %d Value must be one finite number.', row); - return; - end - groupIndex = find(strcmpi(label, [groups.label]), 1); - if isempty(groupIndex) - group = emptyGroup(label); - priorIndex = find(strcmpi(label, [priorGroups.label]), 1); - if ~isempty(priorIndex) - group = priorGroups(priorIndex); - group.values = zeros(0, 1); - group.cellAddresses = strings(0, 1); - else - group.sourceDisplayName = "Manual data table"; - end - groups(end + 1, 1) = group; - groupIndex = numel(groups); - end - groups(groupIndex).values(end + 1, 1) = number; - priorLabel = groups(groupIndex).label; - end -end - -function groups = reassignObservationRows(groups, selectedRows, target) - visibleRows = cell(numel(groups), 1); - movedValueParts = repmat({zeros(0, 1)}, numel(groups), 1); - movedAddressParts = repmat({strings(0, 1)}, numel(groups), 1); - rowOffset = 0; - for groupIndex = 1:numel(groups) - count = numel(groups(groupIndex).values); - visibleRows{groupIndex} = rowOffset + (1:count); - rowOffset = rowOffset + count; - move = ismember(visibleRows{groupIndex}, selectedRows); - if groups(groupIndex).label ~= target - movedValueParts{groupIndex} = groups(groupIndex).values(move); - addresses = string(groups(groupIndex).cellAddresses(:)); - if numel(addresses) == count - movedAddressParts{groupIndex} = addresses(move); - groups(groupIndex).cellAddresses = addresses(~move); - end - groups(groupIndex).values = groups(groupIndex).values(~move); - end - end - movedValues = vertcat(movedValueParts{:}); - movedAddresses = vertcat(movedAddressParts{:}); - targetIndex = find([groups.label] == target, 1); - groups(targetIndex).values = [ ... - groups(targetIndex).values(:); movedValues]; - groups(targetIndex).cellAddresses = [ ... - string(groups(targetIndex).cellAddresses(:)); movedAddresses]; - groups = groups(arrayfun(@(group) ~isempty(group.values), groups)); -end - -function groups = deleteObservationRows(groups, selectedRows) - rowOffset = 0; - for groupIndex = 1:numel(groups) - count = numel(groups(groupIndex).values); - visibleRows = rowOffset + (1:count); - rowOffset = rowOffset + count; - remove = ismember(visibleRows, selectedRows); - groups(groupIndex).values = groups(groupIndex).values(~remove); - addresses = string(groups(groupIndex).cellAddresses(:)); - if numel(addresses) == count - groups(groupIndex).cellAddresses = addresses(~remove); - else - groups(groupIndex).cellAddresses = strings(0, 1); - end - end - groups = groups(arrayfun(@(group) ~isempty(group.values), groups)); -end - -function rows = selectedObservationRows(state) - rows = unique(state.session.selection.analysisCells(:, 1), 'stable'); - observationCount = sum(arrayfun( ... - @(group) numel(group.values), state.project.inputs.groups)); - rows = rows(rows >= 1 & rows <= observationCount); -end - -function tf = isBlankValue(value) - tf = isempty(value) || ... - ((ischar(value) || (isstring(value) && isscalar(value))) && ... - strlength(strip(string(value))) == 0); -end - -function [number, valid] = finiteScalar(value) - if (isnumeric(value) || islogical(value)) && isscalar(value) - number = double(value); - elseif ischar(value) || (isstring(value) && isscalar(value)) - number = str2double(strip(string(value))); - else - number = NaN; - end - valid = isfinite(number); -end - -function value = emptyGroup(label) - value = struct( ... - "label", string(label), ... - "values", zeros(0, 1), ... - "sourceDisplayName", "", ... - "sheet", "", ... - "cellAddresses", strings(0, 1)); -end - -function options = currentTestOptions(state) - options = struct( ... - "method", state.project.parameters.testMethod, ... - "alternative", state.project.parameters.alternative, ... - "alpha", state.project.parameters.alpha); -end - -function filepath = ensureCsvExtension(filepath) - filepath = string(filepath); - [folder, name, extension] = fileparts(filepath); - if strlength(string(extension)) == 0 - filepath = string(fullfile(folder, name + ".csv")); - end -end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/projectSpec.m b/apps/statistics/ttest_wizard/+ttest_wizard/projectSpec.m index 3233e3017..68bb140c2 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/projectSpec.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/projectSpec.m @@ -2,25 +2,24 @@ function spec = projectSpec() %PROJECTSPEC Declare durable T-Test Wizard project state. % -% Expected caller: ttest_wizard.definition. The output owns project version 2, -% creation defaults, A/B-to-group migration, and validation. Side effects are -% none. +% Expected caller: ttest_wizard.definition. The output owns project payload +% version 2, creation defaults, A/B-to-group migration, and validation. Side +% effects are none. - spec = struct( ... - "Version", 2, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", @migrateProject); + spec = labkit.app.project.Schema( ... + Version=2, Create=@createProject, Validate=@validateProject, ... + Migrate=@migrateProject); end function project = createProject() testChoices = ttest_wizard.testRun.choices(); - plotChoices = ttest_wizard.userInterface.plotChoices(); + plotChoices = ttest_wizard.resultPlot.choices(); project = struct(); project.inputs = struct( ... - "sources", labkit.ui.runtime.emptySourceRecords(), ... + "sources", struct([]), ... "sourceSheet", "(no source)", ... - "groups", repmat(emptyGroup("Group 1"), 0, 1)); + "groups", repmat( ... + ttest_wizard.groupData.emptyGroup("Group 1"), 0, 1)); project.parameters = struct( ... "captureTarget", "(new group)", ... "testMethod", testChoices.methodLabels(1), ... @@ -41,15 +40,6 @@ project.extensions = struct(); end -function value = emptyGroup(label) - value = struct( ... - "label", string(label), ... - "values", zeros(0, 1), ... - "sourceDisplayName", "", ... - "sheet", "", ... - "cellAddresses", strings(0, 1)); -end - function project = migrateProject(project, fromVersion) switch double(fromVersion) case 1 @@ -88,7 +78,7 @@ 'T-Test Wizard input state is invalid.'); testChoices = ttest_wizard.testRun.choices(); - plotChoices = ttest_wizard.userInterface.plotChoices(); + plotChoices = ttest_wizard.resultPlot.choices(); parameters = project.parameters; assert(isstruct(parameters) && isscalar(parameters) && ... all(isfield(parameters, ... diff --git a/apps/statistics/ttest_wizard/labkit_TTestWizard_app.m b/apps/statistics/ttest_wizard/labkit_TTestWizard_app.m index c232b589d..cbd8b9b07 100644 --- a/apps/statistics/ttest_wizard/labkit_TTestWizard_app.m +++ b/apps/statistics/ttest_wizard/labkit_TTestWizard_app.m @@ -13,7 +13,7 @@ % equal-variance, or paired t-tests, and draws a grouped mean/SD plot. % % Inputs: -% varargin - Launch requests accepted by labkit.ui.runtime.launch. +% varargin - Launch requests accepted by labkit.app.Definition.launch. % % Outputs: % varargout - Launch, version, requirements, or debug outputs returned by the @@ -22,8 +22,8 @@ % Typical Call: % labkit_TTestWizard_app % -% See also labkit.ui.runtime.launch +% See also labkit.app.Definition - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @ttest_wizard.definition, varargin{:}); + [varargout{1:nargout}] = ... + ttest_wizard.definition().launch(varargin{:}); end diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/analyze.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/analyze.m new file mode 100644 index 000000000..b302eedd2 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/analyze.m @@ -0,0 +1,38 @@ +% App-owned implementation for ecg_print.analysisRun.analyze within the ecg_print product workflow. +function applicationState = analyze(applicationState, callbackContext) +%ANALYZE Filter, detect, segment, template, and measure the current ECG ROI. +if isempty(applicationState.session.cache.signal) + callbackContext.alert( ... + "Open a recording and select a channel first.", ... + "No channel selected"); + return; +end +applicationState.project.parameters = ... + ecg_print.analysisRun.sanitizeParameters( ... + applicationState.project.parameters, ... + applicationState.session.cache.signal.fs); +try + applicationState.session.cache = ... + ecg_print.analysisRun.analyzeSignal( ... + applicationState.session.cache, ... + applicationState.project.parameters); +catch ME + callbackContext.reportError("ECG analysis", ME); + callbackContext.alert(ME.message, "Analysis failed"); + callbackContext.appendStatus("Analysis failed: " + ME.message); + return; +end +cache = applicationState.session.cache; +applicationState.project.results.lastAnalysis = struct( ... + "channel", applicationState.project.parameters.channel, ... + "eventCount", numel(cache.events.index), ... + "segmentCount", size(cache.segments.values, 2), ... + "summary", cache.measurements.summary, ... + "perSegment", cache.measurements.perSegment); +applicationState.project.results.lastSegmentExport = []; +applicationState.project.results.lastWaveformExport = []; +callbackContext.appendStatus(sprintf( ... + "Analyzed ROI with %s: %d peaks, %d valid segments.", ... + applicationState.project.parameters.peakMethod, ... + numel(cache.events.index), size(cache.segments.values, 2))); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/changeChannel.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/changeChannel.m new file mode 100644 index 000000000..6a943ad53 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/changeChannel.m @@ -0,0 +1,32 @@ +% App-owned implementation for ecg_print.analysisRun.changeChannel within the ecg_print product workflow. +function applicationState = changeChannel( ... + applicationState, channel, callbackContext) +%CHANGECHANNEL Adopt one decoded channel and invalidate dependent products. +channel = string(channel); +if channel == "(none)" || ... + isempty(applicationState.session.cache.recording) + return; +end +try + signal = labkit.biosignal.getChannel( ... + applicationState.session.cache.recording, channel); +catch ME + callbackContext.reportError("Channel selection", ME); + callbackContext.alert(ME.message, "Channel selection failed"); + return; +end +applicationState.project.parameters.channel = channel; +applicationState.project.parameters.roiStart = 0; +applicationState.project.parameters.roiEnd = max(signal.time); +applicationState.session.cache.signal = signal; +applicationState.session.cache.workingSignal = signal; +applicationState.session.cache.filteredSignal = []; +applicationState.session.cache.events = []; +applicationState.session.cache.segments = []; +applicationState.session.cache.template = []; +applicationState.session.cache.measurements = []; +applicationState.project.results.lastAnalysis = struct(); +applicationState.project.results.lastSegmentExport = []; +applicationState.project.results.lastWaveformExport = []; +callbackContext.appendStatus("Selected channel: " + channel); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/drawPreview.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/drawPreview.m new file mode 100644 index 000000000..b5a8ab2f9 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/drawPreview.m @@ -0,0 +1,134 @@ +% Expected caller: App SDK registered renderer and waveform PNG export. +% Inputs are one axes and an app-owned axis model. Side effects are limited to +% redrawing that axes; no runtime controls or app state are read. +function drawPreview(axesById, model) + if isgraphics(axesById, "axes") + labkit.app.plot.clearAxes(axesById, ResetScale=true); + drawOne(axesById, model); + return; + end + axisIds = ["wave", "noise", "snr", "template"]; + for k = 1:numel(axisIds) + ax = axesById.(axisIds(k)); + labkit.app.plot.clearAxes(ax, ResetScale=true); + drawOne(ax, model(k)); + end +end + +function drawOne(ax, model) + switch string(model.kind) + case "wave" + drawWave(ax, model.request); + case "noise" + drawMetric(ax, model.analysis, model.smoothBeats, "noise"); + case "snr" + drawMetric(ax, model.analysis, model.smoothBeats, "snr"); + case "template" + drawTemplate(ax, model.request); + end + makeDisplayGraphicsNonPickable(ax); +end + +function drawWave(ax, request) + title(ax, request.title); + xlabel(ax, request.xLabel); + ylabel(ax, request.yLabel); + if ~request.ok + return; + end + plot(ax, request.x, request.y, "Color", request.lineColor, ... + "LineWidth", 1); + hold(ax, "on"); + if ~isempty(request.peakX) + scatter(ax, request.peakX, request.peakY, 24, ... + request.peakColor, "filled"); + end + hold(ax, "off"); + grid(ax, "on"); +end + +function drawMetric(ax, analysis, smoothBeats, kind) + if kind == "noise" + title(ax, sprintf("Template Noise RMS Over Time | Smooth=%d beats", ... + smoothBeats)); + ylabel(ax, "Noise RMS"); + else + title(ax, sprintf("Template SNR Over Time | Smooth=%d beats", ... + smoothBeats)); + ylabel(ax, "SNR (dB)"); + end + xlabel(ax, "Time (s)"); + if height(analysis) == 0 + return; + end + if kind == "noise" + raw = analysis.NoiseRMS; + smooth = analysis.NoiseRMS_smooth; + colors = [0.20 0.45 0.72; 0.05 0.20 0.45]; + else + raw = analysis.SNRdB; + smooth = analysis.SNRdB_smooth; + colors = [0.18 0.55 0.32; 0.05 0.32 0.16]; + end + plot(ax, analysis.EventTime, raw, ".", "MarkerSize", 12, ... + "Color", colors(1, :)); + hold(ax, "on"); + plot(ax, analysis.EventTime, smooth, "-", "LineWidth", 1.5, ... + "Color", colors(2, :)); + hold(ax, "off"); + grid(ax, "on"); +end + +function drawTemplate(ax, request) + title(ax, request.title); + xlabel(ax, request.xLabel); + ylabel(ax, request.yLabel); + if ~request.ok + return; + end + hold(ax, "on"); + if request.showSegments + plot(ax, request.timeOffset, request.segments(:, request.showIndex), ... + "Color", [0.78 0.84 0.92], "LineWidth", 0.5); + else + fill(ax, [request.timeOffset; flipud(request.timeOffset)], ... + [request.upper; flipud(request.lower)], [0.20 0.20 0.20], ... + "FaceAlpha", 0.15, "EdgeColor", "none"); + end + plot(ax, request.timeOffset, request.template, "k-", "LineWidth", 2); + xline(ax, 0, "--r", "R"); + shadeWindows(ax, request); + hold(ax, "off"); + grid(ax, "on"); +end + +function shadeWindows(ax, request) + if isempty(request.signalWindowSec) + return; + end + limits = ax.YLim; + drawWindow(ax, request.signalWindowSec, limits, [1.00 0.20 0.20]); + for k = 1:size(request.noiseWindowsSec, 1) + drawWindow(ax, request.noiseWindowsSec(k, :), ... + limits, [0.00 0.45 1.00]); + end +end + +function drawWindow(ax, windowSec, limits, color) + fill(ax, [windowSec(1) windowSec(2) windowSec(2) windowSec(1)], ... + [limits(1) limits(1) limits(2) limits(2)], color, ... + "FaceAlpha", 0.08, "EdgeColor", "none", ... + "HitTest", "off", "PickableParts", "none"); +end + +function makeDisplayGraphicsNonPickable(ax) +graphics = allchild(ax); +for k = 1:numel(graphics) + if isprop(graphics(k), "HitTest") + graphics(k).HitTest = "off"; + end + if isprop(graphics(k), "PickableParts") + graphics(k).PickableParts = "none"; + end +end +end diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/initialSummaryRows.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/initialSummaryRows.m new file mode 100644 index 000000000..c91963e34 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/initialSummaryRows.m @@ -0,0 +1,10 @@ +% Expected caller: ecg_print.workbench.buildLayout, +% ecg_print.analysisRun.summaryRows, and direct +% unit tests. Output is a two-column cell array for the summary table. Side +% effects: none. + +function rows = initialSummaryRows() +%INITIALSUMMARYROWS Return the ECG Print empty-state summary table rows. + + rows = {'Status', 'No signal analyzed'}; +end diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/layoutSections.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/layoutSections.m new file mode 100644 index 000000000..0d66a74d1 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/layoutSections.m @@ -0,0 +1,38 @@ +% App-owned implementation for ecg_print.analysisRun.layoutSections within the ecg_print product workflow. +function sections = layoutSections() +%LAYOUTSECTIONS Declare channel, ROI, processing, and SNR controls. +channel = labkit.app.layout.section( ... + "channelSection", "Channel + ROI", { ... + labkit.app.layout.field("channel", Label="Channel:", ... + Kind="choice", Choices="(none)", ... + Bind="project.parameters.channel", ... + OnValueChanged=@ecg_print.analysisRun.changeChannel), ... + pannerField("roiStart", "ROI start (s):", [0 86400], 1), ... + pannerField("roiEnd", "ROI end (s):", [0 86400], 1)}); +processing = labkit.app.layout.section( ... + "processingSection", "Signal Processing + SNR", { ... + pannerField("lowCut", "Bandpass low Hz:", [0 10000], 0.1), ... + pannerField("highCut", "Bandpass high Hz:", [0 10000], 1), ... + labkit.app.layout.field("peakMethod", Label="Peak method:", ... + Kind="choice", ... + Choices=["QRS streaming", "Pan-Tompkins", "Local peaks"], ... + Bind="project.parameters.peakMethod"), ... + pannerField("peakDistance", "Peak distance (s):", [0.01 10], 0.01), ... + pannerField("segmentWindow", ... + "Segment half win (s):", [0.01 30], 0.05), ... + pannerField("templateTopN", "Template top N:", [1 10000], 1), ... + pannerField("smoothBeats", "Smooth beats:", [1 10000], 1), ... + labkit.app.layout.field("templateView", Label="Template plot:", ... + Kind="choice", ... + Choices=["Template + residual band", "Template + segments"], ... + Bind="project.parameters.templateView"), ... + labkit.app.layout.button("analyze", ... + "Analyze current ROI", @ecg_print.analysisRun.analyze, ... + Tooltip="Bandpass the selected ECG interval, detect beats, build the template, and calculate segment SNR.")}); +sections = {channel, processing}; +end + +function node = pannerField(id, label, limits, step) +node = labkit.app.layout.slider(id, Label=label, ... + Limits=limits, Step=step, Bind="project.parameters." + id); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/peakMethodValue.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/peakMethodValue.m index c129ce866..bbfe33883 100644 --- a/apps/wearable/ecg_print/+ecg_print/+analysisRun/peakMethodValue.m +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/peakMethodValue.m @@ -1,4 +1,4 @@ -% Expected caller: ecg_print.definitionActions and direct unit tests. Input is the UI +% Expected caller: ecg_print direct callbacks and direct unit tests. Input is the UI % dropdown label. Output is the method string accepted by % labkit.biosignal.detectEcgPeaks. Side effects: none. diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/present.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/present.m new file mode 100644 index 000000000..aca509049 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/present.m @@ -0,0 +1,9 @@ +% App-owned implementation for ecg_print.analysisRun.present within the ecg_print product workflow. +function view = present(cache, parameters, ~, hasSignal) +choices = string(cache.channelItems); +value = string(parameters.channel); +if ~any(value == choices), value = choices(1); end +view = labkit.app.view.Snapshot() ... + .choices("channel", choices).value("channel", value) ... + .enabled("channel", hasSignal).enabled("analyze", hasSignal); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/previewModels.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/previewModels.m new file mode 100644 index 000000000..e627c8ac6 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/previewModels.m @@ -0,0 +1,22 @@ +% App-owned implementation for ecg_print.analysisRun.previewModels within the ecg_print product workflow. +function models = previewModels(cache, parameters) +emptyRequest = struct(); +models = repmat(struct( ... + "kind", "", "request", emptyRequest, ... + "analysis", table(), "smoothBeats", parameters.smoothBeats), 1, 4); +models(1).kind = "wave"; +models(1).request = ecg_print.analysisRun.waveformPlotRequest( ... + cache.workingSignal, cache.filteredSignal, cache.events); +analysis = table(); +if ~isempty(cache.measurements) && ~isempty(cache.measurements.perSegment) + analysis = ecg_print.resultFiles.analysisTable(cache.measurements.perSegment, parameters.smoothBeats); +end +models(2).kind = "noise"; +models(2).analysis = analysis; +models(3).kind = "snr"; +models(3).analysis = analysis; +models(4).kind = "template"; +models(4).request = ecg_print.analysisRun.templatePlotRequest( ... + cache.segments, cache.template, cache.measurements, ... + parameters.templateView); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/sanitizeParameters.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/sanitizeParameters.m new file mode 100644 index 000000000..788af99f2 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/sanitizeParameters.m @@ -0,0 +1,29 @@ +% App-owned implementation for ecg_print.analysisRun.sanitizeParameters within the ecg_print product workflow. +function parameters = sanitizeParameters(parameters, sampleRate) +%SANITIZEPARAMETERS Normalize ECG analysis controls before calculation. +% Inputs are one App-owned parameter struct and the selected signal sample +% rate. Output preserves the struct while making calculation inputs finite +% and legal. Side effects: none. +parameters.roiStart = finiteNonnegative(parameters.roiStart, 0); +parameters.roiEnd = finiteNonnegative(parameters.roiEnd, 0); +parameters.lowCut = finiteNonnegative(parameters.lowCut, 0.5); +parameters.highCut = finiteNonnegative(parameters.highCut, 40); +parameters.highCut = min(parameters.highCut, ... + max(parameters.lowCut + eps, 0.45 * sampleRate)); +parameters.peakDistance = max(eps, ... + finiteNonnegative(parameters.peakDistance, 0.28)); +parameters.segmentWindow = max(eps, ... + finiteNonnegative(parameters.segmentWindow, 0.7)); +parameters.templateTopN = max(1, round( ... + finiteNonnegative(parameters.templateTopN, 30))); +parameters.smoothBeats = max(1, round( ... + finiteNonnegative(parameters.smoothBeats, 15))); +end + +function value = finiteNonnegative(value, fallback) +value = double(value); +if isempty(value) || ~isscalar(value) || ~isfinite(value) + value = fallback; +end +value = max(0, value); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+userInterface/summaryRows.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/summaryRows.m similarity index 89% rename from apps/wearable/ecg_print/+ecg_print/+userInterface/summaryRows.m rename to apps/wearable/ecg_print/+ecg_print/+analysisRun/summaryRows.m index 47f2411be..4d9b996cd 100644 --- a/apps/wearable/ecg_print/+ecg_print/+userInterface/summaryRows.m +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/summaryRows.m @@ -1,11 +1,11 @@ -% Expected caller: ecg_print.userInterface.presentWorkbench and direct unit tests. Inputs are the +% Expected caller: ecg_print.workbench.present and direct unit tests. Inputs are the % current signal, events, segments, and measurement structs. Output is a % two-column cell array for the summary table. Side effects: none. function rows = summaryRows(signal, events, segments, measurements) %SUMMARYROWS Build ECG Print summary table rows from app state fields. - rows = ecg_print.userInterface.initialSummaryRows(); + rows = ecg_print.analysisRun.initialSummaryRows(); if ~isempty(signal) rows = [rows; { 'Channel', char(signal.displayName); diff --git a/apps/wearable/ecg_print/+ecg_print/+userInterface/templatePlotRequest.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/templatePlotRequest.m similarity index 95% rename from apps/wearable/ecg_print/+ecg_print/+userInterface/templatePlotRequest.m rename to apps/wearable/ecg_print/+ecg_print/+analysisRun/templatePlotRequest.m index 40717ff20..ad0253ddd 100644 --- a/apps/wearable/ecg_print/+ecg_print/+userInterface/templatePlotRequest.m +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/templatePlotRequest.m @@ -1,4 +1,4 @@ -% Expected caller: ecg_print.userInterface.presentWorkbench and direct unit tests. Inputs are ECG +% Expected caller: ecg_print.workbench.present and direct unit tests. Inputs are ECG % segment/template/measurement structs plus the selected template view label. % Output is a GUI-free plot request struct; no UI handles are read or mutated. diff --git a/apps/wearable/ecg_print/+ecg_print/+userInterface/waveformPlotRequest.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/waveformPlotRequest.m similarity index 91% rename from apps/wearable/ecg_print/+ecg_print/+userInterface/waveformPlotRequest.m rename to apps/wearable/ecg_print/+ecg_print/+analysisRun/waveformPlotRequest.m index c53bcbee2..519668944 100644 --- a/apps/wearable/ecg_print/+ecg_print/+userInterface/waveformPlotRequest.m +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/waveformPlotRequest.m @@ -1,4 +1,4 @@ -% Expected caller: ecg_print.userInterface.presentWorkbench and direct unit tests. Inputs are the +% Expected caller: ecg_print.workbench.present and direct unit tests. Inputs are the % current working/filtered signal structs and optional event struct. Output is a % GUI-free plot request struct; no UI handles are read or mutated. diff --git a/apps/wearable/ecg_print/+ecg_print/+debug/writeSamplePack.m b/apps/wearable/ecg_print/+ecg_print/+debug/writeSamplePack.m index e0ea7e103..fee5a60a3 100644 --- a/apps/wearable/ecg_print/+ecg_print/+debug/writeSamplePack.m +++ b/apps/wearable/ecg_print/+ecg_print/+debug/writeSamplePack.m @@ -2,16 +2,15 @@ % a LabKit debug context. Output is a deterministic synthetic ECG recording % sample pack. Side effects: writes anonymous debug files and records a % session manifest when available. -function pack = writeSamplePack(debugLog) +function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write ECG Print debug recording files. + arguments + sampleContext (1, 1) labkit.app.diagnostic.SampleContext + end - folders = debugFolders(debugLog, "ecg_print"); - sampleFolder = fullfile(char(folders.sampleFolder), "ecg_print"); - ensureFolder(sampleFolder); - - csvPath = string(fullfile(sampleFolder, "ecg_representative_debug.csv")); - headerlessPath = string(fullfile(sampleFolder, "ecg_valid_headerless_debug.txt")); - malformedPath = string(fullfile(sampleFolder, "ecg_malformed_text_debug.csv")); + csvPath = sampleContext.samplePath("ecg_print/recording.csv"); + headerlessPath = sampleContext.samplePath("ecg_print/headerless.txt"); + malformedPath = sampleContext.samplePath("ecg_print/malformed.csv"); fs = 500; durationSec = 3; @@ -27,18 +26,17 @@ writematrix(headerless, char(headerlessPath), "Delimiter", "\t"); writeTextFile(malformedPath, ["time_s,ECG"; "0,ok"; "1,not_numeric"]); - manifest = struct( ... - "type", "labkit.debug.samplePack.v1", ... - "app", "labkit_ECGPrint_app", ... - "description", "Anonymous ECG CSV boundary pack for debug launch.", ... - "sampleFolder", string(sampleFolder), ... - "outputFolder", folders.outputFolder, ... - "representativeFiles", csvPath, ... - "boundaryFiles", struct( ... - "validHeaderlessText", headerlessPath, ... - "malformedCsv", malformedPath)); - recordManifest(debugLog, manifest); - pack = manifest; + project = ecg_print.projectSpec().Create(); + project.inputs.sources = sampleContext.sourceRecord( ... + "recording1", "recording", csvPath, true); + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative-ecg", InitialProject=project, ... + Artifacts={ ... + sampleContext.artifact("recording", "recording", csvPath), ... + sampleContext.artifact("headerless", ... + "boundaryInput", headerlessPath), ... + sampleContext.artifact("malformed", "boundaryInput", ... + malformedPath, Expectation="rejects")}); end function y = syntheticEcg(time, gain) @@ -60,30 +58,6 @@ 0.5 .* sin(2 .* pi .* 41.7 .* time + 2 .* phase); end -function folders = debugFolders(debugLog, appToken) - sampleFolder = ""; - outputFolder = ""; - if isstruct(debugLog) - if isfield(debugLog, "sampleFolder"), sampleFolder = string(debugLog.sampleFolder); end - if isfield(debugLog, "outputFolder"), outputFolder = string(debugLog.outputFolder); end - end - if strlength(sampleFolder) == 0 - sampleFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "samples")); - end - if strlength(outputFolder) == 0 - outputFolder = string(fullfile(tempdir, "LabKit-MATLAB-Workbench", "debug", appToken, "outputs")); - end - ensureFolder(sampleFolder); - ensureFolder(outputFolder); - folders = struct("sampleFolder", sampleFolder, "outputFolder", outputFolder); -end - -function recordManifest(debugLog, manifest) - if isstruct(debugLog) && isfield(debugLog, "recordArtifacts") && isa(debugLog.recordArtifacts, "function_handle") - debugLog.recordArtifacts(manifest); - end -end - function writeTextFile(filepath, lines) fid = fopen(char(filepath), "w"); if fid < 0 @@ -92,9 +66,3 @@ function writeTextFile(filepath, lines) cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s\n", lines); end - -function ensureFolder(folder) - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end -end diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/analysisTable.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/analysisTable.m index 66aba9bd2..0177343df 100644 --- a/apps/wearable/ecg_print/+ecg_print/+resultFiles/analysisTable.m +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/analysisTable.m @@ -1,4 +1,4 @@ -% Expected caller: ecg_print.definitionActions and direct unit tests. Inputs are the +% Expected caller: ecg_print direct callbacks and direct unit tests. Inputs are the % measurement per-segment table and smoothing width in beats. Output preserves % existing columns and adds smoothed SignalP2P, NoiseRMS, and SNRdB columns. % Side effects: none. diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportSegments.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportSegments.m new file mode 100644 index 000000000..78108b1a8 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportSegments.m @@ -0,0 +1,46 @@ +% App-owned implementation for ecg_print.resultFiles.exportSegments within the ecg_print product workflow. +function applicationState = exportSegments( ... + applicationState, callbackContext) +%EXPORTSEGMENTS Write per-segment ECG measurements and a result manifest. +measurements = applicationState.session.cache.measurements; +if isempty(measurements) || isempty(measurements.perSegment) + callbackContext.alert( ... + "Analyze a signal before exporting segment SNR.", ... + "No segment SNR"); + return; +end +chosen = callbackContext.chooseOutputFile( ... + ["*.csv", "CSV files (*.csv)"], "ecg_segment_snr.csv"); +if chosen.Cancelled + callbackContext.appendStatus("Segment SNR export cancelled."); + return; +end +filepath = string(chosen.Value); +analysis = ecg_print.resultFiles.analysisTable( ... + measurements.perSegment, ... + applicationState.project.parameters.smoothBeats); +writetable(analysis, filepath); +[folder, name, extension] = fileparts(filepath); +folder = outputFolder(folder); +output = labkit.app.result.File( ... + "ecgSegmentSnr", "primary", string(name) + string(extension), ... + MediaType="text/csv"); +package = labkit.app.result.Package(Outputs={output}, ... + Inputs=applicationState.project.inputs, ... + Parameters=applicationState.project.parameters, ... + Summary=ecg_print.resultFiles.manifestSummary( ... + applicationState.project.results.lastAnalysis), ... + ManifestName="ecg_segment_snr.labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.lastSegmentExport = struct( ... + "csvPath", filepath, "manifestPath", string(written.Value)); +callbackContext.appendStatus( ... + "Exported segment SNR CSV: " + filepath); +end + +function folder = outputFolder(folder) +folder = string(folder); +if strlength(folder) == 0 + folder = string(pwd); +end +end diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportWaveform.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportWaveform.m new file mode 100644 index 000000000..61a032086 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportWaveform.m @@ -0,0 +1,44 @@ +% App-owned implementation for ecg_print.resultFiles.exportWaveform within the ecg_print product workflow. +function applicationState = exportWaveform( ... + applicationState, callbackContext) +%EXPORTWAVEFORM Write the prepared ECG waveform and a result manifest. +request = ecg_print.analysisRun.waveformPlotRequest( ... + applicationState.session.cache.workingSignal, ... + applicationState.session.cache.filteredSignal, ... + applicationState.session.cache.events); +if ~request.ok + callbackContext.alert( ... + "Open a recording before exporting a waveform.", "No waveform"); + return; +end +chosen = callbackContext.chooseOutputFile( ... + ["*.png", "PNG files (*.png)"], "ecg_waveform.png"); +if chosen.Cancelled + callbackContext.appendStatus("Waveform export cancelled."); + return; +end +filepath = string(chosen.Value); +ecg_print.resultFiles.writeWaveformPng(request, filepath); +[folder, name, extension] = fileparts(filepath); +folder = outputFolder(folder); +output = labkit.app.result.File( ... + "ecgWaveform", "primary", string(name) + string(extension), ... + MediaType="image/png"); +package = labkit.app.result.Package(Outputs={output}, ... + Inputs=applicationState.project.inputs, ... + Parameters=applicationState.project.parameters, ... + Summary=ecg_print.resultFiles.manifestSummary( ... + applicationState.project.results.lastAnalysis), ... + ManifestName="ecg_waveform.labkit.json"); +written = callbackContext.writeResultPackage(folder, package); +applicationState.project.results.lastWaveformExport = struct( ... + "pngPath", filepath, "manifestPath", string(written.Value)); +callbackContext.appendStatus("Exported waveform PNG: " + filepath); +end + +function folder = outputFolder(folder) +folder = string(folder); +if strlength(folder) == 0 + folder = string(pwd); +end +end diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/layoutSection.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/layoutSection.m new file mode 100644 index 000000000..159f713b6 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/layoutSection.m @@ -0,0 +1,8 @@ +% App-owned implementation for ecg_print.resultFiles.layoutSection within the ecg_print product workflow. +function section = layoutSection() +section = labkit.app.layout.section("exportSection", "Exports", { ... + labkit.app.layout.button("exportSegments", "Export segment SNR CSV", @ecg_print.resultFiles.exportSegments, ... + Tooltip="Export beat-segment SNR measurements from the most recent ECG ROI analysis."), ... + labkit.app.layout.button("exportWaveform", "Export waveform PNG", @ecg_print.resultFiles.exportWaveform, ... + Tooltip="Export the processed ECG waveform and detected-beat context from the current analysis.")}); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/manifestSummary.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/manifestSummary.m new file mode 100644 index 000000000..b7536e7fd --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/manifestSummary.m @@ -0,0 +1,14 @@ +% App-owned implementation for ecg_print.resultFiles.manifestSummary within the ecg_print product workflow. +function summary = manifestSummary(lastAnalysis) +%MANIFESTSUMMARY Remove per-beat table data from ECG result provenance. +% The returned scalar struct keeps compact counts and aggregate metrics that +% are JSON-safe and sufficient to interpret either exported file. +summary = struct(); +if isempty(fieldnames(lastAnalysis)) + return; +end +summary = lastAnalysis; +if isfield(summary, "perSegment") + summary = rmfield(summary, "perSegment"); +end +end diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/present.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/present.m new file mode 100644 index 000000000..6ca3eb409 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/present.m @@ -0,0 +1,4 @@ +% App-owned implementation for ecg_print.resultFiles.present within the ecg_print product workflow. +function view = present(hasMeasurements, hasWaveform) +view = labkit.app.view.Snapshot().enabled("exportSegments", hasMeasurements).enabled("exportWaveform", hasWaveform); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/writeWaveformPng.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/writeWaveformPng.m index 9024b6efa..746cddbd7 100644 --- a/apps/wearable/ecg_print/+ecg_print/+resultFiles/writeWaveformPng.m +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/writeWaveformPng.m @@ -5,7 +5,7 @@ function writeWaveformPng(request, outputPath) figureHandle = figure("Visible", "off", "Color", "white"); cleanup = onCleanup(@() close(figureHandle)); ax = axes(figureHandle); - ecg_print.userInterface.drawPreviewAxis( ... + ecg_print.analysisRun.drawPreview( ... ax, struct("kind", "wave", "request", request)); exportgraphics(ax, outputPath, "Resolution", 300); end diff --git a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/emptyCache.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/emptyCache.m new file mode 100644 index 000000000..15f6bf6a4 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/emptyCache.m @@ -0,0 +1,13 @@ +% App-owned implementation for ecg_print.sourceFiles.emptyCache within the ecg_print product workflow. +function cache = emptyCache() +%EMPTYCACHE Create the transient ECG recording and analysis cache. +% Expected callers are createSession and parse-recovery callbacks. The +% returned fields are transient and never belong in a saved project. +cache = struct( ... + "filepath", "", "recording", [], "signal", [], ... + "workingSignal", [], "filteredSignal", [], "events", [], ... + "segments", [], "template", [], "measurements", [], ... + "channelItems", {{'(none)'}}, ... + "filePreview", {{ ... + 'Open a CSV/text file, then use Preview file header.'}}); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/importOptions.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/importOptions.m index f1ae6322b..c2e81bac9 100644 --- a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/importOptions.m +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/importOptions.m @@ -1,4 +1,4 @@ -% Expected caller: ecg_print.definitionActions and direct unit tests. Inputs are raw +% Expected caller: ecg_print direct callbacks and direct unit tests. Inputs are raw % UI control values. Output is a struct accepted by labkit.biosignal.readRecording. % Side effects: none. diff --git a/apps/wearable/ecg_print/+ecg_print/+userInterface/importStatusText.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/importStatusText.m similarity index 93% rename from apps/wearable/ecg_print/+ecg_print/+userInterface/importStatusText.m rename to apps/wearable/ecg_print/+ecg_print/+sourceFiles/importStatusText.m index f9a01e610..9278d50b3 100644 --- a/apps/wearable/ecg_print/+ecg_print/+userInterface/importStatusText.m +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/importStatusText.m @@ -1,4 +1,4 @@ -% Expected caller: ecg_print.definitionActions and direct unit tests. Inputs are a +% Expected caller: ecg_print direct callbacks and direct unit tests. Inputs are a % biosignal recording struct and parsed channel count. Output is user-facing % status text. Side effects: none. diff --git a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/layoutSections.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/layoutSections.m new file mode 100644 index 000000000..6ba16a159 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/layoutSections.m @@ -0,0 +1,51 @@ +% App-owned implementation for ecg_print.sourceFiles.layoutSections within the ecg_print product workflow. +function sections = layoutSections() +%LAYOUTSECTIONS Declare ECG recording and text-import controls. +recording = labkit.app.layout.section("recordingSection", "Recording", { ... + labkit.app.layout.fileList("recording", Label="Recording", ... + Filters=["*.mat;*.csv;*.txt;*.tsv", ... + "Biosignal files (*.mat, *.csv, *.txt, *.tsv)", ... + "*.*", "All files"], ... + SelectionMode="single", MaxFiles=1, ... + ChooseLabel="Open recording", EmptyText="No file loaded", ... + ChooseTooltip="Open a MAT, CSV, or delimited biosignal recording containing the ECG channel and time information.", ... + Bind="project.inputs.sources", ... + SourceRole="recording", ... + SourceIdPrefix="recording", Required=true), ... + labkit.app.layout.button("previewHeader", ... + "Preview file header", @ecg_print.sourceFiles.previewHeader, ... + Tooltip="Inspect the delimited-text header before choosing time and signal columns; no signal analysis is run.")}); +importParsing = labkit.app.layout.section( ... + "importSection", "Import Parsing", { ... + labkit.app.layout.field("importStatus", Label="Status", ... + Kind="readonly", ... + Value="Open a recording to inspect import settings."), ... + pannerField("headerLine", "CSV header line:", [0 1000000], 1), ... + labkit.app.layout.field("hasHeader", Label="CSV header:", ... + Kind="choice", Choices=["Auto", "Yes", "No"], ... + Bind="project.parameters.hasHeader", ... + OnValueChanged=@ecg_print.sourceFiles.resetImport), ... + labkit.app.layout.field("timeColumn", Label="Time column:", ... + Bind="project.parameters.timeColumn", ... + OnValueChanged=@ecg_print.sourceFiles.resetImport), ... + labkit.app.layout.field("timeUnit", Label="Time unit:", ... + Kind="choice", ... + Choices=["Auto", "seconds", "milliseconds", ... + "microseconds", "nanoseconds"], ... + Bind="project.parameters.timeUnit", ... + OnValueChanged=@ecg_print.sourceFiles.resetImport), ... + labkit.app.layout.field("signalColumns", Label="Signal columns:", ... + Bind="project.parameters.signalColumns", ... + OnValueChanged=@ecg_print.sourceFiles.resetImport), ... + pannerField("fallbackFs", "Fallback Fs:", [0.001 1000000], 100), ... + labkit.app.layout.button("refreshImport", ... + "Parse / refresh file", @ecg_print.sourceFiles.refreshImport, ... + Tooltip="Reparse the recording with the current header, time-unit, sample-rate, and signal-column settings.")}); +sections = {recording, importParsing}; +end + +function node = pannerField(id, label, limits, step) +node = labkit.app.layout.slider(id, Label=label, ... + Limits=limits, Step=step, Bind="project.parameters." + id, ... + OnValueChanged=@ecg_print.sourceFiles.resetImport); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/loadRecording.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/loadRecording.m index 975ec17f6..6c0aa45e8 100644 --- a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/loadRecording.m +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/loadRecording.m @@ -1,4 +1,4 @@ -% Expected callers: Runtime V2 session creation and ECG actions. Inputs are a +% Expected callers: App SDK session creation and ECG actions. Inputs are a % recording path, durable import parameters, and preferred channel. Outputs % are the decoded session cache and app-facing import status. function [cache, importStatus] = loadRecording(filepath, parameters, preferredChannel) @@ -25,6 +25,6 @@ "events", [], "segments", [], "template", [], ... "measurements", [], "channelItems", {channels}, ... "filePreview", {{}}); - importStatus = ecg_print.userInterface.importStatusText( ... + importStatus = ecg_print.sourceFiles.importStatusText( ... recording, numel(channels)); end diff --git a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/present.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/present.m new file mode 100644 index 000000000..6a0c77a25 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/present.m @@ -0,0 +1,4 @@ +% App-owned implementation for ecg_print.sourceFiles.present within the ecg_print product workflow. +function view = present(state) +view = labkit.app.view.Snapshot().text("importStatus", string(state.session.workflow.importStatus)).enabled("previewHeader", ~isempty(state.project.inputs.sources)).enabled("refreshImport", ~isempty(state.project.inputs.sources)); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewFileHeader.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewFileHeader.m index 57a8a16f0..ed9d778f2 100644 --- a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewFileHeader.m +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewFileHeader.m @@ -1,4 +1,4 @@ -% Expected caller: ecg_print.definitionActions and direct unit tests. Inputs are a +% Expected caller: ecg_print direct callbacks and direct unit tests. Inputs are a % file path and maximum line count. Output is a column cell array of display % lines. Side effects: reads the requested file only. diff --git a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewHeader.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewHeader.m new file mode 100644 index 000000000..47300535a --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewHeader.m @@ -0,0 +1,14 @@ +% App-owned implementation for ecg_print.sourceFiles.previewHeader within the ecg_print product workflow. +function applicationState = previewHeader(applicationState, callbackContext) +%PREVIEWHEADER Read the first lines of the selected text recording. +paths = callbackContext.resolveSourcePaths( ... + applicationState.project.inputs.sources); +if isempty(paths) || strlength(paths(1)) == 0 + applicationState.session.cache.filePreview = { ... + 'Open a CSV/text file, then use Preview file header.'}; + return; +end +applicationState.session.cache.filePreview = ... + ecg_print.sourceFiles.previewFileHeader(paths(1), 18); +callbackContext.appendStatus("Previewed file header: " + paths(1)); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/refreshImport.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/refreshImport.m new file mode 100644 index 000000000..bae745a20 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/refreshImport.m @@ -0,0 +1,48 @@ +% App-owned implementation for ecg_print.sourceFiles.refreshImport within the ecg_print product workflow. +function applicationState = refreshImport(applicationState, callbackContext) +%REFRESHIMPORT Reparse the selected recording with current import settings. +paths = callbackContext.resolveSourcePaths( ... + applicationState.project.inputs.sources); +if isempty(paths) || strlength(paths(1)) == 0 + callbackContext.alert( ... + "Open a recording before parsing.", "No recording selected"); + return; +end +filepath = paths(1); +preview = ecg_print.sourceFiles.previewFileHeader(filepath, 18); +try + [cache, status] = ecg_print.sourceFiles.loadRecording( ... + filepath, applicationState.project.parameters, ... + applicationState.project.parameters.channel); +catch cause + callbackContext.reportError("Recording parse failed", cause); + cache = ecg_print.sourceFiles.emptyCache(); + cache.filepath = filepath; + cache.filePreview = preview; + applicationState.session.cache = cache; + applicationState.session.workflow.importStatus = ... + "Parse failed. Inspect header/settings, then refresh: " + ... + cause.message; + applicationState.project.parameters.channel = "(none)"; + applicationState.project.results.lastAnalysis = struct(); + applicationState.project.results.lastSegmentExport = []; + applicationState.project.results.lastWaveformExport = []; + callbackContext.appendStatus( ... + "Recording parse failed: " + cause.message); + callbackContext.alert(cause.message, "Could not parse recording"); + return; +end +cache.filePreview = preview; +applicationState.session.cache = cache; +applicationState.session.workflow.importStatus = status; +applicationState.project.parameters.channel = string( ... + cache.signal.displayName); +applicationState.project.parameters.roiStart = 0; +applicationState.project.parameters.roiEnd = max(cache.signal.time); +applicationState.project.results.lastAnalysis = struct(); +applicationState.project.results.lastSegmentExport = []; +applicationState.project.results.lastWaveformExport = []; +callbackContext.appendStatus(sprintf( ... + "Parsed %d channel(s) from %s", ... + numel(cache.channelItems), filepath)); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/resetImport.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/resetImport.m new file mode 100644 index 000000000..9a94f3c30 --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/resetImport.m @@ -0,0 +1,21 @@ +% App-owned implementation for ecg_print.sourceFiles.resetImport within the ecg_print product workflow. +function applicationState = resetImport( ... + applicationState, ~, ~) +%RESETIMPORT Mark decoded analysis stale after an import option changes. +applicationState.project.results.lastAnalysis = struct(); +applicationState.project.results.lastSegmentExport = []; +applicationState.project.results.lastWaveformExport = []; +applicationState.session.cache.filteredSignal = []; +applicationState.session.cache.events = []; +applicationState.session.cache.segments = []; +applicationState.session.cache.template = []; +applicationState.session.cache.measurements = []; +if ~isempty(applicationState.session.cache.signal) + applicationState.session.cache.workingSignal = ... + applicationState.session.cache.signal; +end +if ~isempty(applicationState.project.inputs.sources) + applicationState.session.workflow.importStatus = ... + "Import settings changed. Click Parse / refresh file."; +end +end diff --git a/apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m b/apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m deleted file mode 100644 index 5dca10d7c..000000000 --- a/apps/wearable/ecg_print/+ecg_print/+userInterface/buildWorkbenchLayout.m +++ /dev/null @@ -1,200 +0,0 @@ -% Expected caller: ecg_print.definition. Input is a callback struct whose fields -% are app-owned callback handles. Output is a data-only UI 5 workbench layout -% for the ECG Print app. -function layout = buildWorkbenchLayout(callbacks) - - layout = labkit.ui.layout.workbench("ecgPrintApp", ... - "ECG Signal Print + SNR Explorer", ... - "controlTabs", controlTabs(callbacks), ... - "workspace", ecgWorkspace(), ... - "usageTitle", "Workflow Notes", ... - "usage", workflowNotesLines()); -end - -function tabs = controlTabs(callbacks) - tabs = {filesAnalysisTab(callbacks), summaryResultsTab(), logTab()}; -end - -function tab = filesAnalysisTab(callbacks) - tab = labkit.ui.layout.tab("filesAnalysis", "Files + Analysis", { ... - recordingSection(callbacks), ... - importSection(callbacks), ... - channelSection(), ... - processingSection(callbacks), ... - exportSection(callbacks)}); -end - -function tab = summaryResultsTab() - tab = labkit.ui.layout.tab("summaryResults", "Summary + Results", { ... - labkit.ui.layout.section("summarySection", "Summary", { ... - labkit.ui.layout.resultTable("summaryTable", "Summary", ... - "columns", {'Metric', 'Value'}, ... - "data", ecg_print.userInterface.initialSummaryRows()), ... - labkit.ui.layout.statusPanel("filePreview", ... - "File Header Preview", ... - "value", { ... - 'Open a CSV/text file, then use Preview file header.'})})}); -end - -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log", ... - "value", {'Ready.'})})}); -end - -function section = recordingSection(callbacks) - section = labkit.ui.layout.section("recordingSection", "Recording", { ... - labkit.ui.layout.filePanel("recording", "Recording", ... - "mode", "single", ... - "chooseLabel", "Open 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", callbacks.recordingChosen), ... - labkit.ui.layout.action("previewHeader", ... - "Preview file header", callbacks.previewHeader)}); -end - -function section = importSection(callbacks) - section = labkit.ui.layout.section("importSection", "Import Parsing", { ... - labkit.ui.layout.field("importStatus", "Status", ... - "kind", "readonly", ... - "value", "Open a recording to inspect import settings."), ... - labkit.ui.layout.panner("headerLine", "CSV header line:", ... - "value", 0, ... - "limits", [0 1000000], ... - "step", 1, ... - "Bind", "project.parameters.headerLine", ... - "Event", "importOptionChanged"), ... - labkit.ui.layout.field("hasHeader", "CSV header:", ... - "kind", "dropdown", ... - "items", {'Auto', 'Yes', 'No'}, ... - "value", "Auto", ... - "Bind", "project.parameters.hasHeader", ... - "Event", "importOptionChanged"), ... - labkit.ui.layout.field("timeColumn", "Time column:", ... - "kind", "text", ... - "value", "", ... - "Bind", "project.parameters.timeColumn", ... - "Event", "importOptionChanged"), ... - labkit.ui.layout.field("timeUnit", "Time unit:", ... - "kind", "dropdown", ... - "items", {'Auto', 'seconds', 'milliseconds', ... - 'microseconds', 'nanoseconds'}, ... - "value", "Auto", ... - "Bind", "project.parameters.timeUnit", ... - "Event", "importOptionChanged"), ... - labkit.ui.layout.field("signalColumns", "Signal columns:", ... - "kind", "text", ... - "value", "", ... - "Bind", "project.parameters.signalColumns", ... - "Event", "importOptionChanged"), ... - labkit.ui.layout.panner("fallbackFs", "Fallback Fs:", ... - "value", 2000, ... - "limits", [0.001 1000000], ... - "step", 100, ... - "Bind", "project.parameters.fallbackFs", ... - "Event", "importOptionChanged"), ... - labkit.ui.layout.action("refreshImport", ... - "Parse / refresh file", callbacks.refreshImport)}); -end - -function section = channelSection() - section = labkit.ui.layout.section("channelSection", "Channel + ROI", { ... - labkit.ui.layout.field("channel", "Channel:", ... - "kind", "dropdown", ... - "items", {'(none)'}, ... - "value", "(none)", ... - "Bind", "project.parameters.channel", ... - "Event", "channelChanged"), ... - labkit.ui.layout.panner("roiStart", "ROI start (s):", ... - "value", 0, ... - "limits", [0 86400], ... - "step", 1, ... - "Bind", "project.parameters.roiStart"), ... - labkit.ui.layout.panner("roiEnd", "ROI end (s):", ... - "value", 0, ... - "limits", [0 86400], ... - "step", 1, ... - "Bind", "project.parameters.roiEnd")}); -end - -function section = processingSection(callbacks) - section = labkit.ui.layout.section("processingSection", ... - "Signal Processing + SNR", { ... - labkit.ui.layout.panner("lowCut", "Bandpass low Hz:", ... - "value", 0.5, ... - "limits", [0 10000], ... - "step", 0.1, ... - "Bind", "project.parameters.lowCut"), ... - labkit.ui.layout.panner("highCut", "Bandpass high Hz:", ... - "value", 40, ... - "limits", [0 10000], ... - "step", 1, ... - "Bind", "project.parameters.highCut"), ... - labkit.ui.layout.field("peakMethod", "Peak method:", ... - "kind", "dropdown", ... - "items", {'QRS streaming', 'Pan-Tompkins', ... - 'Local peaks'}, ... - "value", "QRS streaming", ... - "Bind", "project.parameters.peakMethod"), ... - labkit.ui.layout.panner("peakDistance", "Peak distance (s):", ... - "value", 0.28, ... - "limits", [0.01 10], ... - "step", 0.01, ... - "Bind", "project.parameters.peakDistance"), ... - labkit.ui.layout.panner("segmentWindow", "Segment half win (s):", ... - "value", 0.7, ... - "limits", [0.01 30], ... - "step", 0.05, ... - "Bind", "project.parameters.segmentWindow"), ... - labkit.ui.layout.panner("templateTopN", "Template top N:", ... - "value", 30, ... - "limits", [1 10000], ... - "step", 1, ... - "Bind", "project.parameters.templateTopN"), ... - labkit.ui.layout.panner("smoothBeats", "Smooth beats:", ... - "value", 15, ... - "limits", [1 10000], ... - "step", 1, ... - "Bind", "project.parameters.smoothBeats"), ... - labkit.ui.layout.field("templateView", "Template plot:", ... - "kind", "dropdown", ... - "items", {'Template + residual band', 'Template + segments'}, ... - "value", "Template + residual band", ... - "Bind", "project.parameters.templateView"), ... - labkit.ui.layout.action("analyze", ... - "Analyze current ROI", callbacks.analyze)}); -end - -function section = exportSection(callbacks) - section = labkit.ui.layout.section("exportSection", "Exports", { ... - labkit.ui.layout.action("exportSegments", ... - "Export segment SNR CSV", callbacks.exportSegments), ... - labkit.ui.layout.action("exportWaveform", ... - "Export waveform PNG", callbacks.exportWaveform)}); -end - -function lines = workflowNotesLines() - lines = { ... - '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.'}; -end - -function workspace = ecgWorkspace() - workspace = labkit.ui.layout.workspace("ecgPreview", "ECG Preview", { ... - labkit.ui.layout.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'})}); -end diff --git a/apps/wearable/ecg_print/+ecg_print/+userInterface/drawPreviewAxis.m b/apps/wearable/ecg_print/+ecg_print/+userInterface/drawPreviewAxis.m deleted file mode 100644 index ac17dadd1..000000000 --- a/apps/wearable/ecg_print/+ecg_print/+userInterface/drawPreviewAxis.m +++ /dev/null @@ -1,108 +0,0 @@ -% Expected caller: Runtime V2 registered renderer and waveform PNG export. -% Inputs are one axes and an app-owned axis model. Side effects are limited to -% redrawing that axes; no runtime controls or app state are read. -function drawPreviewAxis(ax, model) - labkit.ui.plot.clear(ax, "ResetScale", true); - switch string(model.kind) - case "wave" - drawWave(ax, model.request); - case "noise" - drawMetric(ax, model.analysis, model.smoothBeats, "noise"); - case "snr" - drawMetric(ax, model.analysis, model.smoothBeats, "snr"); - case "template" - drawTemplate(ax, model.request); - end -end - -function drawWave(ax, request) - title(ax, request.title); - xlabel(ax, request.xLabel); - ylabel(ax, request.yLabel); - if ~request.ok - return; - end - plot(ax, request.x, request.y, "Color", request.lineColor, ... - "LineWidth", 1); - hold(ax, "on"); - if ~isempty(request.peakX) - scatter(ax, request.peakX, request.peakY, 24, ... - request.peakColor, "filled"); - end - hold(ax, "off"); - grid(ax, "on"); -end - -function drawMetric(ax, analysis, smoothBeats, kind) - if kind == "noise" - title(ax, sprintf("Template Noise RMS Over Time | Smooth=%d beats", ... - smoothBeats)); - ylabel(ax, "Noise RMS"); - else - title(ax, sprintf("Template SNR Over Time | Smooth=%d beats", ... - smoothBeats)); - ylabel(ax, "SNR (dB)"); - end - xlabel(ax, "Time (s)"); - if height(analysis) == 0 - return; - end - if kind == "noise" - raw = analysis.NoiseRMS; - smooth = analysis.NoiseRMS_smooth; - colors = [0.20 0.45 0.72; 0.05 0.20 0.45]; - else - raw = analysis.SNRdB; - smooth = analysis.SNRdB_smooth; - colors = [0.18 0.55 0.32; 0.05 0.32 0.16]; - end - plot(ax, analysis.EventTime, raw, ".", "MarkerSize", 12, ... - "Color", colors(1, :)); - hold(ax, "on"); - plot(ax, analysis.EventTime, smooth, "-", "LineWidth", 1.5, ... - "Color", colors(2, :)); - hold(ax, "off"); - grid(ax, "on"); -end - -function drawTemplate(ax, request) - title(ax, request.title); - xlabel(ax, request.xLabel); - ylabel(ax, request.yLabel); - if ~request.ok - return; - end - hold(ax, "on"); - if request.showSegments - plot(ax, request.timeOffset, request.segments(:, request.showIndex), ... - "Color", [0.78 0.84 0.92], "LineWidth", 0.5); - else - fill(ax, [request.timeOffset; flipud(request.timeOffset)], ... - [request.upper; flipud(request.lower)], [0.20 0.20 0.20], ... - "FaceAlpha", 0.15, "EdgeColor", "none"); - end - plot(ax, request.timeOffset, request.template, "k-", "LineWidth", 2); - xline(ax, 0, "--r", "R"); - shadeWindows(ax, request); - hold(ax, "off"); - grid(ax, "on"); -end - -function shadeWindows(ax, request) - if isempty(request.signalWindowSec) - return; - end - limits = ax.YLim; - drawWindow(ax, request.signalWindowSec, limits, [1.00 0.20 0.20]); - for k = 1:size(request.noiseWindowsSec, 1) - drawWindow(ax, request.noiseWindowsSec(k, :), ... - limits, [0.00 0.45 1.00]); - end -end - -function drawWindow(ax, windowSec, limits, color) - fill(ax, [windowSec(1) windowSec(2) windowSec(2) windowSec(1)], ... - [limits(1) limits(1) limits(2) limits(2)], color, ... - "FaceAlpha", 0.08, "EdgeColor", "none", ... - "HitTest", "off", "PickableParts", "none"); -end diff --git a/apps/wearable/ecg_print/+ecg_print/+userInterface/initialSummaryRows.m b/apps/wearable/ecg_print/+ecg_print/+userInterface/initialSummaryRows.m deleted file mode 100644 index c63192aef..000000000 --- a/apps/wearable/ecg_print/+ecg_print/+userInterface/initialSummaryRows.m +++ /dev/null @@ -1,10 +0,0 @@ -% Expected caller: ecg_print.userInterface.buildWorkbenchLayout, -% ecg_print.userInterface.summaryRows, and direct -% unit tests. Output is a two-column cell array for the summary table. Side -% effects: none. - -function rows = initialSummaryRows() -%INITIALSUMMARYROWS Return the ECG Print empty-state summary table rows. - - rows = {'Status', 'No signal analyzed'}; -end diff --git a/apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m b/apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m deleted file mode 100644 index c28ddb2d6..000000000 --- a/apps/wearable/ecg_print/+ecg_print/+userInterface/presentWorkbench.m +++ /dev/null @@ -1,88 +0,0 @@ -% Expected caller: Runtime V2. Input is canonical ECG state. Output is one -% deterministic control/table/log/four-axis presentation without UI registry -% reads, graphics handles, IO, or analysis side effects. -function view = presentWorkbench(state) - cache = state.session.cache; - parameters = state.project.parameters; - hasSource = ~isempty(state.project.inputs.sources); - hasSignal = ~isempty(cache.signal); - hasMeasurements = ~isempty(cache.measurements) && ... - ~isempty(cache.measurements.perSegment); - view = struct(); - view.controls.recording = sourcePanel(state.project.inputs.sources); - view.controls.importStatus = valueSpec(state.session.workflow.importStatus); - view.controls.filePreview = valueSpec(cache.filePreview); - view.controls.channel = channelSpec(cache); - view.controls.summaryTable = tableSpec( ... - ecg_print.userInterface.summaryRows(cache.signal, cache.events, ... - cache.segments, cache.measurements)); - view.controls.previewHeader = enabledSpec(hasSource); - view.controls.refreshImport = enabledSpec(hasSource); - view.controls.analyze = enabledSpec(hasSignal); - view.controls.exportSegments = enabledSpec(hasMeasurements); - view.controls.exportWaveform = enabledSpec(~isempty(cache.workingSignal)); - models = previewModels(cache, parameters); - view.previews.previewAxes.Axes.wave = axisSpec(models.wave); - view.previews.previewAxes.Axes.noise = axisSpec(models.noise); - view.previews.previewAxes.Axes.snr = axisSpec(models.snr); - view.previews.previewAxes.Axes.template = axisSpec(models.template); -end - -function models = previewModels(cache, parameters) - models.wave = struct("kind", "wave", "request", ... - ecg_print.userInterface.waveformPlotRequest( ... - cache.workingSignal, cache.filteredSignal, cache.events)); - analysis = table(); - if ~isempty(cache.measurements) && ~isempty(cache.measurements.perSegment) - analysis = ecg_print.resultFiles.analysisTable( ... - cache.measurements.perSegment, parameters.smoothBeats); - end - models.noise = struct("kind", "noise", "analysis", analysis, ... - "smoothBeats", parameters.smoothBeats); - models.snr = struct("kind", "snr", "analysis", analysis, ... - "smoothBeats", parameters.smoothBeats); - models.template = struct("kind", "template", "request", ... - ecg_print.userInterface.templatePlotRequest(cache.segments, ... - cache.template, cache.measurements, parameters.templateView)); -end - -function spec = sourcePanel(sources) - files = struct("id", {}, "path", {}, "status", {}); - status = "No file loaded"; - filepath = labkit.ui.runtime.sourcePaths(sources, "recording"); - if strlength(filepath) > 0 - files = struct("id", "item1", "path", filepath, "status", ""); - status = filepath; - end - spec = struct("Files", files, "Status", status); -end - -function spec = channelSpec(cache) - items = cache.channelItems; - value = "(none)"; - if ~isempty(cache.signal) - value = string(cache.signal.displayName); - end - spec = struct(); - spec.Items = items; - spec.Value = value; - spec.Enabled = ~isempty(cache.signal); -end - -function spec = axisSpec(model) - spec = struct("Renderer", "previewAxis", "Model", model); -end - -function spec = valueSpec(value) - spec = struct(); - spec.Value = value; -end - -function spec = tableSpec(value) - spec = struct(); - spec.Data = value; -end - -function spec = enabledSpec(value) - spec = struct("Enabled", logical(value)); -end diff --git a/apps/wearable/ecg_print/+ecg_print/+workbench/buildLayout.m b/apps/wearable/ecg_print/+ecg_print/+workbench/buildLayout.m new file mode 100644 index 000000000..4b576f25b --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+workbench/buildLayout.m @@ -0,0 +1,35 @@ +% App-owned implementation for ecg_print.workbench.buildLayout within the ecg_print product workflow. +function layout = buildLayout() +%BUILDLAYOUT Assemble the visible ECG Print product workflow. +sourceSections = ecg_print.sourceFiles.layoutSections(); +analysisSections = ecg_print.analysisRun.layoutSections(); +controls = { ... + labkit.app.layout.tab("filesAnalysis", "Files + Analysis", ... + [sourceSections, analysisSections, ... + {ecg_print.resultFiles.layoutSection()}]), ... + labkit.app.layout.tab("summaryResults", "Summary + Results", { ... + labkit.app.layout.section("summarySection", "Summary", { ... + labkit.app.layout.dataTable("summaryTable", ... + Title="Summary", Columns=["Metric", "Value"]), ... + labkit.app.layout.statusPanel("filePreview", ... + Title="File Header Preview", Text= ... + "Open a CSV/text file, then use Preview file header.")})}), ... + labkit.app.layout.tab("log", "Log", { ... + labkit.app.layout.section("logSection", "Log", { ... + labkit.app.layout.logPanel("appLog", Title="Log")})})}; +preview = labkit.app.layout.plotArea("previewAxes", ... + @ecg_print.analysisRun.drawPreview, ... + Title="ECG Preview", Layout="stack", ... + AxisIds=["wave", "noise", "snr", "template"], ... + AxisTitles=["Waveform + Peaks", ... + "Template Noise RMS Over Time", ... + "Template SNR Over Time", ... + "Template + Residual Band"]); +workspace = labkit.app.layout.workspace(preview, Title="ECG Preview"); +usage = [ ... + "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."]; +layout = labkit.app.layout.workbench(controls, Workspace=workspace, ... + UsageTitle="Workflow Notes", Usage=usage); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+workbench/present.m b/apps/wearable/ecg_print/+ecg_print/+workbench/present.m new file mode 100644 index 000000000..8d1b419ea --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+workbench/present.m @@ -0,0 +1,14 @@ +% App-owned implementation for ecg_print.workbench.present within the ecg_print product workflow. +function view = present(state) +cache = state.session.cache; +parameters = state.project.parameters; +hasSignal = ~isempty(cache.signal); +hasMeasurements = ~isempty(cache.measurements) && ~isempty(cache.measurements.perSegment); +models = ecg_print.analysisRun.previewModels(cache, parameters); +view = ecg_print.sourceFiles.present(state) ... + .include(ecg_print.analysisRun.present(cache, parameters, models, hasSignal)) ... + .include(ecg_print.resultFiles.present(hasMeasurements, ~isempty(cache.workingSignal))) ... + .tableData("summaryTable", ecg_print.analysisRun.summaryRows(cache.signal, cache.events, cache.segments, cache.measurements)) ... + .text("filePreview", strjoin(string(cache.filePreview), newline)) ... + .renderPlot("previewAxes", models); +end diff --git a/apps/wearable/ecg_print/+ecg_print/createSession.m b/apps/wearable/ecg_print/+ecg_print/createSession.m index 78d366b99..68877e52f 100644 --- a/apps/wearable/ecg_print/+ecg_print/createSession.m +++ b/apps/wearable/ecg_print/+ecg_print/createSession.m @@ -1,13 +1,17 @@ % Rebuild decoded recording, signal products, header preview, workflow log, % and plot caches from one validated ECG Print project. -function session = createSession(project) - cache = emptyCache(); +function session = createSession(project, context) + cache = ecg_print.sourceFiles.emptyCache(); workflow = struct("importStatus", ... "Open a recording to inspect import settings."); - filepath = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "recording"); + paths = context.resolveSourcePaths(project.inputs.sources); + filepath = ""; + if ~isempty(paths) + filepath = paths(1); + end if strlength(filepath) > 0 - [cache, workflow.importStatus] = ecg_print.sourceFiles.loadRecording( ... + [cache, workflow.importStatus] = ... + ecg_print.sourceFiles.loadRecording( ... filepath, project.parameters, project.parameters.channel); cache.filePreview = ecg_print.sourceFiles.previewFileHeader( ... char(filepath), 18); @@ -21,12 +25,3 @@ "workflow", workflow, ... "cache", cache); end - -function cache = emptyCache() - cache = struct( ... - "filepath", "", "recording", [], "signal", [], ... - "workingSignal", [], "filteredSignal", [], "events", [], ... - "segments", [], "template", [], "measurements", [], ... - "channelItems", {{'(none)'}}, ... - "filePreview", {{'Open a CSV/text file, then use Preview file header.'}}); -end diff --git a/apps/wearable/ecg_print/+ecg_print/definition.m b/apps/wearable/ecg_print/+ecg_print/definition.m index 8a5868829..5a7b7daea 100644 --- a/apps/wearable/ecg_print/+ecg_print/definition.m +++ b/apps/wearable/ecg_print/+ecg_print/definition.m @@ -1,23 +1,16 @@ % App-owned runtime definition for labkit_ECGPrint_app. Expected caller: the % public app entrypoint. Output is a declarative LabKit app definition; side % effects are none. -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_ECGPrint_app", ... - "Id", "ecg_print", ... - "Title", "ECG Signal Print + SNR Explorer", ... - "DisplayName", "ECG Print", ... - "Family", "Wearable", ... - "AppVersion", "1.4.6", ... - "Updated", "2026-07-17", ... - "Requirements", labkit.contract.requirements( ... - "ui", ">=7 <8", "biosignal", ">=1.0 <2"), ... - "Project", ecg_print.projectSpec(), ... - "CreateSession", @ecg_print.createSession, ... - "Layout", @ecg_print.userInterface.buildWorkbenchLayout, ... - "Actions", ecg_print.definitionActions(), ... - "Present", @ecg_print.userInterface.presentWorkbench, ... - "Renderers", struct("previewAxis", ... - @ecg_print.userInterface.drawPreviewAxis), ... - "DebugSample", @ecg_print.debug.writeSamplePack); +function app = definition() + app = labkit.app.Definition( ... + Entrypoint="labkit_ECGPrint_app", AppId="ecg_print", ... + Title="ECG Signal Print + SNR Explorer", DisplayName="ECG Print", ... + Family="Wearable", AppVersion="1.5.1", Updated="2026-07-20", ... + Requirements=labkit.contract.requirements( ... + "app", ">=1 <2", "biosignal", ">=1.0 <2"), ... + ProjectSchema=ecg_print.projectSpec(), ... + CreateSession=@ecg_print.createSession, ... + Workbench=ecg_print.workbench.buildLayout(), ... + PresentWorkbench=@ecg_print.workbench.present, ... + BuildDebugSample=@ecg_print.debug.writeSamplePack); end diff --git a/apps/wearable/ecg_print/+ecg_print/definitionActions.m b/apps/wearable/ecg_print/+ecg_print/definitionActions.m deleted file mode 100644 index 4a0e1a378..000000000 --- a/apps/wearable/ecg_print/+ecg_print/definitionActions.m +++ /dev/null @@ -1,289 +0,0 @@ -% App-owned Runtime V2 actions for ECG Print. Handlers own source parsing, -% channel/analysis transitions, and result exports without control reads, -% figure callback state, UI-axis access, or startup plumbing. -function actions = definitionActions() - actions = struct( ... - "recordingChosen", @onRecordingChosen, ... - "previewHeader", @onPreviewHeader, ... - "importOptionChanged", @onImportOptionChanged, ... - "refreshImport", @onRefreshImport, ... - "channelChanged", @onChannelChanged, ... - "analyze", @onAnalyze, ... - "exportSegments", @onExportSegments, ... - "exportWaveform", @onExportWaveform); -end - -function state = onRecordingChosen(state, event, services) - filepath = firstEventPath(event, services); - if strlength(filepath) == 0 - state = services.workflow.log(state, "Recording selection cancelled."); - return; - end - state.project.inputs.sources = services.project.sourceRecord( ... - "recording", "biosignalRecording", filepath, true); - state.session.cache.filepath = filepath; - state.session.cache.filePreview = ... - ecg_print.sourceFiles.previewFileHeader(char(filepath), 18); - state = parseRecording(state, false, services); -end - -function state = onPreviewHeader(state, ~, services) - filepath = state.session.cache.filepath; - if strlength(filepath) == 0 - state.session.cache.filePreview = ... - {'Open a CSV/text file, then use Preview file header.'}; - return; - end - state.session.cache.filePreview = ... - ecg_print.sourceFiles.previewFileHeader(char(filepath), 18); - state = services.workflow.log(state, ... - "Previewed file header: " + filepath); -end - -function state = onImportOptionChanged(state, ~, ~) - state = clearAnalysis(state); - if ~isempty(state.project.inputs.sources) - state.session.workflow.importStatus = ... - "Import settings changed. Click Parse / refresh file."; - end -end - -function state = onRefreshImport(state, ~, services) - state = parseRecording(state, true, services); -end - -function state = parseRecording(state, showAlert, services) - filepath = state.session.cache.filepath; - if strlength(filepath) == 0 - if showAlert - services.dialogs.alert( ... - "Open a recording before parsing.", "No recording selected"); - else - state.session.workflow.importStatus = ... - "Open a recording before parsing."; - end - return; - end - try - [cache, importStatus] = ecg_print.sourceFiles.loadRecording( ... - filepath, state.project.parameters, ... - state.project.parameters.channel); - cache.filePreview = state.session.cache.filePreview; - state.session.cache = cache; - state.session.workflow.importStatus = importStatus; - state.project.parameters.channel = string(cache.signal.displayName); - state.project.parameters.roiStart = 0; - state.project.parameters.roiEnd = max(cache.signal.time); - state.project.results.lastAnalysis = struct(); - state.project.results.lastSegmentExport = []; - state.project.results.lastWaveformExport = []; - state = services.workflow.log(state, sprintf( ... - "Parsed %d channel(s) from %s", ... - numel(cache.channelItems), filepath)); - catch ME - services.diagnostics.report("Recording parse failed", ME); - state = clearDecodedRecording(state); - state.session.workflow.importStatus = ... - "Parse failed. Inspect header/settings, then refresh: " + ME.message; - state = services.workflow.log(state, ... - "Recording parse failed: " + ME.message); - if showAlert - services.dialogs.alert(ME.message, "Could not parse recording"); - end - end -end - -function state = onChannelChanged(state, event, services) - channel = string(event.value); - if isempty(state.session.cache.recording) || channel == "(none)" - return; - end - try - signal = labkit.biosignal.getChannel( ... - state.session.cache.recording, channel); - catch ME - services.diagnostics.report("Channel selection failed", ME); - services.dialogs.alert(ME.message, "Channel selection failed"); - return; - end - state.project.parameters.channel = channel; - state.project.parameters.roiStart = 0; - state.project.parameters.roiEnd = max(signal.time); - state.session.cache.signal = signal; - state.session.cache.workingSignal = signal; - state = clearAnalysis(state); - state = services.workflow.log(state, "Selected channel: " + channel); -end - -function state = onAnalyze(state, ~, services) - if isempty(state.session.cache.signal) - services.dialogs.alert( ... - "Open a recording and select a channel first.", ... - "No channel selected"); - return; - end - state.project.parameters = sanitizeAnalysisParameters( ... - state.project.parameters, state.session.cache.signal.fs); - try - state.session.cache = ecg_print.analysisRun.analyzeSignal( ... - state.session.cache, state.project.parameters); - catch ME - services.diagnostics.report("Analysis failed", ME); - services.dialogs.alert(ME.message, "Analysis failed"); - state = services.workflow.log(state, "Analysis failed: " + ME.message); - return; - end - state.project.results.lastAnalysis = analysisRecord(state); - state.project.results.lastSegmentExport = []; - state.project.results.lastWaveformExport = []; - state = services.workflow.log(state, sprintf( ... - "Analyzed ROI with %s: %d peaks, %d valid segments.", ... - state.project.parameters.peakMethod, ... - numel(state.session.cache.events.index), ... - size(state.session.cache.segments.values, 2))); -end - -function state = onExportSegments(state, ~, services) - measurements = state.session.cache.measurements; - if isempty(measurements) || isempty(measurements.perSegment) - services.dialogs.alert( ... - "Analyze a signal before exporting segment SNR.", ... - "No segment SNR"); - return; - end - filename = "ecg_segment_snr.csv"; - [out, cancelled] = services.dialogs.outputFile( ... - '*.csv', 'Export segment SNR CSV', filename); - if cancelled - state = services.workflow.log(state, "Segment SNR export cancelled."); - return; - end - analysis = ecg_print.resultFiles.analysisTable( ... - measurements.perSegment, state.project.parameters.smoothBeats); - writetable(analysis, out); - [manifestPath, ~] = writeManifest(state, services, out, ... - "ecgSegmentSnr", "text/csv", "ecg_segment_snr.labkit.json"); - state.project.results.lastSegmentExport = struct( ... - "csvPath", string(out), "manifestPath", string(manifestPath)); - state = services.workflow.log(state, ... - "Exported segment SNR CSV: " + string(out)); -end - -function state = onExportWaveform(state, ~, services) - request = ecg_print.userInterface.waveformPlotRequest( ... - state.session.cache.workingSignal, ... - state.session.cache.filteredSignal, state.session.cache.events); - if ~request.ok - services.dialogs.alert( ... - "Open a recording before exporting a waveform.", ... - "No waveform"); - return; - end - filename = "ecg_waveform.png"; - [out, cancelled] = services.dialogs.outputFile( ... - '*.png', 'Export waveform PNG', filename); - if cancelled - state = services.workflow.log(state, "Waveform export cancelled."); - return; - end - ecg_print.resultFiles.writeWaveformPng(request, out); - [manifestPath, ~] = writeManifest(state, services, out, ... - "ecgWaveform", "image/png", "ecg_waveform.labkit.json"); - state.project.results.lastWaveformExport = struct( ... - "pngPath", string(out), "manifestPath", string(manifestPath)); - state = services.workflow.log(state, ... - "Exported waveform PNG: " + string(out)); -end - -function record = analysisRecord(state) - cache = state.session.cache; - perSegment = table(); - summary = struct(); - if ~isempty(cache.measurements) - perSegment = cache.measurements.perSegment; - summary = cache.measurements.summary; - end - record = struct( ... - "channel", state.project.parameters.channel, ... - "eventCount", numel(cache.events.index), ... - "segmentCount", size(cache.segments.values, 2), ... - "summary", summary, "perSegment", perSegment); -end - -function [manifestPath, report] = writeManifest( ... - state, services, outputPath, id, mediaType, manifestName) - [folder, name, extension] = fileparts(outputPath); - output = services.results.output(id, "primary", ... - string(name) + string(extension), mediaType); - summary = struct(); - if ~isempty(fieldnames(state.project.results.lastAnalysis)) - summary = rmfield(state.project.results.lastAnalysis, "perSegment"); - end - spec = struct( ... - "Outputs", output, "Inputs", state.project.inputs.sources, ... - "Parameters", state.project.parameters, "Summary", summary, ... - "ManifestName", manifestName); - [manifestPath, report] = services.results.writeManifest(folder, spec); -end - -function state = clearAnalysis(state) - state.session.cache.filteredSignal = []; - state.session.cache.events = []; - state.session.cache.segments = []; - state.session.cache.template = []; - state.session.cache.measurements = []; - if ~isempty(state.session.cache.signal) - state.session.cache.workingSignal = state.session.cache.signal; - end - state.project.results.lastAnalysis = struct(); - state.project.results.lastSegmentExport = []; - state.project.results.lastWaveformExport = []; -end - -function state = clearDecodedRecording(state) - filepath = state.session.cache.filepath; - preview = state.session.cache.filePreview; - projectSpec = ecg_print.projectSpec(); - empty = ecg_print.createSession(projectSpec.Create()); - state.session.cache = empty.cache; - state.session.cache.filepath = filepath; - state.session.cache.filePreview = preview; - state.project.parameters.channel = "(none)"; - state = clearAnalysis(state); -end - -function parameters = sanitizeAnalysisParameters(parameters, sampleRate) - parameters.roiStart = finiteNonnegative(parameters.roiStart, 0); - parameters.roiEnd = finiteNonnegative(parameters.roiEnd, 0); - parameters.lowCut = finiteNonnegative(parameters.lowCut, 0.5); - parameters.highCut = finiteNonnegative(parameters.highCut, 40); - parameters.highCut = min(parameters.highCut, ... - max(parameters.lowCut + eps, 0.45 * sampleRate)); - parameters.peakDistance = max(eps, ... - finiteNonnegative(parameters.peakDistance, 0.28)); - parameters.segmentWindow = max(eps, ... - finiteNonnegative(parameters.segmentWindow, 0.7)); - parameters.templateTopN = max(1, round( ... - finiteNonnegative(parameters.templateTopN, 30))); - parameters.smoothBeats = max(1, round( ... - finiteNonnegative(parameters.smoothBeats, 15))); -end - -function value = finiteNonnegative(value, fallback) - value = double(value); - if isempty(value) || ~isscalar(value) || ~isfinite(value) - value = fallback; - end - value = max(0, value); -end - -function filepath = firstEventPath(event, services) - paths = services.events.paths(event, "addedFiles"); - if isempty(paths) - paths = services.events.paths(event, "files"); - end - filepath = ""; - if ~isempty(paths) - filepath = paths(1); - end -end diff --git a/apps/wearable/ecg_print/+ecg_print/projectSpec.m b/apps/wearable/ecg_print/+ecg_print/projectSpec.m index 395efe4a4..ccf514dec 100644 --- a/apps/wearable/ecg_print/+ecg_print/projectSpec.m +++ b/apps/wearable/ecg_print/+ecg_print/projectSpec.m @@ -1,18 +1,15 @@ -% App-owned durable ECG Print contract. Runtime V2 applies the single +% App-owned durable ECG Print contract. The App SDK applies the single % migration entry until version 2, then validates sources, parameters, and % compact result records before rebuilding decoded signal state. function spec = projectSpec() - spec = struct( ... - "Version", 2, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", @migrateProject); + spec = labkit.app.project.Schema(Version=2, ... + Create=@createProject, Validate=@validateProject, Migrate=@migrateProject); end function project = createProject() project = struct(); project.inputs = struct("sources", ... - labkit.ui.runtime.emptySourceRecords()); + struct([])); project.parameters = struct( ... "fallbackFs", 2000, "headerLine", 0, "hasHeader", "Auto", ... "timeColumn", "", "timeUnit", "Auto", "signalColumns", "", ... diff --git a/apps/wearable/ecg_print/labkit_ECGPrint_app.m b/apps/wearable/ecg_print/labkit_ECGPrint_app.m index c58f2e50c..5a08ac1d2 100644 --- a/apps/wearable/ecg_print/labkit_ECGPrint_app.m +++ b/apps/wearable/ecg_print/labkit_ECGPrint_app.m @@ -1,6 +1,5 @@ function varargout = labkit_ECGPrint_app(varargin) %LABKIT_ECGPRINT_APP Explore ECG quality, SNR, and printable waveforms. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @ecg_print.definition, varargin{:}); + [varargout{1:nargout}] = ecg_print.definition().launch(varargin{:}); end diff --git a/docs/apps/dic/dic-postprocess/README.md b/docs/apps/dic/dic-postprocess/README.md index 67a2a10ae..6c2004b3f 100644 --- a/docs/apps/dic/dic-postprocess/README.md +++ b/docs/apps/dic/dic-postprocess/README.md @@ -1,5 +1,8 @@ # DIC Postprocess +Every action and input-selection button provides hover help describing its +DIC inputs, ROI/strain processing, display-only effects, or exported evidence. + DIC Postprocess converts Ncorr strain fields into EXX and EYY overlays on an optical reference image and calculates descriptive strain statistics over a validated ROI. It is a rendering and summary tool; it does not rerun DIC. @@ -142,7 +145,7 @@ optional runtime capabilities. `projectSpec.m` keeps the complete version-1 durable schema, creation defaults, and validation together. `createSession.m` rebuilds file-backed strain, image, mask, and overlay caches because those are transient runtime data rather than saved project fields. The App requires -`labkit.ui >=7 <8` and `labkit.image >=2 <3`; busy-state, optional source-slot +`labkit.app >=1 <2` and `labkit.image >=2 <3`; busy-state, optional source-slot lookup, and portable-reference serialization remain framework-owned. The project validator requires the DIC source collection and checks summary @@ -154,4 +157,5 @@ fields. Runtime supplies absent canonical buckets and owns workflow-log initialization. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +every control and plot names its concrete callback or renderer, and the +definition validates those bindings before creating a figure. diff --git a/docs/apps/dic/dic-preprocess/README.md b/docs/apps/dic/dic-preprocess/README.md index 7d5660bb6..a745ed6e8 100644 --- a/docs/apps/dic/dic-preprocess/README.md +++ b/docs/apps/dic/dic-preprocess/README.md @@ -1,5 +1,8 @@ # DIC Preprocess +Every action and input-selection button provides hover help describing its +registration, crop, binary ROI-mask, or downstream DIC effect. + DIC Preprocess registers a moving optical image to a reference image, applies repeatable crop operations to the pair, and creates a binary analysis mask. Use it when camera motion or framing differences must be removed before an @@ -163,13 +166,13 @@ assumptions, output shape, limitations, failure behavior, and related APIs. ## Framework Compatibility -The single `definition.m` owns product metadata, requirements, layout, and -optional runtime capabilities. `projectSpec.m` keeps the complete durable -version-1 schema, creation defaults, and validation together. `createSession.m` -rebuilds decoded source images and replays applied alignment/crop steps because -those images are transient caches rather than project data. The App requires -`labkit.ui >=7 <8` and `labkit.image >=2 <3`; busy-state, managed interactions, -optional source-slot lookup, and portable-reference serialization remain +The single `definition.m` owns product metadata and the immutable App SDK +contract. `projectSpec.m` keeps the complete durable version-1 schema, +creation defaults, and validation together. `createSession.m` rebuilds decoded +source images and replays applied alignment/crop steps because those images +are transient caches rather than project data. The App requires +`labkit.app >=1 <2` and `labkit.image >=2 <3`; managed interactions, portable +source resolution, lifecycle, and presentation reconciliation remain framework-owned. The project validator requires the DIC image-source collection and checks @@ -183,8 +186,10 @@ image cache is ready. There is no generic app-state service or alternate lifecycle layer. Its session factory returns only App-specific editing workflow and decoded -cache fields. Runtime supplies absent canonical buckets and owns workflow-log -initialization. +cache fields. Layout controls bind directly to capability-owned callbacks, +while `+workbench` composes the complete layout and presentation. -The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +The semantic layout follows the +[App callback contract](../../../framework/README.md): each control and managed +interaction references its concrete callback directly and resolves during +definition construction. diff --git a/docs/apps/electrochemistry/chrono-overlay/README.md b/docs/apps/electrochemistry/chrono-overlay/README.md index 5626f6d6f..e041b3b91 100644 --- a/docs/apps/electrochemistry/chrono-overlay/README.md +++ b/docs/apps/electrochemistry/chrono-overlay/README.md @@ -1,11 +1,14 @@ # Chrono Overlay +Every action and input-selection button provides hover help describing its +chrono trace alignment, voltage/current data, or export effect. + Chrono Overlay compares voltage and current transients from multiple Gamry DTA files on a common pulse-centered time axis and exports the aligned curves. ## Requirements And Launch -The app uses the LabKit UI framework and DTA library. Each source must contain a +The app uses the LabKit App SDK and DTA library. Each source must contain a readable chrono curve with time, voltage, and current data. ```matlab @@ -17,9 +20,9 @@ labkit_ChronoOverlay_app Use **Add DTA files** to select one or more `.DTA` files from one directory. The app parses each file as chrono data and reports unreadable items. The file list controls curve order, legend labels, and removal; selection does not -discard other loaded curves. Runtime V2 reconciles durable source records with -the successfully decoded list, preserving the identity of retained files and -allocating collision-free identities after removal and later additions. +discard other loaded curves. The App runtime owns durable source identities, +portable project references, add/remove/clear behavior, and selection. It +rebuilds the transient decoded session only when the file collection changes. ## Basic Workflow @@ -93,17 +96,13 @@ listed as stable public APIs; reusable DTA reading is supported through ## Framework Compatibility -The single `definition.m` owns product metadata, requirements, layout, and -optional runtime capabilities. `projectSpec.m` owns the current version-2 -domain schema plus one version-aware migration entry; its validator requires -the App's source collection. Runtime V2 advances older payloads one version at -a time and validates canonical buckets and each source record before the App -checks its parameter rules. `createSession.m` rebuilds -decoded DTA items and selection because curves are transient caches. Runtime -supplies omitted empty workflow and view buckets. The App requires -`labkit.ui >=7 <8` and `labkit.dta >=2 <3`; busy-state, source identity, -resolved-path access, and portable-reference serialization remain -framework-owned. - -The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +`definition.m` returns one validated `labkit.app.Definition`. +`projectSpec.m` returns the current version-2 `labkit.app.project.Schema` plus its +version-aware migration entry. `createSession(project,context)` resolves the +runtime-owned portable sources and rebuilds decoded DTA items because curves +remain transient caches. Layout bindings provide all four plot parameters and +the file collection without App callbacks or presenter duplication. +`+workbench/buildLayout.m` binds CSV export directly to +`+resultFiles/exportSelectedCurves.m`, while `+overlayPlot/draw.m` receives the +voltage and current axes in declared order. The App requires +`labkit.app >=1 <2` and `labkit.dta >=2 <3`. diff --git a/docs/apps/electrochemistry/cic/README.md b/docs/apps/electrochemistry/cic/README.md index b6e9237e2..2a1e31213 100644 --- a/docs/apps/electrochemistry/cic/README.md +++ b/docs/apps/electrochemistry/cic/README.md @@ -1,5 +1,8 @@ # Charge-Injection Capacity +Every action and input-selection button provides hover help describing its +pulse data, injected-charge/CIC result, or export effect. + The CIC app measures charge delivered by a biphasic current pulse, normalizes charge by electrode area, and reports voltage-transient polarization metrics at a controlled delay after each pulse phase. @@ -18,7 +21,7 @@ labkit_CIC_app Add one or more chrono `.DTA` files. The selected row is decoded for immediate preview; batch calculation is performed with the same analysis settings when results are exported. This avoids repeatedly decoding every large file while -the user is only switching previews. Runtime V2 reconciles the ordered path +the user is only switching previews. App SDK runtime reconciles the ordered path list with durable source records, so retained files keep stable identities and new files receive collision-free identities without an App-owned counter. @@ -128,10 +131,11 @@ domain schema, defaults, parameter validation, and the required source collection; Runtime validates canonical buckets and each source record first. `createSession.m` deliberately decodes only the first source for immediate preview; remaining batch files stay lazy until selection or export. The App -requires `labkit.ui >=7 <8` and +requires `labkit.app >=1 <2` and `labkit.dta >=2 <3`; Runtime also supplies omitted empty session buckets and owns workflow-log initialization. Busy-state, source identity, resolved-path access, and portable-reference serialization remain framework-owned. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +every control and plot names its concrete callback or renderer, and the +definition validates those bindings before creating a figure. diff --git a/docs/apps/electrochemistry/csc/README.md b/docs/apps/electrochemistry/csc/README.md index d2aff30ec..e6f77772c 100644 --- a/docs/apps/electrochemistry/csc/README.md +++ b/docs/apps/electrochemistry/csc/README.md @@ -1,5 +1,8 @@ # Charge-Storage Capacity +Every action and input-selection button provides hover help describing its +CV/CT data, charge-storage comparison, or export effect. + The CSC app compares charge obtained from time-domain current integration with charge obtained from cyclic-voltammetry integration for every readable CV/CT cycle in one or more Gamry DTA files. @@ -18,7 +21,7 @@ labkit_CSC_app Add one or more CV/CT `.DTA` files. The selected file determines the current curve list, readout, and plots. Selecting another file resets the curve selection and default plot quantities to that file; it does not silently keep a cycle from -the previous source. Runtime V2 reconciles durable source identities from the +the previous source. App SDK runtime reconciles durable source identities from the successfully decoded file order, preserving retained identities through removal, later additions, save, and reopen. @@ -127,9 +130,10 @@ collection; Runtime validates canonical buckets and each source record first. `createSession.m` rebuilds decoded CV/CT curves and active selection because they are transient runtime data. The App omits empty workflow and view buckets because Runtime canonicalizes them. -It requires `labkit.ui >=7 <8` and `labkit.dta >=2 <3`; busy-state, source +It requires `labkit.app >=1 <2` and `labkit.dta >=2 <3`; busy-state, source identity, resolved-path access, and portable-reference serialization remain framework-owned. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +every control and plot names its concrete callback or renderer, and the +definition validates those bindings before creating a figure. diff --git a/docs/apps/electrochemistry/eis/README.md b/docs/apps/electrochemistry/eis/README.md index 223455aed..95eeba9bb 100644 --- a/docs/apps/electrochemistry/eis/README.md +++ b/docs/apps/electrochemistry/eis/README.md @@ -1,5 +1,8 @@ # EIS +Every action and input-selection button provides hover help describing its +impedance input, plotted quantities, or exported EIS data. + EIS overlays impedance data from one or more Gamry `ZCURVE` tables, supports Nyquist and Bode-style axis combinations, and exports the values currently selected for plotting. @@ -16,8 +19,8 @@ labkit_EIS_app Add one or more `.DTA` files containing a readable EIS `ZCURVE`. Files that do not contain the required curve are reported and omitted from the plot. The -source list is preserved in project state through portable references. Runtime -V2 reconciles those records with the successfully decoded file list, preserves +source list is preserved in project state through portable references. The App +SDK runtime reconciles those records with the successfully decoded file list, preserves the identity of files that remain loaded, and assigns unique identities to new files; EIS does not maintain its own source-ID counter. @@ -100,10 +103,11 @@ domain schema, defaults, plot-parameter validation, and the required source collection; Runtime validates canonical buckets and each source record first. `createSession.m` rebuilds decoded ZCURVE items and selected paths because they are transient runtime data. Empty workflow and view buckets are supplied by -Runtime V2 rather than repeated in the App factory. The App requires -`labkit.ui >=7 <8` and +App SDK runtime rather than repeated in the App factory. The App requires +`labkit.app >=1 <2` and `labkit.dta >=2 <3`; busy-state, viewport-preserving rendering, resolved-path access, and portable-reference serialization remain framework-owned. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +every control and plot names its concrete callback or renderer, and the +definition validates those bindings before creating a figure. diff --git a/docs/apps/electrochemistry/vt-resistance/README.md b/docs/apps/electrochemistry/vt-resistance/README.md index db0d27ac2..8d7355c3b 100644 --- a/docs/apps/electrochemistry/vt-resistance/README.md +++ b/docs/apps/electrochemistry/vt-resistance/README.md @@ -1,12 +1,15 @@ # VT Resistance +Every action and input-selection button provides hover help describing its +pulse voltage/current input, resistance result, or export effect. + VT Resistance estimates cathodic and anodic steady resistance from a biphasic voltage transient and reports the mean of their absolute values. ## Requirements And Launch -The app uses the LabKit UI framework and DTA library and requires a chrono DTA curve with -valid time, voltage, and current columns. +The app uses the LabKit App framework and DTA library and requires a chrono +DTA curve with valid time, voltage, and current columns. ```matlab labkit_VTResistance_app @@ -14,11 +17,11 @@ labkit_VTResistance_app ## Inputs And Batch Behavior -Add one or more chrono `.DTA` files. The selected file is decoded and analyzed -for preview; exporting applies the current settings to the full source list. +Add one or more chrono `.DTA` files. The transient session decodes and analyzes +the registered batch so shared setting changes update every result together. No electrode-area normalization is performed because the reported quantity is -electrical resistance in ohms. Runtime V2 reconciles the ordered lazy path list -with durable source records, preserving retained identities and allocating +electrical resistance in ohms. The App runtime reconciles the ordered source +list with durable source records, preserving retained identities and allocating collision-free identities for later additions. ## Basic Workflow @@ -99,16 +102,12 @@ assert(result.ok, result.message); ## Framework Compatibility -The single `definition.m` owns product metadata, requirements, layout, and -optional runtime capabilities. `projectSpec.m` owns the complete version-1 -domain schema, defaults, analysis-parameter validation, and the required source -collection; Runtime validates canonical buckets and each source record first. -`createSession.m` deliberately decodes only the first source for preview; the -remaining batch stays lazy until selection or export. The App requires -`labkit.ui >=7 <8` and -`labkit.dta >=2 <3`; Runtime supplies omitted empty session buckets and owns -workflow-log initialization. Busy-state, source identity, resolved-path -access, and portable-reference serialization remain framework-owned. - -The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +The single `definition.m` owns product metadata and the immutable App SDK +contract. `projectSpec.m` owns the complete version-1 domain schema, defaults, +analysis-parameter validation, and required source collection. +`+workbench/buildLayout.m` binds fields and buttons directly to concrete +capability callbacks, while `+workbench/present.m` produces a complete +`labkit.app.view.Snapshot`. The App requires `labkit.app >=1 <2` and +`labkit.dta >=2 <3`. Lifecycle, callback dispatch, source identity, +resolved-path access, project documents, result manifests, and native layout +remain framework-owned. diff --git a/docs/apps/gait/gait-analysis/README.md b/docs/apps/gait/gait-analysis/README.md index 82716dcfc..5e45a73e3 100644 --- a/docs/apps/gait/gait-analysis/README.md +++ b/docs/apps/gait/gait-analysis/README.md @@ -1,5 +1,8 @@ # Gait Analysis +Every action and input-selection button provides hover help describing its +pose input, detected-step navigation, gait calculation, or export effect. + Gait Analysis 2 converts a current Video Marker project into independently segmented treadmill swing steps, per-frame kinematics, per-step gait parameters, visual step reports, and reproducible CSV outputs. Loading and @@ -52,14 +55,14 @@ length time series use conventional plot axes. validation, and the single migration entry for versions 1 and 2. Version 1 renames the legacy step/stride options and invalidates results whose scientific meaning changed. Version 2 moves its singular source into the canonical source -collection. Runtime V2 selects each missing step and validates the final +collection. The App runtime selects each missing step and validates the final project. The pose source, analysis options, computed tables/events, and export record are durable. Decoded pose data, selected step, output-folder convenience, workflow log, and duplicate-run fingerprint are transient and rebuilt by `gait_analysis.createSession`. Source paths are resolved by the Runtime before -session construction and are read through `sourcePaths`. +session construction through the sealed `labkit.app.CallbackContext`. ## Two-Stage Workflow @@ -203,11 +206,10 @@ writetable(result.stepTable, "steps.csv"); ## Framework Compatibility -This App requires `labkit.ui >=7 <8`. Its single `definition.m` owns product -metadata, requirements, layout, actions, presentation, renderer, and debug -capability. `projectSpec.m` concentrates durable creation, validation, and both -historical migration steps; root `createSession.m` rebuilds transient decoded -pose state. +This App requires `labkit.app >=1 <2`. Its single `definition.m` owns product +metadata and the immutable App SDK contract. `projectSpec.m` concentrates +durable creation, validation, and both historical migration steps; root +`createSession.m` rebuilds transient decoded pose state. The project validator requires the pose-project source collection and checks gait options, numeric limits, and result fields; Runtime validates canonical @@ -215,9 +217,11 @@ buckets and each source record first. Analysis defaults, source-fact normalization, result construction, duplicate run fingerprints, and gait calculations are co-located under `+analysisRun`. -There is no generic App lifecycle or state package. Migration iteration, -portable source references, callback queues, busy state, source relinking, and -serialization remain framework-owned. +The `+workbench` package assembles capability-owned callbacks and a complete +snapshot; analysis, step navigation, gait rendering, source adoption, and +result export remain with their semantic owners. Migration iteration, portable +source references, callback queues, source relinking, project documents, +result manifests, and serialization remain framework-owned. Its synthetic debug fixture writes the documented Video Marker payload shape without loading a sibling App package. A separate producer-consumer integration @@ -226,8 +230,6 @@ saved MAT through Gait, so producer drift is detected without making the consumer's normal launch depend on Video Marker source code. Its session factory returns only App-specific step selection, output-folder -workflow, and decoded pose cache fields. Runtime supplies absent canonical -buckets and owns workflow-log initialization. - -The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +workflow, and decoded pose cache fields. Layout controls bind directly to +concrete semantic callbacks; there is no App-authored action or renderer +registry. diff --git a/docs/apps/image-measurement/batch-crop/README.md b/docs/apps/image-measurement/batch-crop/README.md index 6b99661c3..beb74ff3f 100644 --- a/docs/apps/image-measurement/batch-crop/README.md +++ b/docs/apps/image-measurement/batch-crop/README.md @@ -1,5 +1,8 @@ # Batch Image Crop +Every action and input-selection button provides hover help describing its +crop task, geometry, physical calibration, scale bar, or export effect. + Batch Image Crop defines one crop task per image, previews rotation and edge-continuous padding, and exports repeatable same-size crops in pixel or physical-scale mode. @@ -69,25 +72,28 @@ parameters and identifies each output file. ## Project And State -Saved projects use durable schema version 2. `inputs.sources` contains the -framework's portable source records, while `inputs.items` contains the crop -tasks that refer to those sources by `sourceId`. Crop dimensions, physical +Saved projects use durable schema version 3. `inputs.sources` contains one +portable source record per crop task, while `inputs.items` contains the +aligned task values that refer to those records by `sourceId`. Repeated crop +tasks may therefore keep distinct source identities while resolving to the +same image path. Crop dimensions, physical scale settings, output format, output folder, and scale-bar choices are durable project parameters. Loaded image pixels, the current selection, interaction flags, preview graphics, and rotated-canvas caches are transient session state and are reconstructed after load. -Version-1 projects are upgraded by the single migration entry in -`batch_crop.projectSpec`. It removes embedded image pixels, converts item paths -to source records, and preserves each task's crop center, rotation, padding, -and calibration. Missing required sources are handled by the Runtime's shared +Version-1 and version-2 projects are upgraded sequentially by the single +migration entry in `batch_crop.projectSpec`. It removes embedded image pixels, +converts item paths to source records, expands shared records into aligned +task records, and preserves each task's crop center, rotation, padding, and +calibration. Missing required sources are handled by the Runtime's shared source-reconciliation workflow rather than by Batch Crop-specific path code. ## Use Without The GUI ```matlab -calibration = labkit.ui.interaction.scaleBarCalibration(20, 10, "um"); +calibration = labkit.app.interaction.scaleCalibration(20, 10, "um"); items = struct("scaleCalibration", calibration); physicalOptions = struct( ... "physicalWidth", 5, "physicalHeight", 3, ... @@ -126,30 +132,31 @@ when reproducing a physical-scale export outside the GUI. ## Framework Compatibility -This App requires `labkit.ui >=7 <8` and `labkit.image >=2 <3`. Its single -`definition.m` owns product metadata, dependencies, layout, actions, -presentation, renderers, and optional capabilities. Durable creation, -validation, and migration are concentrated in `projectSpec.m`; root -`createSession.m` reconstructs transient state and lazily loads only the first -selected image. +This App requires `labkit.app >=1 <2` and `labkit.image >=2 <3`. Its single +`definition.m` owns product metadata and the immutable App SDK contract. +Durable creation, validation, and migration are concentrated in +`projectSpec.m`; root `createSession.m` resolves task sources, reconstructs +transient state, and lazily loads only the selected image. The project validator requires the App's item and source collections, validates their relationship and crop parameters, and leaves canonical bucket and source record shape to Runtime. Workflow helpers are owned by the capabilities they describe: -`+sourceFiles`, `+cropTasks`, `+cropGeometry`, `+scaleCalibration`, -`+resultFiles`, and `+userInterface`. There is no generic App lifecycle or -state package. Busy state, source serialization, migration iteration, and -portable-path reconciliation remain framework responsibilities. +`+sourceFiles`, `+cropTasks`, `+cropGeometry`, `+cropPreview`, +`+scaleCalibration`, and `+resultFiles`; `+workbench` is the only product +assembly boundary. There is no generic App lifecycle, state, handler, or +renderer registry. Source serialization, migration iteration, and +portable-path resolution remain framework responsibilities. -Variable-length crop manifest outputs begin with the framework's canonical -empty output array, so zero-result and multi-result exports never construct an -invalid placeholder ID. +Each durable crop task owns one portable source identity. Duplicate tasks may +resolve to the same image path, remain independently selectable, and can be +removed without removing their siblings. Its session factory returns only App-specific selection, crop workflow, view, -and image-cache fields. Runtime supplies absent canonical buckets and owns -workflow-log initialization. +resolved task paths, and image-cache fields. Runtime owns lifecycle and +workflow status. -The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +The semantic layout follows the [App Framework](../../../framework/README.md): +controls and managed interactions reference their concrete capability-owned +callbacks directly and resolve during definition construction. diff --git a/docs/apps/image-measurement/curvature/README.md b/docs/apps/image-measurement/curvature/README.md index e3fe7ffc7..4c4c9ff1d 100644 --- a/docs/apps/image-measurement/curvature/README.md +++ b/docs/apps/image-measurement/curvature/README.md @@ -1,5 +1,8 @@ # Curvature Measurement +Every action and input-selection button provides hover help describing its +curve trace, calibration, curvature/length calculation, or export effect. + Curvature Measurement fits a circle to an ordered image curve, reports radius and curvature, measures traced arc length, and supports pixel-to-physical scale calibration. @@ -14,16 +17,28 @@ labkit_CurvatureMeasurement_app ## Basic Workflow -1. Choose an image. -2. Measure a known scale reference and enter its length/unit when physical - values are required. -3. Start curve editing and place ordered anchors along the feature. -4. Drag anchors to refine the trace; undo or clear as needed. -5. Fit the circle and measure curve length. -6. Export the result CSV and overlay PNG. - -The canvas title/subtitle identifies curve-edit mode and its placement/removal -gesture. Anchor edits and result overlays preserve the current axes zoom. +The **Files + Analysis** tab contains the complete workflow: + +1. Choose an image in the **Image** section. +2. Use **Measure reference pixels**, or enter **Reference pixels** directly, + then enter the known reference length and unit. +3. Configure and place the optional display scale bar. +4. Use **Start curve edit** and place ordered anchors along the feature. +5. Drag anchors to refine the trace; undo or clear as needed, then finish + curve editing. +6. Choose the densification settings, fit the circle, and measure curve + length. +7. Export the result CSV and overlay PNG. + +The edit buttons change to **Finish curve edit** or **Finish reference edit** +while their managed interaction is active. Curve and reference edits are +mutually exclusive. Anchor edits and result overlays preserve the current +axes zoom. + +The **Summary + Results** tab reports curve length, radius, curvature, RMSE, +fit center, and pixels per selected unit. **Details** explains the next valid +step before a result exists and reports the current measurement afterward. +The **Log** tab records file, edit, fit, calibration, and export actions. The chosen image is stored in the standard project `inputs.sources` collection. Version 1 projects using the former singular `inputs.source` @@ -67,8 +82,9 @@ moving the display bar does not modify the fit. The CSV records point count, fit settings, center, radius, curvature, traced length, calibration, units, and status. The overlay PNG contains the source -image, curve, fitted circle, and configured scale bar when available. A result -manifest records source and parameter provenance. +image, ordered curve, optional dense samples, fit residuals, fitted circle, +center, and configured scale bar when available. Each export writes its +standard result manifest with source and parameter provenance. ## Use Without The GUI @@ -94,26 +110,35 @@ lengthResult = curvature.analysisRun.computeCurveLength(points, struct()); ## Framework Compatibility -The single `definition.m` owns product metadata, requirements, layout, actions, -presentation, renderer, and debug-sample capability. `projectSpec.m` is the -only durable-project entry and keeps current creation, validation, and the -version-1 source migration together. Runtime V2 owns the migration loop. Root -`createSession.m` reconstructs the decoded image and transient edit state after -source relinking. +The single `definition.m` owns product metadata, requirements, the composed +workbench, project/session boundaries, presentation, and debug-sample +capability. `+workbench/buildLayout.m` composes source selection, curve +editing, scale calibration, analysis/export, summary, log, and preview +surfaces from their owning capability packages. Layout nodes bind their +concrete callbacks and renderer directly; the App has no handler or renderer +registry. + +`projectSpec.m` is the only durable-project entry and keeps current creation, +validation, and the version-1 source migration together. The runtime owns the +migration loop. Root `createSession.m` reconstructs the decoded image and +transient edit state after source relinking. The project validator requires the image-source collection and checks curvature parameters, annotations, and results; Runtime validates canonical buckets and each source record first. Fit/length result shapes and deterministic task fingerprints live with their -calculations under `+analysisRun`; there is no generic `+appState` package. The -App requires `labkit.ui >=7 <8` and `labkit.image >=2 <3`; source-path access, -persistence, callback lifetime, and managed anchor interactions remain -framework-owned. +calculations under `+analysisRun`; there is no generic state or action +package. The App requires `labkit.app >=1 <2` and `labkit.image >=2 <3`; +source-path access, persistence, callback lifetime, result manifests, and +managed anchor/reference interactions remain framework-owned. Its session factory returns only App-specific edit workflow, scale-bar view, and decoded image cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization. -The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +The semantic layout follows the +[App framework contract](../../../framework/README.md): callbacks name the +complete application state, typed event value when present, and +`CallbackContext` at their direct boundary, then delegate scientific work +through narrow inputs. diff --git a/docs/apps/image-measurement/flir-thermal/README.md b/docs/apps/image-measurement/flir-thermal/README.md index 4b5347b72..c399ddc97 100644 --- a/docs/apps/image-measurement/flir-thermal/README.md +++ b/docs/apps/image-measurement/flir-thermal/README.md @@ -1,5 +1,8 @@ # FLIR Thermal +Every action and input-selection button provides hover help describing its +radiometric input, calibrated temperature statistic, color mapping, or export. + FLIR Thermal decodes radiometric FLIR JPEG/RJPEG files, displays calibrated temperature maps, measures rectangular ROI hot/cold/mean values, and exports rendered images with Celsius data. @@ -26,9 +29,10 @@ are read-only. 1. Load radiometric files and inspect the decoded min/max/metadata summary. 2. Choose a palette and color mapping. 3. Set a range preset, per-image range, or shared group range. -4. Place a rectangular ROI and choose hot spot, cold spot, or mean reading. -5. Review the numeric result and marker. -6. Export the current image or the full batch. +4. Choose ROI hot spot, ROI cold spot, or ROI mean, then drag its rectangle. +5. Optionally click the image for an independent manual point reading. +6. Review the numeric results and markers. +7. Export the current image or the full batch. Placing or dragging a reading ROI and refreshing its marker preserves the current zoom. The reading is recalculated from the thermal matrix, not from @@ -68,16 +72,17 @@ does not change source data. ## Outputs -Current or batch export can write PNG, TIFF, or JPEG rendered thermal images, -color scale graphics, Celsius matrices/tables, measurement values, and a -manifest. The clean image export excludes interactive toolbar chrome. Numeric -temperature outputs remain Celsius regardless of palette or mapping mode. +Current or batch export writes PNG, TIFF, or JPEG rendered thermal images, +matching color scale graphics, Celsius CSV matrices, measurement values, a +batch CSV summary, and the standard `flir_thermal.labkit.json` result manifest. +The clean image export excludes interactive toolbar chrome. Numeric temperature +outputs remain Celsius regardless of palette or mapping mode. ## Project And State Saved projects keep portable source references, display parameters, export settings, and lightweight per-image ranges and readings. Raw sensor matrices -and decoded Celsius matrices are transient session data: Runtime V2 resolves +and decoded Celsius matrices are transient session data: App SDK runtime resolves the source references and the App decodes only the selected image again when a project opens. Missing source files therefore use the framework's relinking flow rather than embedding local absolute paths in the project. @@ -128,11 +133,15 @@ conversion failures, and related measurement APIs. ## Framework Compatibility -The single `definition.m` owns product metadata, requirements, layout, actions, -presentation, renderers, and debug-sample capability. `projectSpec.m` is the -only durable-project entry; the version-1 payload needs creation and validation -but no migration. Root `createSession.m` rebuilds only the selected decoded -thermal item after Runtime V2 resolves sources. +`definition.m` is the App composition root. It declares product metadata, +requirements, project/session/presentation callbacks, the composed workbench, +and debug-sample capability through `labkit.app.Definition`. +`+workbench/buildLayout.m` is the visible product assembly boundary: +source navigation, display mapping, reading tools, exports, and preview are +composed from their owning capability packages. `projectSpec.m` is the only +durable-project schema entry; the version-1 payload needs creation and +validation but no migration. Root `createSession.m` rebuilds only the selected +decoded thermal item after the runtime resolves portable sources. The project validator requires the thermal-source collection and checks thermal parameters and annotations; Runtime validates canonical buckets and @@ -141,16 +150,15 @@ each source record first. Decoded record shape lives with `+sourceFiles`, point and ROI calculations live with `+analysisRun`, and lightweight durable readings live with `+thermalAnnotations`; there is no generic `+appState` package. The App -requires `labkit.ui >=7 <8`, `labkit.image >=2 <3`, and -`labkit.thermal >=1.1 <2`. Source-path access, persistence, callback lifetime, -busy state, and managed region interaction remain framework-owned. Thermal -image, colorbar, and CSV manifest outputs are appended to the framework's -canonical empty output array; no invalid placeholder result is created before -batch export. +requires `labkit.app >=1 <2`, `labkit.image >=2 <3`, and +`labkit.thermal >=1.1 <2`. Runtime callbacks name the complete application +state and injected `labkit.app.CallbackContext` explicitly. Source-path access, +persistence, callback lifetime, diagnostic recording, managed region +interaction, render surfaces, and result-manifest writing remain +framework-owned. Its session factory returns only App-specific image selection and decoded -thermal cache fields. Runtime supplies absent canonical buckets and owns -workflow-log initialization. - -The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +thermal cache fields. Presentation produces one complete +`labkit.app.view.Snapshot`; controls bind directly to concrete semantic +callbacks and the paired preview owns its renderer and managed reading +interaction. No App-authored handler or renderer registry remains. diff --git a/docs/apps/image-measurement/focus-stack/README.md b/docs/apps/image-measurement/focus-stack/README.md index 652938062..10c3b2214 100644 --- a/docs/apps/image-measurement/focus-stack/README.md +++ b/docs/apps/image-measurement/focus-stack/README.md @@ -1,5 +1,8 @@ # Focus Stack +Every action and input-selection button provides hover help describing its +z-stack input, local-focus fusion, depth map, or export effect. + Focus Stack fuses at least two focal planes into one all-in-focus image using multilevel Laplacian focus evidence and exports a focus-depth index map. @@ -101,8 +104,8 @@ imwrite(result.fused, "stacked.png"); The single `definition.m` owns product metadata, requirements, layout, actions, presentation, renderers, and debug-sample capability. `projectSpec.m` is the only durable-project entry; the version-1 project needs creation and validation -but no migration. Root `createSession.m` rebuilds decoded images after Runtime -V2 resolves sources. +but no migration. Root `createSession.m` rebuilds decoded images after the App +SDK runtime resolves sources. The project validator requires the image-source collection and checks fusion parameters; Runtime validates canonical buckets and each source record first. @@ -111,7 +114,7 @@ Fusion result defaults, preset values, and deterministic run fingerprints live with the computation under `+analysisRun`; there is no generic `+appState` package. A new empty project performs no App-specific startup callback and chooses an output location only after sources are added or the user exports. -The App requires `labkit.ui >=7 <8` and `labkit.image >=2 <3`; source-path +The App requires `labkit.app >=1 <2` and `labkit.image >=2 <3`; source-path access, persistence, busy state, and debug lifecycle remain framework-owned. Its session factory returns only App-specific registration workflow and image @@ -119,4 +122,5 @@ cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +every control and plot names its concrete callback or renderer, and the +definition validates those bindings before creating a figure. diff --git a/docs/apps/image-measurement/image-enhance/README.md b/docs/apps/image-measurement/image-enhance/README.md index ad8cbfa2e..3749abb81 100644 --- a/docs/apps/image-measurement/image-enhance/README.md +++ b/docs/apps/image-measurement/image-enhance/README.md @@ -1,5 +1,8 @@ # Image Enhance +Every action and input-selection button provides hover help describing its +pixel-processing history, white-balance ROI, or batch export effect. + Image Enhance builds an ordered, reversible processing history for one image or a batch and exports the resulting images with the exact step sequence. @@ -87,7 +90,7 @@ imwrite(output{1}, "enhanced.png"); Saved projects keep portable source references, shared and per-image step histories, white-reference ROIs, export settings, and compact result metadata. -Decoded full-size pixels and downsampled previews remain transient. Runtime V2 +Decoded full-size pixels and downsampled previews remain transient. App SDK runtime resolves source references first; `createSession.m` then rebuilds only the selected source and its preview when a project opens. @@ -116,7 +119,7 @@ The single `definition.m` owns product metadata, requirements, layout, actions, presentation, renderer, and debug-sample capability. `projectSpec.m` is the only durable-project entry; the version-1 payload needs creation and validation but no migration. Root `createSession.m` rebuilds the selected image cache after -Runtime V2 resolves sources. +App SDK runtime resolves sources. The project validator requires the image-source collection and checks export, shared-history, and per-image annotation relationships; Runtime validates @@ -127,7 +130,7 @@ active histories, pipeline replay, and preview-coordinate scaling live with `+analysisRun`; durable per-image histories live with `+enhancementAnnotations`; export fingerprints live with `+resultFiles`. There is no generic `+appState` package. The App requires -`labkit.ui >=7 <8` and `labkit.image >=2 <3`; persistence, source-path access, +`labkit.app >=1 <2` and `labkit.image >=2 <3`; persistence, source-path access, busy state, and managed ROI interaction remain framework-owned. Batch manifest outputs use the framework's canonical empty output array, so export validation applies only to real enhanced-image records. @@ -137,4 +140,5 @@ and preview-cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +every control and plot names its concrete callback or renderer, and the +definition validates those bindings before creating a figure. diff --git a/docs/apps/image-measurement/image-match/README.md b/docs/apps/image-measurement/image-match/README.md index 936907ad4..88d47ea3c 100644 --- a/docs/apps/image-measurement/image-match/README.md +++ b/docs/apps/image-measurement/image-match/README.md @@ -1,5 +1,8 @@ # Image Match +Every action and input-selection button provides hover help describing its +reference distribution, match history, source pixels, or batch export effect. + Image Match transfers tone and color statistics from one reference image to one or more source images while preserving each source image's geometry. @@ -105,7 +108,7 @@ The single `definition.m` owns product metadata, requirements, layout, actions, presentation, renderers, and debug-sample capability. `projectSpec.m` is the only durable-project entry; the version-1 project needs creation and validation but no migration. Root `createSession.m` reconstructs only the selected source, -reference, and preview caches after Runtime V2 resolves sources. +reference, and preview caches after App SDK runtime resolves sources. The project validator requires the image-source collection and checks matching parameters and durable steps; Runtime validates canonical buckets and each @@ -115,7 +118,7 @@ Source item records live in `+sourceFiles`, matching steps in `+analysisRun`, and deterministic export tasks in `+resultFiles`; there is no generic `+appState` package. A new empty project performs no App-specific startup callback and chooses its output directory after source selection or explicit -user choice. The App requires `labkit.ui >=7 <8` and `labkit.image >=2 <3`; +user choice. The App requires `labkit.app >=1 <2` and `labkit.image >=2 <3`; source-path access, persistence, busy state, and debug lifecycle remain framework-owned. @@ -127,4 +130,5 @@ and matched-preview cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +every control and plot names its concrete callback or renderer, and the +definition validates those bindings before creating a figure. diff --git a/docs/apps/image-measurement/video-marker/README.md b/docs/apps/image-measurement/video-marker/README.md index 3e3549a3c..a6e2793b6 100644 --- a/docs/apps/image-measurement/video-marker/README.md +++ b/docs/apps/image-measurement/video-marker/README.md @@ -1,12 +1,15 @@ # Video Marker +Every action and input-selection button provides hover help describing its +landmark/skeleton state, frame annotation, calibration, or coordinate export. + Video Marker defines an ordered landmark skeleton, records coordinates across video frames, predicts forward positions between manual anchors, and saves a -portable project with autosave and recovery. +portable project with an explicit source-adjacent autosave copy. ## Requirements And Launch -The app declares compatibility with LabKit UI 7.x and uses the image functions +The app declares compatibility with `labkit.app` 1.x and uses image functions shipped with the same workbench. Video decoding uses MATLAB's available video support. Predictive navigation is implemented in repository-owned MATLAB code; no model weights or third-party runtime package are downloaded. @@ -70,29 +73,27 @@ conversion. Raw pixel coordinates remain available in the project. ## Autosave, Recovery, And Portability -Changes to the skeleton or annotations are atomically saved to `Video Marker -Autosaves` beside the source video. Autosave and explicit project MAT files use -the same project data. They store frame count, frame rate, duration, image -dimensions, skeleton edges, annotation status/source, and calibration alongside -the durable coordinates. The portable source record stores the video path -relative to the MAT file, the original path, and same-folder filename fallbacks. -Downstream apps such as Gait Analysis therefore use the MAT document as their -scientific data source without reopening the original video. +**Save autosave** writes atomically to `Video Marker Autosaves` beside the +source video. It is an explicit action, not a background timer. Autosave and +named project MAT files contain the same project data: frame count, frame rate, +duration, image dimensions, skeleton edges, annotation status/source, +calibration, and durable coordinates. Portable source records let the runtime +resolve or relink the video while rebuilding the transient reader and decoded +frame cache. When a project tree moves between folders, users, or operating systems, the -relative reference is tried first. If no candidate exists, the app asks the -user to locate the video without discarding skeleton or annotations. Opening a -video with adjacent recovery data asks whether to restore it or start new. A -compatible old Video Marker project or autosave opens with an unsaved marker; -choosing the top-level **Save State** action atomically upgrades that same MAT -path to the current `labkitProject` format. +relative reference is tried first. If no candidate exists, the runtime asks +the user to locate the video without discarding skeleton or annotations. A +compatible old Video Marker project or autosave opens through **Open MAT** or +the top-level **Load State** action. Saving it writes the current +`labkitProject` envelope. ## Project And Session State `video_marker.projectSpec` is the single durable-project contract. It owns the current schema factory and validator, the version-1 payload upgrade, the former `videoMarkerProject` MAT-variable importer, and the lightweight current-frame -resume policy. Runtime V2 performs migration iteration, validates the imported +resume policy. The App SDK performs migration iteration, validates the imported payload, resolves required video sources, and only then calls `video_marker.createSession`. @@ -110,6 +111,18 @@ remain the authoritative scientific data. - coordinate CSV for analysis and plotting; - output manifests recording coordinate options and file roles. +CSV dialogs start in a source-adjacent `video_marker` output folder. Result +manifests are written beside the chosen CSV. + +## Debug Diagnostics + +Launch with `Diagnostics=labkit.app.diagnostic.Options(Level="verbose")` to +record the richer sanitized callback, checkpoint, count, project, dialog, +resource, and failure trace. Add `Sample="synthetic"` and an artifact folder +to run the App's `BuildDebugSample` contract, which creates a synthetic video, +a valid initial marking project, and declared marker/coordinate output targets +without including user filenames or laboratory data. + ## Use Without The GUI @@ -161,21 +174,25 @@ page documents the repository-owned prediction contract separately. ## Framework Compatibility -This App requires `labkit.ui >=7 <8`. Its single `definition.m` owns product -metadata, requirements, layout, actions, presentation, renderers, and optional -capabilities. `projectSpec.m` concentrates all durable creation, validation, -migration, legacy import, and resume hooks; root `createSession.m` rebuilds the +This App requires `labkit.app >=1 <2`. Its single `definition.m` creates one +immutable `labkit.app.Definition`; feature-owned layout nodes bind directly to +semantic callbacks, `workbench.present` returns a complete +`labkit.app.view.Snapshot`, and plot rendering stays with the video-preview +capability. `projectSpec.m` concentrates durable creation, validation, +migration, legacy import, and resume hooks; root `createSession.m` rebuilds transient video state. The project validator requires the video-source collection and checks metadata, coordinate parameters, skeleton, and frame-array relationships; Runtime validates canonical buckets and each source record first. -The App reads video locations only through `labkit.ui.runtime.sourcePaths`. -Legacy portable references are passed intact to `sourceRecord` for framework -validation and canonicalization; no App helper knows the Runtime's nested path -schema. Busy state, callback queues, resource cleanup, source relinking, -serialization envelopes, and migration iteration remain framework-owned. +Callbacks receive `labkit.app.CallbackContext` as an injected runtime port. +They resolve portable sources, register the document-scoped video reader/cache, +open dialogs, restore or create project documents, write result packages, and +record diagnostics through specifically named methods. No App helper reads a +runtime registry or nested source schema. Busy state, callback queues, +resource cleanup, source relinking, serialization envelopes, and migration +iteration remain framework-owned. Its session factory returns only App-specific frame selection, editing workflow, scale-bar view, and decoded video cache fields. Runtime supplies @@ -183,8 +200,9 @@ absent canonical buckets and owns workflow-log initialization. The skeleton preset selector is a direct session binding; choosing a label does not require an App callback until **Use preset** applies the selected -schema. The decoded-video cache is a session resource registered with Runtime -default cleanup rather than an App-owned empty cleanup hook. +schema. The decoded-video cache is a document-scoped runtime resource reused +across frame navigation and cleared by project replacement or runtime close. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +every control and plot names its concrete callback or renderer, and the +definition validates those bindings before creating a figure. diff --git a/docs/apps/labkit-core/figure-studio/README.md b/docs/apps/labkit-core/figure-studio/README.md index ef29ee368..874f07da4 100644 --- a/docs/apps/labkit-core/figure-studio/README.md +++ b/docs/apps/labkit-core/figure-studio/README.md @@ -1,5 +1,8 @@ # Figure Studio +Every action and input-selection button provides hover help describing its +FIG source, editable styling artifact, plotted data, or graphics export. + Figure Studio restyles MATLAB figures, exports presentation copies, and extracts supported visible graphics into a portable data package. It changes presentation properties, not the calculation that produced the plot. @@ -16,22 +19,20 @@ labkit_FigureStudio_app A LabKit plot can also send its current axes to Figure Studio through the plot context menu. That handoff embeds a serializable plot snapshot in the project. -## Initialization And Runtime Services +## Initialization And Runtime Context -`figure_studio.definition` declares an optional Runtime V2 `Start` capability +`figure_studio.definition` declares an optional App SDK `OnStart` capability named `figure_studio.initializeWorkbench`. Runtime calls it after the semantic -layout and preview axes exist but before startup readiness is released. This is -why axes handoff and resize-resource registration do not belong in -`createSession(project)`, which is deliberately GUI-free and receives no -runtime services. - -The initializer receives the canonical state, the startup event, and injected -services. `services.request` carries the optional axes handoff prepared by -`figure_studio.launchRequest`; `services.previews` resolves the managed preview -axes; `services.resources` registers cleanup-owned resize state; -`services.dialogs`, `services.workflow`, and `services.debug` provide -domain-neutral runtime behavior. Figure Studio does not construct these -services or control callback queueing, busy state, or readiness. +layout and first complete view exist but before startup readiness is released. +The entrypoint converts an optional axes handoff into the normal +`InitialProject` launch value. `createSession(project,callbackContext)` then +rebuilds only transient FIG data, and the initializer establishes the default +output folder and records restored-source status. + +The initializer receives canonical `applicationState` and the sealed +`labkit.app.CallbackContext`. It does not receive axes, a service bag, native +components, or a launch-request object. Fixed-canvas resize reflow, callback +queueing, busy state, and readiness remain runtime-owned. ## Load And Select Figures @@ -118,14 +119,15 @@ objects. ## Framework Compatibility -This App's `definition.m` owns its product metadata, `labkit.ui >=7 <8` +This App's `definition.m` owns its product metadata, `labkit.app >=1 <2` requirement, layout, and optional capabilities. `projectSpec.m` is the single durable-schema entry and keeps project creation and validation local; `createSession.m` separately rebuilds decoded FIG data because it is transient runtime state. The entrypoint only adapts the optional axes handoff and -delegates to Runtime V2. App code uses semantic actions, injected project -services, and the stable resolved-path accessor; busy-state and -portable-reference serialization mechanics remain framework-owned. +delegates to App SDK runtime. App code binds controls directly to semantic +callbacks and uses the sealed callback context for status, dialogs, project +operations, resources, and resolved paths; busy-state and portable-reference +serialization mechanics remain framework-owned. The project validator requires the figure-source collection and checks style and embedded-plot fields; Runtime validates canonical buckets and each source @@ -136,4 +138,5 @@ workflow, and decoded plot cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +every control and plot names its concrete callback or renderer, and the +definition validates those bindings before creating a figure. diff --git a/docs/apps/labkit-core/launcher/README.md b/docs/apps/labkit-core/launcher/README.md index cf8aa6919..e13f7d8f8 100644 --- a/docs/apps/labkit-core/launcher/README.md +++ b/docs/apps/labkit-core/launcher/README.md @@ -1,8 +1,8 @@ # LabKit Launcher The LabKit Launcher is the installed workbench entry point. It discovers apps, -prepares their MATLAB paths, checks requirements, starts normal or debug -sessions, manages installed versions, opens app documentation, and exposes +prepares their MATLAB paths, checks requirements, starts App SDK sessions, +manages installed versions, opens app documentation, and exposes source-checkout maintenance tools. It is intentionally self-contained so a single surviving `labkit_launcher.m` can repair an incomplete ZIP installation. @@ -20,8 +20,8 @@ tool availability, or the active maintenance operation. | Group | Action | Behavior | | --- | --- | --- | -| Run Apps | **Open Selected App** | Checks the selected app requirements, adds the app root, and launches normally. | -| Run Apps | **Open Debug** | Launches the same app with diagnostic tracing enabled. | +| Run Apps | **Open Selected App** | Checks the selected app requirements, adds the app root, and calls its App SDK entrypoint without retired runtime launch arguments. | +| Run Apps | **Open Debug** | Starts the same App through its typed SDK diagnostics contract, records verbose structured events under `artifacts/diagnostics/launcher/`, and loads the App-owned anonymous synthetic sample. | | Run Apps | **Refresh App List** | Repeats public and configured private-app discovery without restarting the launcher. | | Run Apps | **Documentation and History** | Opens the generated manual for the selected app. | | Versions and Install | **Latest** | Installs the current `main` branch archive. | @@ -38,6 +38,12 @@ Double-clicking an app row is equivalent to selecting it and opening it normally. The checkbox column controls package membership; ordinary launch selection does not change the checked set. +Debug sessions use a new isolated artifact folder on every launch. The folder +contains the runtime event stream, session manifest, synthetic sample manifest, +and any anonymous fixture files declared by the selected App. Normal launches +keep the SDK's bounded standard diagnostics in memory and do not create this +verbose artifact set. + ## Programmatic Calls The launcher exposes a small non-GUI surface: diff --git a/docs/apps/neurophysiology/nerve-response-analysis/README.md b/docs/apps/neurophysiology/nerve-response-analysis/README.md index 8d17d62f3..bc02ada92 100644 --- a/docs/apps/neurophysiology/nerve-response-analysis/README.md +++ b/docs/apps/neurophysiology/nerve-response-analysis/README.md @@ -1,5 +1,8 @@ # Nerve Response Analysis +Every action and input-selection button provides hover help describing its +protocol/filter input, nerve-response calculation, reset, or exported evidence. + Nerve Response Analysis reads the recording list prepared in RHS Preview, finds stimulation events, groups them into trains, and measures compound action potential responses in the assigned channels. @@ -51,7 +54,7 @@ preserves the current document. An absent optional protocol remains valid. For developers, `nerve_response_analysis.definition` is the complete product contract. `nerve_response_analysis.projectSpec` owns project creation, validation, and the version-1 upgrade in one file, while -`nerve_response_analysis.createSession` rebuilds transient state. Runtime V2 +`nerve_response_analysis.createSession` rebuilds transient state. App SDK runtime owns the migration loop and source-reference representation. ## What The Analysis Does @@ -151,9 +154,9 @@ schemas, units, partial-recording failure policy, and related APIs. ## Framework Compatibility -This App uses the Runtime V2 lifecycle and requires `labkit.ui >=7 <8` and +This App uses the App SDK runtime lifecycle and requires `labkit.app >=1 <2` and `labkit.rhs >=1.0 <2`. App code uses semantic actions, `sourcePaths`, and the -injected source-upsert service; migration iteration, busy state, and portable +sealed callback context; migration iteration, busy state, and portable reference serialization remain framework-private. The project validator requires the source collection and retains the @@ -165,4 +168,5 @@ decoded analysis cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +every control and plot names its concrete callback or renderer, and the +definition validates those bindings before creating a figure. diff --git a/docs/apps/neurophysiology/response-review-stats/README.md b/docs/apps/neurophysiology/response-review-stats/README.md index 5326fb9e4..adccc7323 100644 --- a/docs/apps/neurophysiology/response-review-stats/README.md +++ b/docs/apps/neurophysiology/response-review-stats/README.md @@ -1,5 +1,8 @@ # Response Review And Stats +Every action and input-selection button provides hover help describing its +response metrics, statistical refresh, reset, or export effect. + Response Review and Stats opens a Nerve Response Analysis result or a segment table, displays the measurements, and exports a clean CSV for review or downstream statistics. Segment tables can also be aligned and measured again @@ -130,13 +133,13 @@ behavior, output table columns, failures, examples, and related APIs. ## Framework Compatibility -This App requires `labkit.ui >=7 <8`. Its single `definition.m` owns product +This App requires `labkit.app >=1 <2`. Its single `definition.m` owns product metadata, requirements, layout, actions, presentation, renderer, and debug capability. `projectSpec.m` concentrates durable creation, validation, and migration; root `createSession.m` rebuilds transient analysis tables. -Source paths are read through `labkit.ui.runtime.sourcePaths`; no App code -inspects portable-reference fields. Callback queues, busy state, migration +Source paths are read through `CallbackContext.resolveSourcePaths`; no App +code inspects portable-reference fields. Callback queues, busy state, migration iteration, source relinking, serialization, and resource lifetime remain framework-owned. @@ -149,4 +152,5 @@ decoded metrics cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +every control and plot names its concrete callback or renderer, and the +definition validates those bindings before creating a figure. diff --git a/docs/apps/neurophysiology/rhs-preview/README.md b/docs/apps/neurophysiology/rhs-preview/README.md index df9f0bcd8..ad03d0735 100644 --- a/docs/apps/neurophysiology/rhs-preview/README.md +++ b/docs/apps/neurophysiology/rhs-preview/README.md @@ -1,5 +1,8 @@ # RHS Preview +Every action and input-selection button provides hover help describing its +RHS/protocol input, waveform window, response ROI, filter, or JSON output. + RHS Preview lets you inspect an Intan RHS recording without loading the entire waveform into memory. Use it to check channels, move through short waveform windows, choose the channels to plot, and prepare protocol or file-filter JSON @@ -69,7 +72,7 @@ The durable project stores portable references for one preview recording, one optional protocol, and an ordered collection of filter recordings. It also stores preview settings, channel-role drafts, manual filter labels/comments, and compact export records. The App owns the `recording`, `protocol`, and -`filterRecording` roles; Runtime V2 owns each reference's portable path data. +`filterRecording` roles; App SDK runtime owns each reference's portable path data. Header indices, decoded preview windows, table presentation state, current ROI and window position, status text, and log messages are transient session data. @@ -78,11 +81,12 @@ They are reconstructed from the project sources when a project is opened. For developers, `rhs_preview.definition` is the complete product contract. `rhs_preview.projectSpec` owns project creation, validation, and the version-1 upgrade; `rhs_preview.createSession` rebuilds transient state. Fixed recording -and protocol sources use the injected upsert service. The variable filter -collection uses the injected reconcile service so existing source IDs remain -stable when files are added, removed, or rediscovered. The App-local +and protocol sources, plus the variable filter collection, are updated as +ordinary App-owned portable source values. Framework file-list bindings +preserve stable source IDs while files are added, removed, or rediscovered. The App-local `rhs_preview.sourceFiles.pathsForRole` function selects its role ordering and -delegates portable-reference decoding to `labkit.ui.runtime.sourcePaths`. +delegates portable-reference decoding to the sealed +`CallbackContext.resolveSourcePaths` operation. ## Review Recording Information @@ -129,9 +133,9 @@ interpret the waveform matrix. ## Framework Compatibility -This App uses the Runtime V2 lifecycle and requires `labkit.ui >=7 <8` and +This App uses the App SDK runtime lifecycle and requires `labkit.app >=1 <2` and `labkit.rhs >=1.0 <2`. App code uses semantic actions, managed interval -interaction, `sourcePaths`, and injected upsert/reconcile services; migration +interaction, direct layout callbacks, and `resolveSourcePaths`; migration iteration, busy state, and portable-reference serialization remain framework-private. @@ -144,4 +148,5 @@ indexed preview cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +every control and plot names its concrete callback or renderer, and the +definition validates those bindings before creating a figure. diff --git a/docs/apps/statistics/ttest-wizard/README.md b/docs/apps/statistics/ttest-wizard/README.md index 1b16843cb..e731cea28 100644 --- a/docs/apps/statistics/ttest-wizard/README.md +++ b/docs/apps/statistics/ttest-wizard/README.md @@ -1,5 +1,8 @@ # T-Test Wizard +Every action and input-selection button provides hover help describing its +group assignment, hypothesis test, alpha decision, or portable export. + T-Test Wizard captures two or more numeric groups, compares every group after the first with the first group, and draws one publication-oriented mean and standard-deviation plot. The first group is always the reference group. @@ -16,7 +19,13 @@ labkit_TTestWizard_app The App requires Base MATLAB and the LabKit App Framework. It does not require Statistics and Machine Learning Toolbox. -Current App version: **1.0.1**. +Current App version: **1.1.0**. + +The App uses `labkit.app` 1.x. Its definition names the project schema, +transient session factory, semantic workbench, derived view, and plot +renderer. Runtime-injected callback context and table payload types are +declared in MATLAB `arguments` blocks; the App does not receive a component +registry or an untyped service bag. ## Workflow diff --git a/docs/apps/wearable/ecg-print/README.md b/docs/apps/wearable/ecg-print/README.md index 509c76bf9..7d7c15dab 100644 --- a/docs/apps/wearable/ecg-print/README.md +++ b/docs/apps/wearable/ecg-print/README.md @@ -1,5 +1,8 @@ # ECG Print +Every action and input-selection button provides hover help describing its +ECG import, ROI processing, beat/template analysis, SNR, or export effect. + ECG Print reads a wearable recording, filters one channel, detects beats, builds event-centered segments and a representative template, and reports signal quality over time. It can export the segment measurements and a @@ -52,8 +55,8 @@ saved projects portable and avoids duplicating large waveform caches. For developers, `ecg_print.definition` is the complete product contract. `ecg_print.projectSpec` keeps project creation, validation, and the version-1 upgrade in one file; `ecg_print.createSession` reconstructs transient state. -Runtime V2 performs the version loop and calls the migration entry once for -each older payload version. +The App SDK runtime performs the version loop and calls the migration entry +once for each older payload version. ## Analyze ECG @@ -70,6 +73,14 @@ each older payload version. The filter is applied to the full selected channel before the time region is cropped. This reduces boundary artifacts at the region edges. +The **Files + Analysis** tab keeps the workflow in five ordered sections: +**Recording**, **Import Parsing**, **Channel + ROI**, +**Signal Processing + SNR**, and **Exports**. Bounded numeric settings use +paired spinner-and-slider controls. **Summary + Results** contains the +analysis summary and file-header preview, while **Log** records the current +session workflow. The **ECG Preview** workspace keeps four vertically stacked +time-series axes available on every tab. + ## Analysis Parameters | Parameter | Default | Meaning | @@ -143,18 +154,25 @@ segments, template, and measurements. For a more customized pipeline, call ## Framework Compatibility -This App uses the Runtime V2 lifecycle and requires `labkit.ui >=7 <8` and -`labkit.biosignal >=1.0 <2`. App code uses semantic actions, `sourcePaths`, and -injected project services; migration iteration, busy state, and portable -reference serialization remain framework-private. +This App uses `labkit.app.Definition`, semantic `labkit.app.layout.*` +controls, complete `labkit.app.view.Snapshot` presentation, typed events, and +the injected `labkit.app.CallbackContext`. It requires `labkit.app >=1 <2` +and `labkit.biosignal >=1.0 <2`. The runtime owns launch, busy state, +portable-source serialization, project migration, result-manifest +provenance, log presentation, and native component lifecycle. The project validator requires the recording source collection and checks import, filter, detection, and result fields; Runtime validates canonical buckets and each source record first. -Its session factory returns only App-specific import workflow and decoded -signal cache fields. Runtime supplies absent canonical buckets and owns -workflow-log initialization. - -The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): -every referenced action must be registered and resolves during layout construction. +Its session factory resolves the portable recording through +`CallbackContext`, then returns only App-specific import workflow and decoded +signal cache fields. A failed project/session reconstruction reaches the +runtime's atomic failure boundary. A failed user-requested +**Parse / refresh file** operation keeps the source and header preview +available so import settings can be corrected in place. + +Layout controls bind directly to ECG capability callbacks and the four-axis +plot area binds directly to its renderer; there is no App-authored action or +renderer registry. See the +[App SDK runtime contract](../../../framework/guides/runtime.md). diff --git a/docs/development/build-apps/app-development.md b/docs/development/build-apps/app-development.md index 8364b9baa..87ab25cb2 100644 --- a/docs/development/build-apps/app-development.md +++ b/docs/development/build-apps/app-development.md @@ -15,16 +15,16 @@ it actually uses: ```text apps///labkit__app.m apps///+/definition.m -apps///+/+userInterface/buildWorkbenchLayout.m +apps///+/+workbench/buildLayout.m ``` -`runtime.define` supplies an empty version-1 project, empty session, empty -action registry, and empty presenter model when those components are omitted. -Add `definitionActions.m` for interactions, a presenter for dynamic views, +`labkit.app.Definition` supplies empty project/session and default presentation +behavior when optional components are omitted. Bind real business callbacks +directly from the layout. Add `+workbench/present.m` for derived visible state, `createSession.m` for transient decoded/cache state, and `projectSpec.m` only when the App owns durable data. That single project file contains local create, -validate, and migrate functions. Its migrate callback exists only after a saved -project schema has actually changed; Runtime owns the version loop. +validate, and migrate functions. Its migrate callback exists only after a +saved project schema has actually changed; Runtime owns the version loop. Runtime and App architecture names remain versionless. Put facade/App compatibility in the existing version and requirement metadata, and put saved @@ -44,11 +44,12 @@ example `+sourceFiles`, `+analysisRun`, `+resultFiles`, `+cropGeometry`, or ## Define The Runtime Contract -`definition.m` returns a plain struct created by -`labkit.ui.runtime.define`. It is the App's single product contract and names -the public command, stable ID, display metadata, App version, compatible -LabKit facades, and layout builder. Project schema, session factory, action -registry, presenter, renderers, and startup event are opt-in capabilities. +`definition.m` returns one immutable `labkit.app.Definition`. It is the App's +single product contract and names the public command, stable ID, display +metadata, App version, compatible LabKit facades, and workbench. Project +schema, session factory, presenter, post-layout start callback, and debug +sample are opt-in capabilities. Callbacks and renderers are owned directly by +their layout nodes. The complete field tables, callback signatures, canonical project/session buckets, presenter shape, and renderer contract are documented in @@ -64,19 +65,34 @@ The framework owns: - managed interactions and resources - debug tracing and result manifests -The app owns durable `state.project`, transient `state.session`, workflow -handlers, presentation models, and scientific behavior. +The app owns durable `state.project`, transient `state.session`, semantic +callbacks, presentation models, and scientific behavior. ## Build The Workbench -`+userInterface/buildWorkbenchLayout.m` returns a data-only -`labkit.ui.layout.*` tree. Keep tab, section, and workspace builders in the -same order users see them. It must not create graphics handles, read files, -run calculations, mutate state, or schedule startup work. - -`+userInterface/presentWorkbench.m` is the pure bridge from canonical state to -semantic control properties, prepared plot models, and managed interaction -specs. Renderers draw prepared models and should not own workflow decisions. +`+workbench/buildLayout.m` returns a data-only `labkit.app.layout.*` tree. +Keep tabs, sections, and workspace pages in the same order users see them. +For a complex App, compose capability-owned layout fragments instead of +flattening every control into this file. It must not create graphics handles, +read files, run calculations, mutate state, or schedule startup work. + +`+workbench/present.m` is the pure assembly bridge from canonical state to a +complete `labkit.app.view.Snapshot`. Compose feature-owned fragments with +`Snapshot.include`. Renderers live with the capability they draw, receive only +axes and a prepared model, and do not own workflow decisions. + +Use the complete runtime value only at this assembly bridge and at callbacks +referenced directly by layout signals. Name it `applicationState`, unpack +`project` and `session` at the top, and call feature presenters with the exact +values they display. A feature presenter should look like +`present(results, selection, displayOptions)`, not `present(state)`. + +A direct callback is the transaction adapter. Its signature is +`(applicationState, callbackContext)` for a button or +`(applicationState, typedEventValue, callbackContext)` for a value-bearing +signal. It may perform short, visible state mutation in workflow order, then +delegate calculations and writes through explicit inputs. Do not pass the +complete state or callback context into a generic second action layer. ## Name Workflow Code diff --git a/docs/development/build-apps/architecture.md b/docs/development/build-apps/architecture.md index 066daafcc..5627e4f39 100644 --- a/docs/development/build-apps/architecture.md +++ b/docs/development/build-apps/architecture.md @@ -107,7 +107,7 @@ Direct syntax, options, outputs, and artifact behavior are documented in | --- | --- | | App entry point | Public launch name delegated to one runtime definition, including lightweight requirements/version/debug requests. | | App package | App definition, workflow state, command handlers, presenters, calculations, summaries, exports, and app-local helpers. | -| `labkit.ui` | Declarative app runtime, app shell, readiness/busy state, data-only workbench layouts, semantic view updates, reusable tools, and diagnostics. | +| `labkit.app` | App definition and launch, semantic layout and view snapshots, typed events, callback capabilities, projects, diagnostics, results, interactions, and private native runtime lifecycle. | | `labkit.image` | GUI-free image file IO, display normalization, resizing, mean filtering, and basic enhancement primitives. | | `labkit.thermal` | GUI-free thermal source-file parsing, raw thermal matrices, embedded calibration metadata, raw-to-temperature conversion, and thermal colormap rendering. | | `labkit.dta` | GUI-free Gamry DTA discovery, loading, parsed curves, and pulse helpers. | @@ -126,18 +126,19 @@ its app folder: ```text apps///labkit__app.m apps///+/definition.m -apps///+/+userInterface/buildWorkbenchLayout.m +apps///+/+workbench/buildLayout.m ``` Those three files are a complete static App. Add only the capabilities the product needs: ```text -definitionActions.m semantic commands or bound events projectSpec.m durable create/validate/migrate contract createSession.m transient App-specific reconstruction -+userInterface/presentWorkbench.m dynamic semantic view models -+userInterface/.m drawing for a registered preview model ++workbench/present.m dynamic snapshot assembly ++/layoutSection.m feature-owned controls ++/present.m feature-owned snapshot fragment ++/draw.m feature-owned plot renderer ``` `definition.m` is the single product contract. It owns identity, display @@ -161,8 +162,8 @@ Add workflow packages only when the App has that user-facing capability: Create only the packages the app needs. Names should describe a workflow or domain capability that changes together, not a broad technical phase. Avoid -generic `+actions`, `+renderers`, `+ops`, `+io`, `+export`, `+helpers`, and -`+utils` packages for new app code. Avoid fixed `+app` namespaces, +generic `+actions`, `+renderers`, `+ops`, `+io`, `+ui`, `+userInterface`, +`+view`, `+export`, `+helpers`, and `+utils` packages for new app code. Avoid fixed `+app` namespaces, family-level `private/` helpers, `*Workflow.m` string dispatchers, and `+core/dispatch.m` routers. @@ -170,16 +171,17 @@ family-level `private/` helpers, `*Workflow.m` string dispatchers, and were retired with the workflow-first migration. Current app work should follow the workflow-first shape. -## UI Boundary +## App SDK Boundary -App GUIs use the layered UI foundation: +App GUIs use the explicit `labkit.app` SDK: | Layer | App-facing API | | --- | --- | -| Runtime | `labkit.ui.runtime.launch`, `define`, `emptySourceRecords`, `sourceRecord`, `sourcePaths`, `saveState`, `loadState`, portable source references, and source-adjacent output defaults; the runtime privately owns request dispatch, queueing, resources, presentation, interactions, recovery, diagnostics, and result manifests. | -| Layout | `labkit.ui.layout.workbench`, `workspace`, `tab`, `section`, `group`, `field`, `rangeField`, `panner`, `action`, `filePanel`, `previewArea`, `resultTable`, `logPanel`, `statusPanel` | -| Plot | Advanced renderer helpers: `clear`, `fit`, `fitCanvas`, `offsetData`, `clampData`, `message` | -| Interaction | GUI-free `anchorPath`, `scaleBarCalibration`, `scaleBarGeometry`, plus `enablePopout`; editor/runtime objects are private. | +| Definition | `labkit.app.Definition` owns identity, requirements, optional project/session boundaries, workbench, presentation, and launch. | +| Layout | `labkit.app.layout.*` owns semantic controls, containers, workspace pages, direct callbacks, bindings, and plot renderers. | +| View | `labkit.app.view.Snapshot` owns complete derived visible state and prepared renderer models. | +| Callback boundary | Typed `labkit.app.event.*` values and sealed, specifically named `labkit.app.CallbackContext` operations. | +| Optional contracts | `labkit.app.project.*`, `result.*`, and `dialog.*`. | Reusable facades publish MATLAB-native contract versions through their `version()` APIs. Apps declare required facade ranges in the `Requirements` @@ -209,20 +211,46 @@ placement, overlay-removal workflow wording, measurements, and user-facing decisions. Generic image IO and filters stay in `labkit.image`; thermal file parsing and raw-to-temperature mechanics stay in `labkit.thermal`. -`definition.m` returns the app runtime contract. It names the project schema, -optional session factory, data-only layout builder, handler registry, -presenter, renderers, and optional `Start`. The framework runtime validates -the definition, generates semantic callbacks, builds the shell, owns the event -queue and readiness/busy state, routes diagnostics, and -protects hidden test behavior. - -`+userInterface/buildWorkbenchLayout.m` returns a data-only `labkit.ui.layout.*` -tree. It should not create MATLAB UI handles, mutate app state, perform IO, -run calculations, write exports, schedule startup, or set row/column layout -mechanics. App command handlers own app-specific state changes, alerts, refresh -decisions, and log wording. `+userInterface/presentWorkbench.m` is the pure -bridge from canonical state to semantic control properties, prepared preview -models, and controlled interaction specs. +`definition.m` returns the app runtime contract. Layout nodes own concrete +callbacks and renderers, so Apps maintain no parallel registries. The +framework compiles the static graph, builds the shell, owns the transactional +event queue, persistence, resources, diagnostics, and private native adapter. + +`+workbench/buildLayout.m` should read as the product's user workflow. +Capability packages own their controls, state transitions, prepared view +models, rendering, alerts, and wording. `+workbench/present.m` extracts exact +state inputs and composes those feature fragments; it is not a second +monolithic implementation of the App. + +### The state funnel + +The runtime commits one canonical value containing `project` and `session`, +but that value is an SDK transaction envelope, not the App's domain model. +Keep it at the edge: + +```text +layout signal + -> capability action(applicationState, event, callbackContext) + -> calculation(exact scientific inputs) + -> writer(exact result and destination inputs) + +runtime presentation + -> workbench.present(applicationState) + -> capability.present(exact visible inputs) + -> renderer(axes, prepared model) +``` + +Only `createSession`, `workbench.present`, `OnStart`, and a function bound +directly to a layout signal accept the complete envelope. Those functions +name it `applicationState`, expose the typed event and `callbackContext` in +their signatures, and unpack it immediately. Feature presenters, renderers, +calculations, writers, and local helpers receive named narrow inputs. + +Do not introduce a second App object, service bag, callback registry, or +feature-wide context type to hide these inputs. If a calculation needs five +scientific values, list those five values. If several always travel together +and form a stable app-owned concept, give that concept a semantic struct and +validate it at its owner. ## Reusable Extraction Rule diff --git a/docs/development/build-apps/complete-app.md b/docs/development/build-apps/complete-app.md index d869d97e8..2db5534bf 100644 --- a/docs/development/build-apps/complete-app.md +++ b/docs/development/build-apps/complete-app.md @@ -1,386 +1,248 @@ # Build A Complete App -[App development](app-development.md) | [Runtime field reference](../../framework/guides/runtime.md#definition-component-contract) | [Testing](../maintain-and-release/testing.md) +This guide builds a small trace viewer with the production `labkit.app` +contract. The example keeps durable settings in a project, rebuilds transient +file data in a session, draws a plot, and exports a result. Each file has one +visible responsibility. -This tutorial builds one current Runtime V2 App without copying historical -lifecycle adapters. The example opens one numeric trace, applies a gain, -previews the result, and keeps a summary in project state. - -The important rule is progressive capability: start with three files and add -state or workflow files only when the product actually needs them. - -## The Three-File Starting Point - -A static App needs only: - -```text -apps/example/trace_viewer/ -|-- labkit_TraceViewer_app.m -`-- +trace_viewer/ - |-- definition.m - `-- +userInterface/ - `-- buildWorkbenchLayout.m -``` - -`labkit.ui.runtime.define` supplies an empty version-1 project, empty session, -empty action registry, empty presenter, and no Start callback. A static layout -therefore launches without `projectSpec.m`, `createSession.m`, -`definitionActions.m`, or `presentWorkbench.m`. - -The trace example is interactive and persistent, so its completed tree is: +## File Shape ```text -apps/example/trace_viewer/ +apps/examples/trace_viewer/ |-- labkit_TraceViewer_app.m `-- +trace_viewer/ |-- definition.m |-- projectSpec.m |-- createSession.m - |-- definitionActions.m - |-- +sourceFiles/ - | `-- readTrace.m - |-- +analysisRun/ - | `-- applyGain.m - `-- +userInterface/ - |-- buildWorkbenchLayout.m - |-- presentWorkbench.m - `-- renderTrace.m + |-- +workbench/ + | |-- buildLayout.m + | `-- present.m + |-- +sourceTrace/ + | |-- readTrace.m + | |-- layoutSection.m + | |-- workspacePlot.m + | |-- present.m + | `-- draw.m + `-- +resultFiles/ + |-- layoutSection.m + `-- exportTrace.m ``` -There is no separate `requirements.m`, `version.m`, `+appLifecycle`, -`+appState`, or migration-file collection. Product metadata and optional -capabilities live in `definition.m`; durable schema callbacks are local -functions inside one `projectSpec.m`. - -## 1. Add The Thin Entrypoint +There is no handler table, renderer registry, or `+userInterface` bucket. +`+workbench` is the product assembly boundary. Capability packages own their +part of the user workflow. -`labkit_TraceViewer_app.m` delegates every request to the definition: +## 1. Thin Entrypoint ```matlab function varargout = labkit_TraceViewer_app(varargin) -%LABKIT_TRACEVIEWER_APP Inspect a scaled numeric trace. - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @trace_viewer.definition, varargin{:}); +[varargout{1:nargout}] = trace_viewer.definition().launch(varargin{:}); end ``` -The entrypoint does not add paths, create figures, parse files, or route -callbacks. The same command handles normal launch, `debug`, `requirements`, -and `version` requests through Runtime V2. - -## 2. Declare One Product Contract +The entrypoint does not build state, controls, services, or figures. -`+trace_viewer/definition.m` owns product metadata, facade compatibility, and -the capabilities this App actually uses: +## 2. One App Definition ```matlab -function def = definition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_TraceViewer_app", ... - "Id", "trace_viewer", ... - "Title", "Trace Viewer", ... - "Family", "Example", ... - "AppVersion", "1.0.0", ... - "Updated", "2026-07-16", ... - "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ... - "Project", trace_viewer.projectSpec(), ... - "CreateSession", @trace_viewer.createSession, ... - "Layout", @trace_viewer.userInterface.buildWorkbenchLayout, ... - "Actions", trace_viewer.definitionActions(), ... - "Present", @trace_viewer.userInterface.presentWorkbench, ... - "Renderers", struct( ... - "trace", @trace_viewer.userInterface.renderTrace)); +function app = definition() +app = labkit.app.Definition( ... + Entrypoint="labkit_TraceViewer_app", ... + AppId="examples.trace-viewer", ... + Title="Trace Viewer", ... + Family="Examples", ... + AppVersion="1.0.0", ... + Updated="2026-07-19", ... + Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + ProjectSchema=trace_viewer.projectSpec(), ... + CreateSession=@trace_viewer.createSession, ... + Workbench=trace_viewer.workbench.buildLayout(), ... + PresentWorkbench=@trace_viewer.workbench.present); end ``` -`Id` is the permanent persistence identity, not a display label. Do not change -it after project files exist. `AppVersion` versions the product; project -payload `Version` below versions only the durable data schema. - -For the three-file static form, omit `Project`, `CreateSession`, `Actions`, -`Present`, and `Renderers` from this call. - -## 3. Own Durable Data In One Project Spec +Definition is a readable inventory, not an execution script. It validates the +static layout, direct callback signatures, plot renderers, target IDs, project +schema, and presentation contract before native UI mutation. -`+trace_viewer/projectSpec.m` contains the public project-spec entry and local -schema functions: +## 3. Durable Project ```matlab -function spec = projectSpec() - spec = struct( ... - "Version", 1, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", []); +function schema = projectSpec() +schema = labkit.app.project.Schema( ... + Version=1, ... + Create=@createProject, ... + Validate=@validateProject); end function project = createProject() - project = struct(); - project.inputs = struct( ... - "sources", labkit.ui.runtime.emptySourceRecords()); - project.parameters = struct("gain", 1); - project.annotations = struct(); - project.results = struct("summary", table()); - project.extensions = struct(); +project = struct( ... + "inputs", struct("sources", struct([])), ... + "parameters", struct("gain", 1), ... + "results", struct("lastExport", [])); end function accepted = validateProject(project) - gain = project.parameters.gain; - assert(isnumeric(gain) && isscalar(gain) && isfinite(gain), ... - "trace_viewer:InvalidProject", ... - "Trace Viewer gain must be one finite numeric scalar."); - assert(istable(project.results.summary), ... - "trace_viewer:InvalidProject", ... - "Trace Viewer summary must be a table."); - accepted = true; +accepted = isstruct(project) && isscalar(project) && ... + isfield(project, "inputs") && isfield(project.inputs, "sources") && ... + isfield(project, "parameters") && ... + isnumeric(project.parameters.gain) && ... + isscalar(project.parameters.gain) && ... + isfinite(project.parameters.gain); end ``` -Runtime validates the scalar project, its five canonical buckets, and standard -source-record structure before this callback. The App callback therefore -checks only Trace Viewer fields and invariants: gain and summary in this -example. Runtime validates the complete value after creation, load, migration, -and every action transaction. A validator checks; it does not repeat -framework checks, repair, prompt, or access graphics. +The project contains only durable App meaning. External files are portable +source records owned by the runtime, not raw paths. -When the schema later advances to version 2, change `Version` and provide one -local version-aware function: +## 4. Transient Session ```matlab -function project = migrateProject(project, fromVersion) - switch fromVersion - case 1 - project.annotations.note = ""; - otherwise - error("trace_viewer:UnsupportedProjectVersion", ... - "Cannot migrate payload version %d.", fromVersion); - end +function session = createSession(project, callbackContext) +paths = callbackContext.resolveSourcePaths(project.inputs.sources); +trace = struct("x", [], "y", []); +if ~isempty(paths) + trace = trace_viewer.sourceTrace.readTrace(paths(1)); +end +session = struct("trace", trace); end ``` -Set `"Migrate", @migrateProject`. Runtime calls it once per missing version and -validates every returned payload. Do not create `migrateProjectV1ToV2.m`, -`migrateProjectV2ToV3.m`, and similar files. - -## 4. Rebuild Only App-Specific Session Data +`createSession(project,callbackContext)` is the one reconstruction boundary. +The runtime calls it after project restore and file-list source changes. +Session data is reconstructible and is not written into the project envelope. -`+trace_viewer/createSession.m` reconstructs data that should not be saved: +## 5. Product Layout ```matlab -function session = createSession(project) - sourcePath = labkit.ui.runtime.sourcePaths( ... - project.inputs.sources, "trace"); - rawTrace = zeros(0, 1); - if strlength(sourcePath) > 0 - rawTrace = trace_viewer.sourceFiles.readTrace(sourcePath); - end - [scaledTrace, ~] = trace_viewer.analysisRun.applyGain( ... - rawTrace, project.parameters.gain); - session = struct("cache", struct( ... - "sourcePath", sourcePath, ... - "rawTrace", rawTrace, ... - "scaledTrace", scaledTrace)); +function layout = buildLayout() +controls = { ... + trace_viewer.sourceTrace.layoutSection(), ... + trace_viewer.resultFiles.layoutSection()}; +workspace = labkit.app.layout.workspace( ... + trace_viewer.sourceTrace.workspacePlot()); +layout = labkit.app.layout.workbench( ... + controls, Workspace=workspace); end ``` -Runtime supplies missing `selection`, `workflow`, `view`, and `cache` buckets. -Return only App-specific fields. During project load, required portable sources -are resolved or interactively relinked before this function runs. - -Graphics handles, readers, listeners, timers, and cleanup callbacks do not -belong in project or session values. Register them with Runtime resources. +The assembly reads in user order. A complex App can use tabs and workspace +pages here without flattening every feature into one file. -## 5. Implement GUI-Free Workflow Functions - -`+trace_viewer/+sourceFiles/readTrace.m` owns the accepted input: +The source capability owns its controls: ```matlab -function trace = readTrace(filepath) - filepath = string(filepath); - if ~isscalar(filepath) || strlength(filepath) == 0 || ~isfile(filepath) - error("trace_viewer:SourceNotFound", ... - "The selected trace file was not found."); - end - value = readmatrix(filepath); - if ~isnumeric(value) || isempty(value) - error("trace_viewer:InvalidSource", ... - "The trace file must contain numeric values."); - end - trace = double(value(:, 1)); +function section = layoutSection() +section = labkit.app.layout.section("sourceTrace", "Trace", { ... + labkit.app.layout.fileList("traceFiles", ... + Label="Trace file", ... + SelectionMode="single", ... + Bind="project.inputs.sources", ... + SourceRole="trace", ... + SourceIdPrefix="trace"), ... + labkit.app.layout.slider("gain", ... + Label="Gain", Limits=[0.1 10], ... + Bind="project.parameters.gain")}); end ``` -`+trace_viewer/+analysisRun/applyGain.m` owns the deterministic calculation: +Standard fields and file lists need no App callback. Their bindings update +canonical state transactionally. + +The result capability binds a real business action directly: ```matlab -function [scaled, summary] = applyGain(trace, gain) - trace = double(trace(:)); - gain = double(gain); - if ~isscalar(gain) || ~isfinite(gain) - error("trace_viewer:InvalidGain", ... - "Gain must be one finite scalar."); - end - scaled = trace .* gain; - summary = table(numel(scaled), mean(scaled, "omitnan"), ... - "VariableNames", {"sample_count", "mean_value"}); +function section = layoutSection() +section = labkit.app.layout.section("resultFiles", "Results", { ... + labkit.app.layout.button("exportTrace", ... + "Export trace", @trace_viewer.resultFiles.exportTrace, ... + Tooltip="Export the calibrated trace and its sampling metadata.")}); end ``` -Both functions are directly testable without launching the GUI. - -## 6. Register Semantic Actions - -`+trace_viewer/definitionActions.m` maps layout event IDs to transactions: +## 6. Complete View Snapshot ```matlab -function actions = definitionActions() - actions = struct( ... - "openTrace", @onOpenTrace, ... - "gainChanged", @onGainChanged, ... - "runAnalysis", @onRunAnalysis); -end - -function state = onOpenTrace(state, ~, services) - [filepath, cancelled] = services.dialogs.inputFile( ... - "*.txt;*.csv", "Open numeric trace", ... - services.dialogs.defaultFolder("input")); - if cancelled - return; - end - rawTrace = trace_viewer.sourceFiles.readTrace(filepath); - state.project.inputs.sources = services.project.upsertSource( ... - state.project.inputs.sources, ... - "trace", "trace", filepath, true); - state.session.cache.sourcePath = filepath; - state.session.cache.rawTrace = rawTrace; - state = recompute(state); - state = services.workflow.log(state, "Opened trace: " + filepath); -end - -function state = onGainChanged(state, ~, ~) - state = recompute(state); +function view = present(applicationState) +view = labkit.app.view.Snapshot(); +view = view.include(trace_viewer.sourceTrace.present( ... + applicationState.session.trace, ... + applicationState.project.parameters.gain)); +view = view.enabled("exportTrace", ... + ~isempty(applicationState.session.trace.x)); end +``` -function state = onRunAnalysis(state, ~, services) - state = recompute(state); - state = services.workflow.log(state, "Updated trace summary."); -end +`+workbench/present.m` is a short assembly boundary. It extracts exact inputs +and composes feature fragments with `Snapshot.include`; it does not perform IO +or calculation. -function state = recompute(state) - [scaled, summary] = trace_viewer.analysisRun.applyGain( ... - state.session.cache.rawTrace, state.project.parameters.gain); - state.session.cache.scaledTrace = scaled; - state.project.results.summary = summary; +```matlab +function view = present(trace, gain) +model = struct("x", trace.x, "y", trace.y .* gain); +view = labkit.app.view.Snapshot().renderPlot("tracePlot", model); end ``` -The App owns what each command means. Runtime owns queueing, busy state, -rollback, validation, presentation commit, and error propagation. Use injected -services for dialogs, logging, source identity, results, diagnostics, previews, -and managed resources; do not read registries or UI controls. - -## 7. Build A Data-Only Layout - -`+trace_viewer/+userInterface/buildWorkbenchLayout.m` describes semantics: +## 7. Feature-Owned Renderer ```matlab -function layout = buildWorkbenchLayout() - commands = labkit.ui.layout.section("commands", "Commands", { ... - labkit.ui.layout.action("openTrace", "Open trace", "openTrace"), ... - labkit.ui.layout.field("gain", "Gain", ... - "kind", "number", ... - "value", 1, ... - "Bind", "project.parameters.gain", ... - "Event", "gainChanged"), ... - labkit.ui.layout.action( ... - "runAnalysis", "Run analysis", "runAnalysis")}); - tab = labkit.ui.layout.tab("main", "Trace", {commands}); - workspace = labkit.ui.layout.workspace("workspace", "Trace", { ... - labkit.ui.layout.previewArea("tracePlot", "Scaled trace"), ... - labkit.ui.layout.resultTable("summary", "Summary"), ... - labkit.ui.layout.logPanel("appLog", "Log")}); - layout = labkit.ui.layout.workbench( ... - "traceViewer", "Trace Viewer", ... - "controlTabs", {tab}, "workspace", workspace); +function node = workspacePlot() +node = labkit.app.layout.plotArea( ... + "tracePlot", @trace_viewer.sourceTrace.draw); end -``` -The layout does not create handles, read files, mutate state, choose pixel -geometry, or schedule callbacks. IDs are stable semantic references shared by -the layout, action registry, and presenter. +function draw(axesById, model) +ax = axesById.main; +cla(ax); +plot(ax, model.x, model.y); +xlabel(ax, "Time (s)"); +ylabel(ax, "Signal"); +grid(ax, "on"); +end +``` -## 8. Present One Committed View +The plot area references the concrete renderer. The model carries prepared +App meaning; drawing does not read project state or dispatch workflow actions. -`+trace_viewer/+userInterface/presentWorkbench.m` converts canonical state to -declarative view models: +## 8. Direct Business Callback ```matlab -function view = presentWorkbench(state) - trace = state.session.cache.scaledTrace; - view = struct(); - view.controls.summary = struct( ... - "Data", state.project.results.summary); - view.previews.tracePlot = struct( ... - "Renderer", "trace", ... - "Model", struct( ... - "x", (1:numel(trace)).', ... - "y", trace, ... - "hasData", ~isempty(trace))); +function applicationState = exportTrace( ... + applicationState, callbackContext) +trace = applicationState.session.trace; +gain = applicationState.project.parameters.gain; +choice = callbackContext.chooseOutputFile( ... + {"*.csv", "CSV files"}, ""); +if choice.Cancelled + return; end -``` -`+trace_viewer/+userInterface/renderTrace.m` owns drawing only: - -```matlab -function renderTrace(ax, model) - labkit.ui.plot.clear(ax, "ResetScale", true); - if ~model.hasData - labkit.ui.plot.message(ax, "Open a trace to begin."); - return; - end - plot(ax, model.x, model.y, "LineWidth", 1.2); - xlabel(ax, "Sample"); - ylabel(ax, "Scaled value"); - grid(ax, "on"); - labkit.ui.plot.fit(ax); +tableValue = table(trace.x(:), trace.y(:) .* gain, ... + VariableNames=["Time", "Signal"]); +writetable(tableValue, choice.Value); +applicationState.project.results.lastExport = string(choice.Value); end ``` -Presenters do not write files or mutate state. Renderers do not choose -scientific options or reset zoom for overlay-only edits. - -## 9. Validate The Product - -At minimum, add: - -- GUI-free tests for `readTrace` and `applyGain` -- definition tests for metadata, project creation, validation, and session - reconstruction -- one hidden GUI workflow covering launch, open, gain change, analysis, save, - clear, and reopen -- migration tests for every supported prior payload version -- documentation examples that run in a clean MATLAB session +The callback signature exposes its runtime boundary. For larger exports, +delegate table construction and result packaging to functions that accept +only the trace, gain, and destination they require. -During iteration, run the exact affected files. Use the project test planner -only at a coherent checkpoint; follow [Testing](../maintain-and-release/testing.md) for supported -commands and the distinction between hidden GUI coverage and manual native -dialog/interaction checks. +## Validation -## Capability Checklist +Test readers, calculations, project validation, snapshot fragments, renderers, +and exports directly with synthetic values. Construct `definition()` in a +headless test to validate the whole static contract. Run the App's bounded +hidden-GUI workflow after smaller tests are stable; native dialogs and visual +quality still require developer-led interactive validation. -Add a file only when the answer is yes: +Before merge, update the App version, owning manual, structured component +history, generated documentation site, and the final branch validation gates. -| Need | Add | -| --- | --- | -| Static product metadata and layout | `definition.m`, layout builder | -| User commands or bound events | `definitionActions.m` | -| Dynamic control/preview models | presenter and any renderers | -| Durable App-owned data | `projectSpec.m` | -| Rebuilt decoded/cache/selection state | `createSession.m` | -| Saved schema has changed | local `migrateProject` in `projectSpec.m` | -| Legacy top-level MAT variable is supported | local importer declared by `LegacyImports` | -| Post-layout request/resource initialization | a semantically named optional `Start` function | +## Related Documentation -This is the current Runtime V2 architecture. Older split metadata files, -generic lifecycle packages, and per-version migration files are retired, not -alternative supported styles. +- [App Development](app-development.md) +- [Architecture](architecture.md) +- [LabKit App SDK](../../framework/README.md) +- [Testing](../maintain-and-release/testing.md) diff --git a/docs/development/maintain-and-release/private-apps.md b/docs/development/maintain-and-release/private-apps.md index 23187d779..09f83fd90 100644 --- a/docs/development/maintain-and-release/private-apps.md +++ b/docs/development/maintain-and-release/private-apps.md @@ -32,11 +32,10 @@ LabKit-MATLAB-Workbench/ labkit__app.m +/ definition.m - +userInterface/buildWorkbenchLayout.m - definitionActions.m optional + +workbench/buildLayout.m projectSpec.m optional createSession.m optional - +userInterface/presentWorkbench.m optional + +workbench/present.m optional +sourceFiles/... as needed +analysisRun/... as needed +resultFiles/... as needed @@ -96,15 +95,15 @@ Private apps should follow the same app-owned package shape as public apps: - keep app workflow code under the owning `+/` package - use one `definition.m` for product identity, version, requirements, layout, and references to optional capabilities -- start with only the entrypoint, definition, and data-only layout -- add `definitionActions.m` only for semantic interactions and a pure - presenter only for dynamic views +- start with only the entrypoint, definition, and semantic workbench layout +- bind callbacks and plot renderers directly on their owning layout controls; + add `+workbench/present.m` only for dynamic visible state - add one `projectSpec.m` only for durable App-owned state; keep create, validate, and version-aware migrate functions local to that file - add root `createSession.m` only for App-specific transient reconstruction - group workflow code by concrete user capability, such as `+sourceFiles`, `+analysisRun`, `+resultFiles`, or another app-owned domain package -- use shared LabKit facades such as `labkit.ui.*`, `labkit.image.*`, +- use shared LabKit facades such as `labkit.app.*`, `labkit.image.*`, `labkit.thermal.*`, `labkit.dta.*`, `labkit.rhs.*`, and `labkit.biosignal.*` diff --git a/docs/development/maintain-and-release/testing.md b/docs/development/maintain-and-release/testing.md index 32eb534ff..0fb4f7403 100644 --- a/docs/development/maintain-and-release/testing.md +++ b/docs/development/maintain-and-release/testing.md @@ -493,22 +493,24 @@ artifacts/debug//// Coverage is report-only and not part of the default local check. -## Runtime V2 Contract Coverage +## App SDK Runtime Contract Coverage The workflow-first app contract is covered by layered tests, not by a single launch-only suite: - `AppPackageStructureGuardrailTest` discovers every `apps/**/labkit_*_app.m` entrypoint, requires the canonical `definition.m` and - `+userInterface/buildWorkbenchLayout.m`, verifies references to optional + `+workbench/buildLayout.m`, verifies references to optional action/project/session/presentation capabilities when present, and rejects retired metadata files, generic lifecycle/state packages, per-version migration files, package-root app runners, and broad app buckets such as `+actions`, `+state`, `+ui`, `+view`, `+ops`, `+io`, and `+export`. -- `GuiLayoutUiRuntimeV2Test` owns queued dispatch, canonical state, - presentation commits, service injection, rollback, and `Start` behavior. - `GuiLayoutUiRuntimeV2ProjectTest` owns project persistence, migrations, - source relinking, and read-only legacy snapshot imports. +- `UiRuntimeKernelTest` owns queued dispatch, canonical state, presentation + commits, callback-capability injection, rollback, and `OnStart` behavior. + `UiProjectDocumentStoreTest` owns project persistence, migrations, source + relinking, and read-only legacy snapshot imports. + `UiMatlabPlatformAdapterTest` owns the native component and interaction + adapter boundary. - `AppOwnedWorkflowBoundariesTest` and `AppLibraryCompatibilityTest` keep app workflow code under the owning app tree and prevent apps from depending on removed helper-dump or old UI surfaces. diff --git a/docs/framework/README.md b/docs/framework/README.md index d1e235786..b1a8d1b36 100644 --- a/docs/framework/README.md +++ b/docs/framework/README.md @@ -1,147 +1,132 @@ -# LabKit App Framework +# LabKit App SDK -`labkit.ui` is the application framework above the domain libraries and -concrete apps. It provides lifecycle management, semantic workbench layouts, -action dispatch, project save/load support, presentation updates, managed -interactions, debug traces, and top-level utilities. App packages supply the -workflow decisions, scientific calculations, data schemas, labels, and exports. +`labkit.app` is the MATLAB App SDK above LabKit's GUI-free domain libraries. +Apps own scientific workflow, calculations, units, wording, plots, and exports. +The SDK owns semantic layout, transactions, project documents, portable +sources, dialogs, resources, results, and native component lifecycle. ## Start Here | Goal | Documentation | | --- | --- | -| Understand how an App starts and processes actions | [Runtime and lifecycle](guides/runtime.md) | -| Declare and validate compatible LabKit modules | [Compatibility contracts](compatibility/contracts.md) | -| Build or refactor a concrete App | [App development](../development/build-apps/app-development.md) | -| Choose reusable package boundaries | [Architecture](../development/build-apps/architecture.md) | -| Look up exact MATLAB function syntax | [Public API reference](../reference/README.md) | +| Build or refactor an App | [Build a Complete App](../development/build-apps/complete-app.md) | +| Understand ownership boundaries | [Architecture](../development/build-apps/architecture.md) | +| Declare compatible LabKit modules | [Compatibility contracts](compatibility/contracts.md) | +| Look up exact MATLAB syntax | [Public API reference](../reference/README.md) | | Validate framework or GUI changes | [Testing](../development/maintain-and-release/testing.md) | -## Module Overview +## SDK Map -| Package | Responsibility | Typical entry points | -| --- | --- | --- | -| `labkit.ui.runtime` | Launch, lifecycle, queued actions, canonical state, persistence, dialogs, resources | `launch`, `define`, `saveState`, `loadState` | -| `labkit.ui.layout` | Data-only semantic workbench descriptions | `workbench`, `tab`, `section`, `filePanel`, `previewArea`, `action` | -| `labkit.ui.plot` | Viewport-safe plot and image mechanics | `clear`, `fit`, `fitCanvas`, `message`, `clampData` | -| `labkit.ui.interaction` | Managed axes interactions and reusable geometry | `enablePopout`, `anchorPath`, `scaleBarGeometry` | -| `labkit.ui.debug` | Structured trace and exception context | `context` | -| `labkit.contract` | Framework-wide facade requirements and compatibility checks | `requirements`, `checkRequirements`, `assertRequirements` | +The required path has three concepts: -`labkit.contract` is documented with the App Framework because definitions and -startup consume it, while its stable MATLAB namespace remains separate: it -checks compatibility for UI and domain facades rather than becoming a UI-only -dependency. - -Framework concepts and source names are versionless. Compatibility belongs to -`labkit.ui.version` and App requirements; saved-data versions belong to project -migration contracts. Do not add version-named runtimes, packages, functions, -types, tests, or manual sections when the framework contract changes. - -This package division is the supported public surface. Primitive MATLAB UI -handles, registry mutation, concrete grid geometry, event queues, renderer -internals, and resource cleanup implementations stay private. - -## Runtime Model +| API | Purpose | +| --- | --- | +| `labkit.app.Definition` | App identity, lifecycle callbacks, layout, and launch | +| `labkit.app.layout.*` | Semantic inputs, displays, containers, and workbench structure | +| `labkit.app.view.Snapshot` | Derived visible state and App-owned rendering | + +Read an App in this order: `definition.m` shows its complete contract, +`+workbench/buildLayout.m` shows the user workflow, and its capability +packages show the controls, state transitions, presentation, and rendering +owned by each feature. Open `projectSpec.m` or `createSession.m` only when the +definition names those optional capabilities. + +Layout controls bind directly to named App callbacks, and plot areas bind +directly to their renderer. Apps do not maintain handler, renderer, or +capability registries. Runtime-injected values are +`labkit.app.CallbackContext` and typed `labkit.app.event.*` payloads. +Callbacks name those boundary values explicitly and delegate domain work +through narrow inputs. + +Optional capabilities are grouped by purpose: + +| Package | Purpose | +| --- | --- | +| `labkit.app.event.*` | Typed callback payloads | +| `labkit.app.interaction.*` | Managed plot gestures with direct callbacks | +| `labkit.app.plot.*` | Domain-neutral axes redraw, message, fit, and annotation mechanics | +| `labkit.app.project.*` | Durable project schema and migration | +| `labkit.app.result.*` | Exported files and result packages | +| `labkit.app.dialog.*` | Explicit dialog outcomes | +| `labkit.app.CallbackContext` | Runtime operations available inside callbacks | -An App declares a definition and passes it to `labkit.ui.runtime.launch`: +## Smallest App ```matlab -function varargout = labkit_Example_app(varargin) - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @example.definition, varargin{:}); +function app = definition() + workbench = labkit.app.layout.workbench({ ... + labkit.app.layout.field("gain", Label="Gain", ... + Kind="numeric", Bind="project.parameters.gain"), ... + labkit.app.layout.button("run", "Run", @runAnalysis, ... + Tooltip="Compute the result from the current gain.")}); + app = labkit.app.Definition( ... + Entrypoint="labkit_Example_app", AppId="example", ... + Title="Example", Family="Examples", ... + AppVersion="1.0.0", Updated="2026-07-19", ... + Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + Workbench=workbench); end ``` -A static App package needs only that thin entrypoint, one `definition.m`, and -its semantic layout. The definition is the single product contract: it owns -the command, stable ID, names, family, App version, update date, LabKit -requirements, layout, and any optional capabilities. The runtime supplies an -empty version-1 project, empty session, no actions, and an empty presentation -model until the App opts into more behavior. - -The framework then validates the definition, creates canonical project and -session state, builds the semantic layout, generates callbacks, commits the -first presentation, and queues the optional start action. App handlers receive -semantic events plus injected services and return updated state. They do not -own busy flags, callback plumbing, persistence envelopes, or UI resource -lifetimes. Project loading resolves portable external sources before session -creation; when a required source moved, the framework identifies it and lets -the user locate a replacement without committing a partial project. Migrated -or relinked documents remain visibly unsaved until **Save State** atomically -writes the current project format. Each project, explicit autosave, and -recovery write recalculates source-relative paths from that MAT file's actual -destination, so moving a saved project tree does not depend on the folder from -which the source was first imported. - -An existing source that fails decoding is different from a missing path: -Runtime aborts the candidate session, reports its `inputs.sources` identities -and filenames to diagnostics, and preserves the currently open project and -presentation. Session factories do not turn decoder or programming exceptions -into empty caches. - -A control whose complete behavior is writing one state path uses `Bind` -without `Event`; Runtime commits it without requiring an empty App action. -Handlers register plain nonsemantic values through -`services.resources.set(scope,id,value)` and supply a fourth cleanup function -only when the resource needs custom disposal. - -An App `CreateSession` factory returns only its own transient selection, view, -workflow, or cache fields. The runtime adds the canonical session buckets and -initializes workflow logging; Apps do not repeat empty framework-owned -structures. An action that must discard the complete current document calls -`services.project.newState()`, which rebuilds both project and session through -the current definition and applies the same canonical normalization used at -launch. App code must not approximate a full reset by calling its session -factory directly. - -The same ownership applies to durable validation. Runtime verifies the five -canonical project buckets and any standard portable source records before it -calls the App's project validator. The App validator owns only its domain -fields, legal values, relationships, roles, and scientific invariants. - -Variable-length sources and manifest outputs start from framework-provided -empty arrays (`emptySourceRecords` and `services.results.emptyOutputs()`), -then append validated real records. Apps do not construct empty-ID placeholder -records merely to copy their struct shape. App code creates current source -records through `labkit.ui.runtime.sourceRecord`, passes an existing legacy -portable reference back to the same factory as one opaque value, and reads -current locations through `labkit.ui.runtime.sourcePaths` rather than depending -on the runtime-owned portable-reference fields. ID-based lookup preserves -requested order and returns an empty path for an optional semantic source slot -that has not been selected yet. - -A persistent App exposes one `projectSpec.m` entry containing its project -version plus local create, validate, and migrate functions. Runtime owns the -loop across missing versions and validates each returned payload; App packages -do not publish one migration file per historical step. - -These are the only supported source forms: `launch` receives one definition -factory, and a versioned project exposes one -`Migrate(project,fromVersion)` callback. Separate requirements/version launch -factories, migration callback collections, and layout-only tool hosts are not -part of the current UI contract. Declared legacy MAT imports and supported -older project payloads remain read-only data compatibility, not alternate App -source architectures. - -Read [Runtime and lifecycle](guides/runtime.md) for the detailed definition fields, -state transaction rules, startup/readiness behavior, plot and interaction -contracts, debug semantics, callback policy, and the responsibilities of the -framework and app. - -## Public API Documentation - -Exact syntax, inputs, outputs, defaults, legal values, examples, and related -functions are generated from the help block immediately following each public -function declaration. The documentation contract discovers every non-private -`labkit.ui` function, requires explicit failure behavior, verifies each -implemented option and default, and executes every `Example:` block. Open the -[API reference](../reference/README.md) and search for a fully qualified symbol -such as `labkit.ui.runtime.launch` or `labkit.ui.version`. +The entrypoint calls `definition().launch(...)`. Definition compiles the +immutable semantic graph before creating a figure, validates direct callback +and renderer signatures, and builds one private native platform plan. + +## Paved Road + +- Bind ordinary state with `Bind="project..."` or `Bind="session..."`. +- Use `labkit.app.layout.fileList` for portable file records and selection. + Set `AllowDuplicatePaths=true` only when separate workflow tasks may share + one resolved path, and present row-level workflow state with + `Snapshot.fileItemStatuses`. + Source changes rebuild the transient session; Apps do not mirror choose, + remove, clear, or selection UI events. +- Give every scientific or workflow action an App-owned `Tooltip`. The + framework guarantees a nonempty label-based fallback, while repository + guardrails require tracked Apps to explain the action instead of repeating + its visible label. File-list choose/folder/remove/clear controls expose + dedicated tooltip options and domain-neutral label defaults. +- Rebuild transient data with + `session = createSession(project,context)` and resolve opaque source records + with `context.resolveSourcePaths`. +- Return only derived view state from `labkit.app.view.Snapshot`; runtime + supplies layout defaults, bindings, file state, log text, and status text. +- Use `labkit.app.layout.dataTable` with + `labkit.app.event.TableCellEdit` and + `labkit.app.event.TableCellSelection`; Apps never decode native events. +- Use `labkit.app.layout.plotArea` and a fixed + `renderer(axesById,model)` callback. `axesById` is always a named struct, + even when the plot area declares only one axis. +- Pass one workspace node or a row cell array of vertically arranged nodes to + `workspace.page`; growable tables and plots share the available page height + without an App-authored wrapper section. +- Use `layout.group(..., Title="...")` for a nested reader-facing control + boundary inside a section; leave `Title` blank for arrangement-only groups. +- A control tab containing one growable file list, table, log, status, or + plot surface fills the available tab height. Tabs with longer mixed content + remain scrollable. +- Declare editable overlays with `labkit.app.interaction.*` on the plot area; + supply their current values with same-named Snapshot methods. +- Use `labkit.app.plot.clearAxes`, `showMessage`, and `fitAxesToGraphics` + for renderer mechanics; Apps still decide message wording and viewport + policy. +- Use `labkit.app.project.Schema`, `labkit.app.result.File`, and + `labkit.app.result.Package` only when those optional capabilities exist. +- Use `labkit.app.project.sourceRecord` only in pure project creation or + migration code that must turn a legacy path into a portable source value; + runtime callbacks still resolve paths through `CallbackContext`. + +Runtime validates candidate state and the complete view snapshot before +publishing either. The private MATLAB adapter maps semantic IDs to native +components, preserves plot viewports, normalizes native event differences, and +never exposes component registries to Apps. + +Framework concepts and source names are versionless. Compatibility belongs to +`labkit.app.version`; saved-data versions belong to +`labkit.app.project.Schema`. ## Related Topics - [App catalog](../apps/README.md) - [App development](../development/build-apps/app-development.md) -- [Architecture](../development/build-apps/architecture.md) - [Project history](../history/README.md) diff --git a/docs/framework/guides/runtime.md b/docs/framework/guides/runtime.md index ec180d078..00d988abe 100644 --- a/docs/framework/guides/runtime.md +++ b/docs/framework/guides/runtime.md @@ -1,832 +1,289 @@ # Runtime And Lifecycle -[Public API index](../../reference/README.md) | [App development](../../development/build-apps/app-development.md) +`labkit.app` is the App-facing runtime contract. Apps declare product meaning; +the private runtime owns native MATLAB components, event serialization, +transactions, project documents, portable sources, resources, and cleanup. -This page explains how a LabKit app is declared, launched, updated, saved, and -debugged. The current runtime manages queued events, project and session state, -presentation, resources, interactions, saved projects, and result manifests. -Old runtime snapshots can still be imported, but new apps use the current -lifecycle exclusively. +## Definition And Launch -`labkit.ui` is divided into these public packages: - -| Package | Owns | Main APIs | -| --- | --- | --- | -| `labkit.ui.runtime` | Launch, canonical state, queued events, services, projects, and resources. | `launch`, `define`, `emptySourceRecords`, `sourceRecord`, `sourcePaths`, `saveState`, `loadState`, `defaultOutputFolder`. | -| `labkit.ui.layout` | Data-only semantic workbench layouts. | `workbench`, `workspace`, `tab`, `section`, `group`, `field`, `rangeField`, `panner`, `action`, `filePanel`, `previewArea`, `resultTable`, `logPanel`, `statusPanel`. | -| `labkit.ui.plot` | Advanced renderer viewport and coordinate mechanics. | `clear`, `fit`, `fitCanvas`, `message`, `offsetData`, `clampData`. | -| `labkit.ui.interaction` | Managed-interaction calculation helpers and popout enablement. | `anchorPath`, `scaleBarCalibration`, `scaleBarGeometry`, `enablePopout`. | - -Apps call the package that provides the behavior they need. The earlier flat -`labkit.ui.*` helper surface is no longer supported, and implementation details -inside each package's `private/` folder are not public APIs. - -`labkit.ui.version()` returns the UI framework's version and compatibility -information for `labkit.contract` requirement checks. - -## Declarative App Runtime - -### Definition And Launch - -The UI surface makes app code read as a semantic description of a LabKit -workflow, not as grid construction or a general MATLAB GUI DSL. Apps expose an -app-owned `definition.m` and launch it through `labkit.ui.runtime.launch`. The -framework owns lifecycle, callback dispatch, readiness, busy state, -diagnostics, persistence, and resources. App packages declare durable project -data, transient session data, workflow handlers, presentation, and data-only -UI structure. - -Public launch files stay thin. They delegate requests and GUI creation to the -single app-owned definition: +Each App-owned `definition.m` is the single immutable product contract, and +each entrypoint delegates to it: ```matlab function varargout = labkit_Example_app(varargin) - [varargout{1:nargout}] = labkit.ui.runtime.launch( ... - @example.definition, varargin{:}); +[varargout{1:nargout}] = example.definition().launch(varargin{:}); end ``` ```matlab -function def = definition() -def = labkit.ui.runtime.define( ... - "Command", "labkit_Example_app", ... - "Id", "example", ... - "Title", "Example App", ... - "Family", "Examples", ... - "AppVersion", "1.0.0", ... - "Updated", "2026-07-16", ... - "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ... - "Layout", @example.userInterface.buildWorkbenchLayout); +function app = definition() +app = labkit.app.Definition( ... + Entrypoint="labkit_Example_app", ... + AppId="examples.example", ... + Title="Example", ... + Family="Examples", ... + AppVersion="1.0.0", ... + Updated="2026-07-19", ... + Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + Workbench=example.workbench.buildLayout(), ... + ProjectSchema=example.projectSpec(), ... + CreateSession=@example.createSession, ... + PresentWorkbench=@example.workbench.present); end ``` -`definition.m` is a small MATLAB-scale DSL made of structs and function -handles. It is not a new language, a generator, or a class hierarchy. The -framework validates the definition, creates canonical state, generates -callbacks, builds the workbench, commits the first presentation, and queues -the optional `Start` event. App-specific launch payloads are available -read-only through injected services. - -### Definition Component Contract - -`labkit.ui.runtime.define` accepts these components. Required means every app -must provide the field; optional components may be omitted. - -| Component | Required | Value and signature | Responsibility | -| --- | --- | --- | --- | -| `Command` | Yes | Public MATLAB function name | Stable launcher command and request-error identity. | -| `Id` | Yes | Stable nonempty text | Permanent project, recovery, result, and diagnostic identity. | -| `Title` | Yes | Nonempty text | Window title without the runtime-added version and file state. | -| `DisplayName` | No | Nonempty text | Short launcher name. Default: `Title`. | -| `Family` | Yes | Nonempty text | App family used for launcher grouping and documentation. | -| `AppVersion` | Yes | `X.Y.Z` text | App product version, independent of project payload version. | -| `Updated` | Yes | `YYYY-MM-DD` text | Date of the App product version. | -| `Requirements` | Yes | `labkit.contract.requirements(...)` | Compatible reusable LabKit facade ranges. | -| `Project` | No | Scalar project-declaration struct described below | Durable schema, validation, migration, and source relinking. Omission uses an empty version-1 project. | -| `CreateSession` | No | `session = createSession()` or `session = createSession(project)` | Rebuild transient selection, workflow, view, and cache state. | -| `Layout` | Yes | `layout()`, `layout(callbacks)`, or `layout(callbacks,state)` | Return one data-only `labkit.ui.layout.workbench` tree. | -| `Actions` | No | Struct mapping action IDs to `state = action(state,event,services)` | Apply semantic workflow transactions. Default: no actions. | -| `Present` | No | `view = present(state)` | Convert canonical state into semantic control, preview, and interaction properties. Default: an empty model. | -| `Renderers` | No | Struct mapping renderer IDs to `renderer()`, `renderer(model)`, or `renderer(ax,model)` | Draw a prepared presenter model without changing workflow state. | -| `Start` | No | Registered action ID or an action function | Queue optional initialization after the first visible presentation. | -| `DebugSample` | No | `pack = writer(debugContext)` | Create synthetic local debug inputs only during a debug launch. | -| `Utilities` | No | Scalar struct | Configure framework-owned plot, screenshot, and state commands. | - -#### Project Declaration And Durable Payload - -`Project` is not the saved payload itself. It is the declaration that tells -the runtime how to create, validate, migrate, and restore that payload: - -| Field | Required | Contract | +Required Definition arguments are product metadata, requirements, and one +`labkit.app.layout.workbench` value. Optional callbacks are: + +| Argument | Signature | Purpose | | --- | --- | --- | -| `Version` | Yes | Positive integer payload version. Version 1 has no migration entries. | -| `Create` | Yes | `project = createProject()` returns a complete new durable payload. | -| `Validate` | Yes | `accepted = validateProject(project)` checks the App-owned schema and returns a logical scalar or throws a field-specific error. Runtime first validates the canonical buckets and standard source records. | -| `Migrate` | For version > 1 | `project = migrate(project,fromVersion)` upgrades exactly one version. The runtime calls it repeatedly for every missing step. | -| `LegacyImports` | No | Struct mapping a trusted top-level MAT variable name to `project = import(value)` or `[project,resume] = import(value)`. Imports are read-only adapters. | -| `CreateResume` | No | `resume = createResume(session,project)` saves small navigation convenience, never authoritative data. | -| `ApplyResume` | No | `session = applyResume(session,resume,project)` applies that convenience after a fresh session is built. | -| `RelinkSources` | No | `project = relink(project,unresolved,projectFile)` handles a nonstandard source schema. Returning `[]` cancels load. | +| `CreateSession` | `session = callback(project,callbackContext)` | Rebuild transient App data from durable project state. | +| `PresentWorkbench` | `view = callback(applicationState)` | Return the App-owned fragment of the complete visible snapshot. | +| `OnStart` | `applicationState = callback(applicationState,callbackContext)` | Perform a real post-first-commit request or resource initialization. | +| `BuildDebugSample` | `sample = callback(callbackContext)` | Build clean-room debug input when the App supports it. | + +Ordinary default state needs no startup callback. Exact syntax and errors are +in the generated [public API reference](../../reference/README.md). -`createProject` returns one scalar struct with the five canonical buckets: +`launch("requirements")` and `launch("version")` answer metadata without +creating a figure. `launch()` constructs the private native adapter and shows +the App. + +## Static Workbench Contract + +`+workbench/buildLayout.m` returns a tree composed from +`labkit.app.layout.*` values. Layout IDs are stable semantic identifiers and +must be globally unique. ```matlab -function project = createProject() -project = struct(); -project.inputs = struct( ... - "sources", labkit.ui.runtime.emptySourceRecords()); -project.parameters = struct("threshold", 0.5); -project.annotations = struct(); -project.results = struct("analysis", table()); -project.extensions = struct(); +function layout = buildLayout() +controls = { ... + labkit.app.layout.section("parameters", "Parameters", { ... + labkit.app.layout.field("gain", ... + Label="Gain", Kind="numeric", ... + Bind="project.parameters.gain"), ... + labkit.app.layout.button("exportResult", ... + "Export", @example.resultFiles.exportResult, ... + Tooltip="Export the current analyzed result.")})}; +workspace = labkit.app.layout.workspace( ... + labkit.app.layout.plotArea( ... + "previewPlot", @example.previewPlot.draw)); +layout = labkit.app.layout.workbench( ... + controls, Workspace=workspace); end ``` -The bucket names are stable; their app-owned fields are not prescribed by the -framework. `inputs` stores portable source records and durable input metadata; -`parameters` stores reproducible analysis and export choices; `annotations` -stores user-authored scientific edits; `results` stores reproducible results -that should survive reopen; and `extensions` is reserved for explicit future -schema additions. Decoded images, open readers, graphics, listeners, timers, -dialogs, and service handles never belong in the payload. - -The runtime saves this payload inside one `labkitProject` envelope. The -envelope owns format and producer metadata, source portability, payload -version, and optional resume state; apps own only their payload fields and do -not construct the envelope themselves. - -For a project at version `N`, one `Migrate` callback owns every supported -step. When an older document is opened, the runtime calls it with the saved -version, validates that returned payload is serializable, advances one version, -and repeats until `N`. It then validates the complete current project, rebuilds -the session, and keeps the document visibly dirty until the user saves it. A -migration changes durable data only; it does not open dialogs, draw, dispatch -actions, or invent scientific values that cannot be derived from the saved -payload. - -For example, a current version-3 app declares: +Layout controls own direct callbacks. Plot areas own direct renderers. There +is no handler, renderer, command, or capability registry for Apps to maintain. +Definition compiles the immutable graph once and validates callback roles, +renderer roles, IDs, bindings, and view capabilities before UI mutation. -```matlab -project = struct( ... - "Version", 3, ... - "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrate", @migrateProject); -``` +Complex Apps keep the top-level workbench readable by composing +capability-owned `layoutSection`, `workspaceTable`, or `workspacePlot` +functions in user order. + +## State And Transactions -`projectSpec.m` normally owns the three local functions behind these handles. -`migrateProject(project,fromVersion)` uses `fromVersion` to select exactly one -step. A version-1 saved payload cannot already contain version-2 fields, so the -case for `fromVersion == 1` derives them before returning. New projects start -from `Create`, and current projects skip migration. - -Runtime verifies that `inputs`, `parameters`, `annotations`, `results`, and -`extensions` are scalar structs before it invokes `Validate`. When -`inputs.sources` exists, Runtime also verifies the complete portable -source-record contract. App validators therefore check only their own required -fields, legal values, cross-field relationships, source roles, and scientific -constraints. Repeating canonical bucket or source-record checks in every App -would give one framework contract two owners. - -#### Portable Source Records - -Apps create external-file references through the GUI-free -`labkit.ui.runtime.sourceRecord(id,role,sourceValue,required)` factory. The -source value is normally a filepath; a legacy importer may instead pass an -existing portable reference as one opaque struct so the Runtime can preserve -and validate it without exposing reference construction to the App. Action -handlers may use the equivalent filepath-based injected -`services.project.sourceRecord(id,role,filepath,required)` service. The pure -factory is available to project creation, migration, legacy import, tests, and -other code that does not run inside a callback. A durable record has stable -App-facing identity fields plus a runtime-owned portable reference: +Runtime state always has two App-owned buckets: ```matlab -source = labkit.ui.runtime.sourceRecord( ... - "trace", "numericTrace", selectedPath, true); -currentPath = labkit.ui.runtime.sourcePaths(source); +applicationState.project +applicationState.session ``` -Do not inspect or construct fields inside `source.reference`. A legacy importer -that already received such a reference passes the whole struct back to -`sourceRecord`; all other App code reads resolved paths through `sourcePaths`. - -| Field | Meaning | -| --- | --- | -| `id` | App-stable identity for this source inside the project. IDs are unique within the app's source collection and are not file names. | -| `required` | Whether project load must resolve the file before committing the loaded state. | -| `role` | App-owned semantic role such as `numericTrace`, `referenceImage`, or `movingImage`. | -| `reference` | Runtime-owned portable location data. Apps preserve this value but do not read or construct its fields. | - -An App reads current file locations with -`labkit.ui.runtime.sourcePaths(sources)`. Supplying a source ID or ordered ID -collection returns those paths in requested order; a semantic source slot not -yet present returns an empty string. This pure accessor is valid inside -`CreateSession`, actions, presenters, and GUI-free workflow functions, so App -code does not depend on the portable-reference schema. - -Immediately before each write, Runtime copies the durable project and -rebases its portable references from that write's actual MAT-file destination. -It preserves additive reference data and does not mutate the live project -merely to serialize it. - -For unordered file collections, the injected -`services.project.reconcileSources(existing,paths,role,idPrefix,required)` -service preserves existing IDs by resolved path and assigns stable nonempty -IDs to newly selected files. An empty selection returns the canonical empty -source array; Apps do not create a placeholder record with an empty ID. - -Result manifests follow the same rule. Start a variable-length output list -with `services.results.emptyOutputs()`, then append records returned by -`services.results.output(...)`. The output factory validates real IDs and is -not a struct-template constructor. - -The runtime first tries the saved relative and original locations. When a -required source cannot be resolved, it identifies the source by app ID, role, -and filename and lets the user locate a replacement. Cancelling leaves the -currently open app state unchanged. Apps read the resolved location through -`labkit.ui.runtime.sourcePaths`; they do not parse the portable reference or -implement their own repeated file-dialog loop. - -#### Session, Actions, Presentation, And Renderers - -`CreateSession` returns one scalar struct containing only App-specific -transient fields. Runtime adds any missing canonical buckets: +`project` is durable, validated meaning. `session` is transient and +reconstructible. A callback receives the previous complete state and returns a +candidate complete state. The runtime: + +1. serializes the event; +2. invokes the direct callback; +3. validates project and session shape; +4. builds and validates the complete view snapshot; +5. reconciles native components; +6. publishes state and view together. + +Failure rolls back both state and presentation and clears event-scoped +resources. Apps do not implement busy flags, callback queues, readiness +timers, or figure close guards. + +Use direct `Bind="project...."` or `Bind="session...."` paths for ordinary +fields, ranges, sliders, file sources, and selection. Bound controls need no +callback or presenter operation unless the App has additional derived meaning. + +## Portable Sources And Session Reconstruction + +`labkit.app.layout.fileList` owns file/folder selection, portable source +records, removal, clearing, and optional selection binding: ```matlab -session = struct( ... - "selection", struct("currentIndex", 1), ... - "view", struct("mode", "Preview"), ... - "cache", struct("decodedInput", [])); +labkit.app.layout.fileList("sources", ... + Filters=["*.csv", "CSV files"], ... + Bind="project.inputs.sources", ... + SelectionBind="session.selection.sources", ... + SourceRole="measurement", ... + SourceIdPrefix="source") ``` -Do not return empty buckets or initialize `workflow.logLines`. The injected -workflow service creates that framework-owned log on first use. An action that -starts a completely new project calls `services.project.newState()` so project -creation, session reconstruction, and canonical normalization follow the same -Runtime path as initial launch. - -Project loading resolves missing required sources before `CreateSession`. -Cancelling that relink leaves the live document unchanged. Once a source path -exists, its decoder must either return the transient value or throw; a session -factory must not convert corrupt, unsupported, or programming failures into an -empty cache. Runtime reports the exception with the `inputs.sources` IDs, -roles, and filenames, preserves the previous state and presentation, and lets -the Load State command show the failure. An optional source may be absent only -when the App explicitly defines absence as valid; an existing optional file is -still decoded strictly. - -`selection` is current user focus, `workflow` is transient progress and log -state, `view` is presentation convenience, and `cache` is rebuildable decoded -or calculated data. Session values are never the sole owner of scientific -inputs, parameters, annotations, or results. - -Each action receives the complete `state.project` and `state.session`, one -normalized event, and injected services. It returns the complete updated -state. An action may coordinate app-owned reader or calculation functions, but -does not read controls or mutate graphics. The runtime validates and commits -the returned state atomically. - -`Present(state)` returns a scalar view struct whose optional roots are -`controls`, `previews`, and `interactions`. Control fields are addressed by -layout ID and contain semantic properties such as `Value`, `Enabled`, `Items`, -`Limits`, `Data`, `Files`, or `Status`. A single-axis preview may specify -`Renderer` and `Model` directly; a multi-axis preview uses -`previews..Axes.`. Interaction entries declare managed -interaction kinds, targets, values, and semantic event IDs. - -A registered renderer receives only its prepared model and, when requested, -the declared target axes. It may clear and draw that axes, but it does not -change project/session state, open files, display workflow dialogs, or install -figure callbacks. Renderer IDs in presentation must exactly match fields in -`Renderers`; action and interaction event IDs must exactly match fields in -`Actions`. - -Static Apps may omit `Project`, `CreateSession`, `Actions`, and `Present`. -Apps add one `projectSpec.m` only when they own durable schema or imports, root -`createSession.m` only for App-specific transient reconstruction, and -`definitionActions.m` plus presenter functions only when they own semantic -interactions or dynamic views. Version-aware migration is one local function -behind `Project.Migrate`; Runtime owns the ordered version loop. App-specific -work belongs in concrete workflow packages such as `+sourceFiles`, -`+analysisRun`, `+resultFiles`, or a domain-specific package. Separate -`requirements.m`, `version.m`, generic `+appLifecycle`/`+appState` packages, -per-version migration files, and the older `+state`, `+actions`, `+ui`, and -`+view` adapters are retired. +The App does not mirror those UI lifecycle actions with callbacks. Runtime +updates the bound source records and invokes `CreateSession` after source +changes: ```matlab -function layout = buildWorkbenchLayout(callbacks) -layout = labkit.ui.layout.workbench("exampleApp", "Example App", ... - "controlTabs", controlTabs(callbacks), ... - "workspace", previewWorkspace(callbacks), ... - "usage", {"Load input data.", "Run analysis.", "Review/export results."}); +function session = createSession(project, callbackContext) +paths = callbackContext.resolveSourcePaths(project.inputs.sources); +session = struct("measurements", example.sourceFiles.read(paths)); end +``` -function tabs = controlTabs(callbacks) - tabs = {setupTab(callbacks), reviewTab(), logTab()}; -end +Portable source records are opaque. Resolve their paths only at IO boundaries. +Saved projects store portable references and use runtime relinking. -function tab = setupTab(callbacks) - tab = labkit.ui.layout.tab("setup", "Setup", { ... - labkit.ui.layout.section("actions", "Actions", { ... - labkit.ui.layout.action("run", "Run", callbacks.run, ... - "priority", "primary"), ... - labkit.ui.layout.action("reset", "Reset", callbacks.reset)})}); -end +## Typed Events -function tab = reviewTab() - tab = labkit.ui.layout.tab("review", "Review", { ... - labkit.ui.layout.section("results", "Results", { ... - labkit.ui.layout.resultTable("resultsTable", "Results", ... - "columns", {"Name", "Status"})})}); -end +Callbacks are attached only where an App owns real behavior: + +- button: `state = callback(state,callbackContext)` +- field, range, slider, workspace page, or interaction change: + `state = callback(state,value,callbackContext)` +- table edit: + `state = callback(state,labkit.app.event.TableCellEdit,callbackContext)` +- table selection: + `state = callback(state,labkit.app.event.TableCellSelection,callbackContext)` -function tab = logTab() - tab = labkit.ui.layout.tab("log", "Log", { ... - labkit.ui.layout.section("logSection", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log")})}); +Name the boundary values explicitly and delegate domain work through narrow +inputs: + +```matlab +function applicationState = replaceGroupValue( ... + applicationState, cellEdit, callbackContext) +arguments + applicationState (1,1) struct + cellEdit (1,1) labkit.app.event.TableCellEdit + callbackContext (1,1) labkit.app.CallbackContext end -function workspace = previewWorkspace(callbacks) - workspace = labkit.ui.layout.workspace("workspace", "Preview", { ... - labkit.ui.layout.previewArea("preview", "Preview", ... - "layout", "single", ... - "viewModes", {"Input", "Output"}, ... - "onModeChange", callbacks.previewModeChanged)}); +groups = applicationState.project.groups; +applicationState.project.groups = ... + example.groupData.replaceValue(groups, ... + cellEdit.Row, cellEdit.Column, cellEdit.NewValue); end ``` -`buildWorkbenchLayout.m` stays data-only. It receives framework-generated -semantic callbacks and returns a workbench layout. It does not create MATLAB -handles, run IO, compute data, mutate app state, schedule startup work, or set -concrete layout geometry. - -### Layout And Action Rules - -Use these app-facing rules: - -- The default shell is a LabKit workbench: control tabs on the left and primary - preview, plot, waveform, image, or canvas content on the right. -- `definition.m` declares app identity, project schema, optional session - factory, workbench layout, handler registry, presenter, prepared-data - renderers, and optional `Start` event. -- The framework runtime owns lifecycle scheduling, readiness/loading surface, - generated callbacks, busy gating, debug exception plumbing, close guards, and - hidden/minimized test behavior. -- `buildWorkbenchLayout.m` describes controls and workspace structure only. App - command handlers own app-specific state changes, alerts, refresh decisions, - and log wording. -- Control ids are globally unique within an app. The UI registry is keyed by - those ids, not by tab or section placement. -- Public layouts are semantic controls such as `filePanel`, `field`, - `panner`, `action`, `previewArea`, `resultTable`, `logPanel`, and `statusPanel`. - Primitive MATLAB controls are implementation details. -- `section` layout nodes contain real semantic controls; do not leave empty - titled sections as placeholders. Axes tools are declared through the - presenter's managed `interactions` model rather than mounted into a - layout-host control. -- Callback values placed in the layout are framework-generated adapters; apps - do not implement control-handle callbacks. App handlers are the functions in - `definitionActions.m`, are named by user intent, and use - `state = handler(state,event,services)`. The normalized event carries - `id`, `source`, `target`, `value`, and `meta`; injected services decode file - entries and indices without exposing the UI registry. -- Reference a generated callback directly as `callbacks.actionId`. Do not use - an App-owned lookup that substitutes `[]` for an unknown action. A missing - or misspelled action ID is a definition error and must fail while the layout - is built, before the user receives a control that silently does nothing. - -### Identity Contracts - -Runtime ids are developer-owned semantic names, not generated object handles. -Name an object once at its declaration and reuse that id when referring to it; -the runtime validates the resulting graph before it mutates the visible UI. - -| Identity | Scope and contract | Compatibility meaning | -| --- | --- | --- | -| App `Id` | Starts with an ASCII letter and contains only letters, digits, `_`, `-`, or `.`. Public app ids are unique across the app catalog. | Permanent after a project or recovery file has been written. Renaming it creates a different app identity. | -| Layout node id | Nonempty MATLAB field name, globally unique in one workbench tree. | Used to bind presenter controls and UI callbacks. | -| Preview axis id | Nonempty MATLAB field name, unique within its preview area and in the runtime axes registry. | Used by presenter axes models and `services.previews.axes`. | -| Action id | MATLAB struct field in `definitionActions`; every layout or interaction event reference must name a registered action. | Renaming requires updating every declaration that emits the event. | -| Renderer id | MATLAB struct field in `Renderers`; every presented renderer reference must name a registered renderer. | App-owned presentation contract. | -| Durable source id | Nonempty text, unique in `project.inputs.sources`. | Stable key for reconciliation and portable external-file references. | -| Result output id | Nonempty text, unique within one result manifest. | Stable machine-readable name for one exported artifact. | -| Resource id | Nonempty text paired with an event, interaction, or figure scope. | Calling `set` again with the same scope and id intentionally disposes and replaces the previous resource; use different ids for resources that coexist. | - -Invalid declarations fail during definition, layout preparation, state -validation, or presentation preflight instead of silently overwriting a -registry entry. Recovery folders use a reversible encoded app id so distinct -legal ids cannot normalize to the same path. Discovery also checks the earlier -MATLAB-normalized folder name, allowing existing recovery files to remain -readable while all new writes use the collision-free key. - -### File Selection And Dialogs - -- `filePanel` owns file input mechanics: file chooser defaults, optional - recursive folder scans, duplicate filename display, current selection, and - file-entry events. Each file entry exposes `id`, `index`, `path`, `name`, - `displayName`, and `status`. Callback events expose file entries through - `event.files`, `event.addedFiles`, `event.removedFiles`, - `event.selectedFiles`, and `event.value` for the current selection. Apps - consume paths and indices through `services.events`; apps do not parse - control ids, UI registries, or adapter structs. -- The active `filePanel` selection is also a framework title context. When a - file is selected through the panel or a presentation commit, the - app window title and preview axes titles include `file N/M: name.ext`; when - selection is cleared, the framework removes that suffix. Apps should keep - preview titles focused on the view or measurement being shown, not duplicate - the selected filename in app-local title strings. -- Multi-file `filePanel` mode exposes direct commands for adding one or more - files from one folder, - every supported file directly under one folder, or every supported file - recursively below a folder, plus Remove selected and Clear. Each command - opens the native file or folder navigator immediately; there is no - file-versus-folder question. The native file dialog supports ordinary - same-folder multi-selection; separate folder commands cover directory-wide - imports. Recursive folder scans count matching files first and ask - for confirmation only when the count exceeds the panel warning threshold. - File labels show sequence numbers plus - short filenames, adding the nearest unique parent directory when repeated - filenames would otherwise collide. Apps that allow duplicate source files - should remove by file `id` or `index` from `event.removedFiles` instead of - deleting by path. Programmatic file replacement uses - presenter-owned `Value` and `Selection` properties. Apps with their own - nearby status field can pass `showStatus=false` to omit the panel's internal - count/status text. -- Single-file `filePanel` mode opens one file directly, replaces the previous - file on the next choose action, does not offer recursive folder selection, - and displays the short filename in one read-only text field instead of a - selectable list. -- File and folder selection in handlers goes through `services.dialogs`, which - owns safe remembered defaults outside the LabKit install root. -- App output defaults should be source-adjacent when a source file or folder is - known. Use `labkit.ui.runtime.defaultOutputFolder(sourcePaths, subfolderName)`; - the helper uses the first source path when multiple files are loaded and - creates the app-specific subfolder before returning it. -- App-owned alerts use `services.dialogs.alert(message, title)`. Apps still own - the wording and decision; the runtime owns modal and hidden-test mechanics. - -### Saved Projects And Result Utilities - -- `labkit.ui.runtime.saveState/loadState` retain one stable public signature. - Apps write a versioned `labkitProject` envelope with only durable project - buckets, ordered app payload migrations, producer - provenance, optional resume data, and additive extension preservation. - Loads inventory the MAT file before loading a recognized variable, - migrate and validate off to the side, resolve required sources, create a - fresh session, invalidate the previous document's presentation cache, and - then replace live state and repaint every preview once. This repaint also - occurs when the loaded project has the same semantic values as the current - document, because ephemeral graphics are not durable state. Saves use - temporary-file readback plus atomic replacement. Project edits drive the dirty title, - debounced bounded recovery, and unsaved-close wording; recovery never owns - or overwrites an explicit project path. An app action may call - `services.project.saveAutosave(state)` to update that same recovery copy - immediately. Apps with a stable product-owned recovery location may call - `saveAutosave(state,filepath)` instead. Both forms avoid a file dialog and - leave the named project path and dirty status unchanged. Declared old - snapshots and legacy - app variables are import-only and are never written again. -- Apps whose saved state refers to external source files store canonical - source records created by `services.project.sourceRecord`. The runtime - derives their portable relative references at each actual save destination. - On load it checks the path relative to the loaded project first, then the - original absolute path, then the saved filename beside the project. Relative - references use `/` inside - MAT payloads, so a project/source directory tree can move between cloud-drive - roots and operating systems. If a required source remains unresolved, the - runtime identifies its saved filename and role, asks whether to locate it, - and opens one file chooser for that source. A selected file receives a new - reference relative to the loaded project before session creation. The - repaired document opens as unsaved work so the new location can be retained; - cancelling leaves the current project and view unchanged. Apps may declare - `Project.RelinkSources` only when their source schema needs behavior beyond - this standard source-record flow. Projects migrated from an older payload, - snapshot, or declared legacy variable also open as unsaved work. **Save - State** reuses the opened path and atomically replaces it with the current - `labkitProject` format; opening alone never rewrites the source MAT file. - -### Workbench Utilities And Preview Axes - -- The workbench shell includes a native `Plot` menu plus top-level - `Screenshot`, `Save State`, and `Load State` entries. Plot commands operate - on every registered preview axes - in the app, so multi-axes workspaces do not require users to repeat the same - command per view. Apps can use - `define(..., "Utilities", struct(...))` to hide the bar or disable groups of - commands without adding app-local shell buttons. Utility commands operate on - framework-owned runtime or visible preview axes; scientific result exports - remain app-owned. -- `previewArea` belongs in `workspace` by default. Its optional `viewModes` - selector is workspace-owned, and apps can react through `onModeChange`. -- `previewArea` axes install LabKit-managed, pointer-gated mouse-wheel zoom by - default. Scrolling over controls, logs, or empty figure space does not zoom - plots, and users should not need to click a preview before wheel zoom works. - Time-series axes with a time x-label zoom the horizontal time axis only. - Apps can set per-axis `scrollZoomAxes` values of `xy`, `x`, or `y` when a - secondary fixed-width scale or histogram axis should receive wheel events - without changing its horizontal span. - Preview axes are also registered with the workbench utility menus, whose plot - commands act on all registered visible axes. -- `labkit.ui.interaction.enablePopout(ax)` installs the standard axes context - menu action, which copies the visible axes into a - standalone MATLAB figure with optional copied-figure text buttons for font - size, plotted data line width, axes line width, grid visibility, and a Studio - handoff. Popout edits operate on the copied figure only. Plot axes remain - freely resizable, while image axes preserve locked data aspect ratio. - Data-package export and generated reconstruction scripts belong in the - Figure Studio workflow rather than the lightweight popout window; app-owned - scientific CSV/MAT result exports still belong in the app package. - -### Parameter Controls And Layout Sizing - -- Parameter value controls (`field`, `rangeField`, and `panner`) debounce - semantic `onChange` callbacks by default so short bursts of edits submit - only the latest value after roughly 0.5 seconds of idle time. Explicit - actions, file selection, and table edits run immediately. Apps should put - expensive recompute work behind those semantic callbacks or explicit action - buttons rather than binding work to lower-level MATLAB value-changing events. -- `panner` is the preferred app layout control for bounded numeric parameters. - It renders as a compact numeric spinner plus a linked slider when limits are - finite, and as spinner-only when limits are intentionally unbounded. Slider - drags update the numeric readout continuously and submit semantic changes - through the same debounced callback queue as spinner edits; release submits - the final committed value. -- Text-heavy controls have conservative automatic heights owned by the - framework. App layout nodes must not set concrete height, row-count, spacing, - padding, chrome, row-height, or column-width properties. -- Text-bearing controls default to complete display over single-line - compactness. The framework enables wrapping where MATLAB supports it, - shrinks long labels within a small readability range, gives text-heavy rows - extra height, and keeps the full text available as a tooltip when supported. - Apps should shorten wording when it improves workflow clarity, but should - not add app-local layout or font-size patches to prevent clipping. -- Section height is automatic: the builder estimates height from child control - types and framework spacing defaults. Apps declare only the page, section, - and control order. -- `group` composes related semantic controls inside a section and replaces the - older action-only grouping concept. Apps should use `group(id, "", {...})` - for inline tool rows and `group(id, title, {...})` for titled subgroups. - With `layout="auto"`, a group whose children are all `action` layout nodes uses the - action layout; mixed controls use a compact form layout. Action groups wrap - into at most two columns by default, collapse to one column for long labels, - and let a single odd final action span the row. Apps should not create - app-local button grids just to place several actions near each other. -- Use app-level `usage`/`usageTitle` on `labkit.ui.layout.workbench` for static - workflow instructions. The framework places that read-only usage panel at the - bottom of the first control tab. -- Presenter `Limits` and `Items` properties are committed before bound values; - the runtime clamps or selects safely without firing app callbacks. - -The public layout-node grammar is semantic: pages, sections, controls, order, -values, and callbacks. When a workflow needs a control that cannot be expressed -with the ordinary layout nodes, add a named framework or app-owned layout node -instead of placing -MATLAB layout code in `buildWorkbenchLayout.m`. - -Control tabs with more than one section include draggable horizontal -separators by default. A tab may opt out with `resize="none"` when a fixed -stack is intentional. - -### Runtime State And Transactions - -A definition always declares product metadata, requirements, `Id`, `Title`, -and `Layout`. It may add `Project`, `CreateSession`, `Actions`, `Present`, -`Renderers`, and `Start`. It launches through -`labkit.ui.runtime.launch`, which owns lightweight request dispatch, contract -checks, runtime creation, output normalization, and the versioned title. - -Runtime state has exactly two roots, `project` and `session`. Project contains -`inputs`, `parameters`, `annotations`, `results`, and `extensions`; session -contains `selection`, `workflow`, `view`, and `cache`. Both slices contain only -plain serializable MATLAB data. Graphics, listeners, timers, tools, callbacks, -services, and debug contexts stay in the framework resource registry. - -Project factories use `labkit.ui.runtime.emptySourceRecords()`; the runtime owns -that shape, handlers populate it through `services.project` operations, and -all App layers read paths through `labkit.ui.runtime.sourcePaths()`. - -The `Project` declaration owns its version, factory, validator, migrations, -and named read-only legacy imports. Optional `CreateResume` and `ApplyResume` -hooks may restore conveniences such as the current frame, never durable data. - -Events run through one non-recursive FIFO queue per figure. A handler has -the signature `state = handler(state, event, services)`. Nested -`services.dispatch` calls enqueue a later transaction, so the current handler -finishes one state commit and one presentation commit first. A failed handler, -validator, or presenter restores the last committed state and visible -presentation. - -Ordinary value controls may use a semantic binding such as -`"Bind", "project.parameters.gamma"` and may name an optional `Event` to run -after the value is staged. Omit `Event` when the bound value is the complete -state change; the runtime still validates and commits the transaction, and the -App does not need a no-op handler. `Present(state)` returns control properties and -prepared preview models by semantic id. Registered renderers receive an axes -and model; presenters and actions never receive the raw UI registry. A -renderer runs only when its declared renderer/model request changes; -state-only commits preserve existing graphics and viewports. Plot utilities are -inferred from the layout. Dynamic `Items` and `Limits` -are applied before bound values. Runtime saves one `labkitProject`; named legacy -variables import read-only. - -`Event` is meaningful only on a control that also declares `Bind`. An unbound -control must use an explicit generated `onChange` callback; the runtime rejects -an unbound `Event` during launch so a migrated control cannot appear editable -while silently discarding user changes. - -After shell, state, first presentation, and interaction hub exist, the runtime -queues optional `Start` with injected app-neutral `services`. An optional -`DebugSample` writer runs only for debug launches, without app startup glue. -Commits mirror `session.workflow.logLines` into semantic `logPanel` controls. -Injected `services.dialogs` provides input-file, input-folder, output-file, and -output-folder selection with safe defaults and test-injectable choosers. App -handlers therefore do not call `uigetfile`, `uigetdir`, or save-dialog helpers -directly. A `Start` handler may resolve a registered preview with -`services.previews.axes(previewId, axisId)` only to attach a framework-managed -listener or timer; actions, presenters, and semantic state still use plain data -and preview models. - -Injected `services.resources.set(scope,id,value)` registers a nonsemantic -event-, session-, or figure-scoped resource with the Runtime's default cleanup. -Pass a fourth cleanup function only when the value needs custom disposal. -Replacing the same scope and id disposes the prior value before registration. - -Each figure owns one private interaction hub. Preview targets register as -`previewId` or `previewId.axisId`; the hub owns hover wheel/zoom routing, -drag callbacks, and atomic groups. Apps never set figure callbacks. -`Present` may declare `anchors`, `pairedAnchors`, `pointSlots`, `rectangle`, `regionSelection`, or -`scaleBarReference` with semantic targets, values, events, and image sizes. `regionSelection` -emits a dragged rectangle to `Event` or clicked point to `BackgroundEvent`. -Kind/target changes replace or dispose resources; programmatic values suppress -events, while user edits enqueue the declared event. When an interaction event -changes an overlay model and causes its renderer to run again, the runtime -preserves any manually zoomed or panned axes limits. Ordinary file-selection, -reset, and data-change events may still let the renderer fit a new view. - -## Start And Readiness - -LabKit app startup is a framework runtime state, not an app-owned progress -dialog convention. `labkit.ui.runtime.launch` builds the shell, canonical state, -first presentation, and interaction hub, then queues the optional definition -`Start` handler through the same transaction queue as every later event. The -runtime paints a readiness boundary when this work is slow enough to be -perceptible and clears it only after the first visible presentation commits. - -Fast apps should not flash a loading strip. The app window remains hidden until -the initial workspace is ready, while the launcher progress dialog mirrors the -current framework phase. A direct command-window launch prints the same phases -with a `[LabKit startup]` prefix. This keeps slow or failed startup observable -without exposing a blank or partially constructed app window. Hidden or -minimized GUI test modes preserve readiness state for assertions while avoiding -disruptive visual UI and command output. - -Nonessential work belongs behind a user event or a framework-owned managed -resource, not an app-created startup timer. App code should express initial -domain work in its single `Start` handler and must not mutate readiness flags -or manage loading controls directly. - -## Busy State - -Every `labkit.ui.layout.action` callback runs as an app-wide action transaction. -The framework marks the app busy before invoking the app callback and clears -that busy state after the callback returns or errors. While the figure is busy, -other semantic callbacks return without invoking app code, so repeated -clicks or value changes do not submit duplicate work even when the user waits -and interacts again before the first action finishes. - -The default busy text comes from the action label: +Do not pass the complete state or callback context into calculation code that +only needs groups and one edited value. -```matlab -labkit.ui.layout.action("exportCrops", ... - "Export cropped images", callbacks.exportCrops, ... - "enabled", false) -``` +## Complete View Snapshots -This displays `Working: Export cropped images` in the window title while the -callback runs. Use `busyMessage` only when the title text needs to differ from -the button label. - -Busy state is app-wide. While the callback runs, LabKit marks the figure busy, -sets a busy pointer, and appends the busy message to the window title. Direct -changes made by the App callback to its pointer, title, or interaction state -remain in place after the transaction; cleanup only restores framework-owned -values that the callback left unchanged. - -LabKit-created app figures install a framework close guard. Closing any LabKit -app shows an in-window confirmation prompt before deleting the figure. If the -user tries to close an app while a semantic action is -active, the prompt uses busy-state wording. - -The app-window close shortcut uses the same path: Command-W on macOS and -Control-W elsewhere request a guarded close rather than bypassing unfinished -work prompts. When a close shortcut opens the confirmation prompt, repeating or -holding the same close shortcut confirms the close. - -Apps should not maintain close-guard dirty state. The framework owns close -confirmation for public and private LabKit apps. - -Busy transactions intentionally do not create modal progress dialogs or mutate -control `Enable` values. Apps should not maintain their own busy-control lists; -App render logic still owns permanent button enablement rules. - -## Presentation And Plotting - -Apps do not receive the UI registry and do not call control setters. A pure -`Present(state)` function returns semantic control properties, prepared preview -models, and controlled interaction specs. The runtime applies dynamic items and -limits before values, suppresses callbacks during the commit, mirrors workflow -logs, and invokes changed renderer/model requests with only `(axes, model)`. -Unchanged preview requests retain their graphics handles and axes limits. - -For a `resultTable`, the presenter may supply `Data`, `ColumnName`, and -`RowName`. The runtime applies headers before data so one table can display -different imported CSV or worksheet shapes without predeclaring placeholder -columns. A presenter may also set a semantic control's `Visible` property. -Hidden workspace panels collapse their rows so another table or preview can -use the full right-side workspace. - -A workspace normally contains panels directly. When an App needs several -user-selectable right-side pages, the same `workspace` node may instead contain -two or more `tab` nodes, each containing workspace panels. Runtime builds one -native tab group and owns page selection and geometry. Direct panels and tab -pages cannot be mixed, a single tab is rejected as unnecessary structure, and -every workspace tab must contain at least one panel. Existing single-workspace -Apps keep their direct-panel form. - -Renderers may use ordinary MATLAB graphics plus the small advanced plot surface: -`clear`, `fit`, `fitCanvas`, `message`, `offsetData`, and `clampData`. -`clear` and `fit` own full-redraw viewport mechanics; `offsetData` and -`clampData` honor log scales and reversed axes. Image preparation, annotations, -labels, and scientific plot meaning remain app-owned. - -A renderer may call `labkit.ui.interaction.enablePopout(ax)` after drawing an -axes. The workbench also installs the standard popout action automatically and -restores it after a renderer resets the axes or replaces its children. - -## Managed Interactions - -Apps declare controlled interaction specs from their presenter rather than -constructing editor objects. Supported kinds include `anchors`, -`pairedAnchors`, `pointSlots`, `rectangle`, `regionSelection`, -`interval`, and `scaleBarReference`. The figure-scoped interaction hub owns -pointer routing, wheel zoom, drag capture/release, callback restoration, event -enqueueing, and resource cleanup. - -While an anchor interaction is active, the framework writes the exact add, -drag, and removal gesture into the preview subtitle. Curve anchors use -double-click to add/delete, point-marking modes use a single click plus their -explicit Undo/Clear controls, and paired matching explains that both previews -must be clicked in corresponding order. - -Apps persist only semantic values such as points, rectangles, intervals, and -calibration. `labkit.ui.interaction.anchorPath`, `scaleBarCalibration`, and -`scaleBarGeometry` remain GUI-free advanced helpers for calculations, -presentation models, and exports. Apps never place editor/runtime objects or -graphics handles in project or session state. - -## Debug - -Debug launches create an ignored `artifacts/debug///manifest.json`. -This is a local index for the anonymous debug sample pack, trace log, and -expected debug output folder; it is not project state and is safe to remove -with the rest of that debug run. By contrast, `*.labkit.json` beside an -exported result is a result manifest containing output status, byte counts, -SHA-256 hashes, parameters, provenance, and summary data, and should normally -stay with the exported files. - -`labkit.ui.runtime.launch` owns normal, debug, requirements, and version -requests. A debug launch remains: +Runtime starts from layout defaults, bindings, file state, log text, and +status text. `PresentWorkbench` returns only derived App-owned operations: ```matlab -[fig, debug] = appName("debug"); +function view = present(applicationState) +view = labkit.app.view.Snapshot(); +view = view.include(example.previewPlot.present( ... + applicationState.session.measurements, ... + applicationState.project.parameters)); +view = view.enabled("exportResult", ... + ~isempty(applicationState.session.measurements)); +end ``` -The runtime injects diagnostics and workflow-log services. Handlers that catch -and continue after an exception call `services.diagnostics.report`; visible -log lines go through `services.workflow.log` and are mirrored into semantic -`logPanel` controls on commit. Apps do not construct debug contexts, wrap UI -callbacks, or append separately to a UI registry. +The combined snapshot must cover every semantic target exactly as its layout +capabilities require. `Snapshot.include` composes feature-owned fragments +without opening a generic property-patch schema. -Each debug launch writes a session folder under -`artifacts/debug///` containing `trace.log`, `samples/`, -`outputs/`, and `manifest.json`. The trace is authoritative when the GUI is -unresponsive; the Log tab is its human-readable workflow mirror. Framework -instrumentation records active operations and callback failures without adding -app-owned lifecycle glue. +Plot presentation passes a prepared model to the renderer declared by its +plot area: -## Callback Policy +```matlab +view = view.renderPlot("previewPlot", model); +``` -Reusable helpers and tools keep three callback classes separate: +```matlab +function draw(axesById, model) +ax = axesById.main; +cla(ax); +plot(ax, model.x, model.y); +end +``` -| Callback class | Purpose | -| --- | --- | -| User semantic callbacks | Notify the app that the user changed app-relevant state. | -| Internal refresh callbacks | Keep controls, graphics, and derived readouts synchronized without re-entering app semantics. | -| Programmatic callbacks | Apply app-initiated state changes and report source as programmatic when exposed through trace. | +Renderers own drawing and viewport policy, not workflow decisions or project +mutation. Display-only graphics disable hit testing. Managed interaction +specs own editable gestures and event-scoped resources. -All `setX(value)` style APIs should do nothing when the requested value is -already current. Internal synchronization should not fire app-facing semantic -callbacks. Composed tools should trace callback reason/source as `user`, -`internal`, or `programmatic` when an event crosses the app/tool boundary. +Declare managed gestures statically on their plot area and provide their +current value in the snapshot: -## Framework And App Responsibilities +```matlab +crop = labkit.app.interaction.rectangle( ... + "cropRegion", @example.cropGeometry.moveCrop); +plot = labkit.app.layout.plotArea( ... + "previewPlot", @example.previewPlot.draw, Interactions={crop}); +``` -`labkit.ui` provides the app-neutral GUI shell, view construction, axes -rendering, interaction lifecycle, composed tools, diagnostics, and reusable -control-state mechanics. +```matlab +view = view.rectangle( ... + "cropRegion", project.annotations.crop, ImageSize=size(image)); +``` -Experiment names, formulas, thresholds, parser calls, result fields, export -schemas, plotting annotations, and workflow-specific action order remain in the -app. Apps pass labels, semantic action ids, prepared vectors, tables, debug -contexts, and option values into UI helpers. +Named contracts also cover anchor paths, paired anchors, fixed point slots, +transient region selection, intervals, and scale references. Apps never author +`Kind`, `Targets`, `Event`, or `Options` transport structs. + +Renderer mechanics such as complete clears, empty-state messages, fitting, +fixed-aspect canvases, and axes-relative annotation placement live under +`labkit.app.plot.*`. Apps own when those operations occur, user wording, and +whether a semantic change should preserve or fit the viewport. + +## CallbackContext + +`labkit.app.CallbackContext` is sealed and exposes specifically named runtime +operations for dialogs, status and diagnostics, portable sources, project +documents, result packages, render surfaces, and managed resources. It does +not expose figures, component registries, queues, lifecycle handles, or a +nested service bag. + +Use context methods only at a callback or reconstruction boundary. Pure +readers, calculations, result builders, and render-model builders accept +ordinary explicit values. + +`callbackContext.chooseOption(prompt, choices, ...)` owns ordinary native +confirmation choices. `Title` controls the dialog title, `DefaultChoice` +selects the Enter-key action, and `CancelChoice` is returned when the user +dismisses the dialog. All three named choices must be members of the declared +nonempty unique choice row. File and folder methods remain separate because +they return paths and use platform file choosers. + +An App-specific project button may choose a MAT file and return +`callbackContext.restoreProjectDocument(filepath)`. The context prepares the +same migrated, relinked project/session candidate used by the framework Load +State menu; the active callback transaction still owns validation, native +presentation, rollback, document metadata, and title publication. +`callbackContext.newProjectDocument()` similarly returns the schema's fresh +project/session state and publishes a new unsaved document identity only when +that callback transaction commits. + +## Persistence, Results, And Cleanup + +`labkit.app.project.Schema` owns current project creation, validation, and +ordered version migration. Runtime owns the project envelope, atomic save, +restore, recovery, and relinking loop. + +`labkit.app.result.File` and `labkit.app.result.Package` describe App-owned +outputs. `CallbackContext.writeResultPackage` writes through the runtime so +source and project provenance remain consistent. + +Resources have event, interaction, document, or application scope. Replacing +the same scope and ID is idempotent; the runtime cleans every surviving +resource on scope end or close. ## Validation -Reusable UI contracts are covered by the source-aligned UI and project build -tasks listed in [Testing](../../development/maintain-and-release/testing.md). +Use focused contract tests for Definition, layout, callbacks, snapshots, +project schema, and runtime transactions. Add downstream App tests for changed +behavior and a bounded hidden-GUI test for native wiring. Automated hidden GUI +tests do not prove dialog quality, pointer feel, scientific validity, or a +complete interactive workflow. -Automated GUI tests validate launch, layout, callback wiring, trace plumbing, -reusable tool lifecycle, and hidden synthetic app workflows. Full interactive -drawing, file selection, visual inspection, scientific validity, and workflow -feel still require manual MATLAB GUI validation. +See [Testing](../../development/maintain-and-release/testing.md) and +[Build A Complete App](../../development/build-apps/complete-app.md). diff --git a/docs/history/records/2026/07/LK-20260719-ui-explicit-contract-migration.md b/docs/history/records/2026/07/LK-20260719-ui-explicit-contract-migration.md new file mode 100644 index 000000000..d25237a06 --- /dev/null +++ b/docs/history/records/2026/07/LK-20260719-ui-explicit-contract-migration.md @@ -0,0 +1,220 @@ +# App SDK explicit contract replaces the retired UI runtime + +```labkit-change +schema: 2 +id: LK-20260719-ui-explicit-contract-migration +date: 2026-07-19 +sequence: 138 +type: refactor +compatibility: breaking +component: `labkit.app` | `new -> 1.0.0` +component: `labkit_DICPostprocess_app` | `1.4.7 -> 1.5.0` +component: `labkit_DICPreprocess_app` | `1.5.8 -> 1.6.0` +component: `labkit_ChronoOverlay_app` | `1.4.7 -> 1.5.0` +component: `labkit_CIC_app` | `1.4.7 -> 1.5.0` +component: `labkit_CSC_app` | `1.4.8 -> 1.5.0` +component: `labkit_EIS_app` | `1.4.7 -> 1.5.0` +component: `labkit_VTResistance_app` | `1.4.7 -> 1.5.0` +component: `labkit_GaitAnalysis_app` | `2.0.8 -> 2.1.0` +component: `labkit_BatchImageCrop_app` | `1.7.7 -> 1.8.0` +component: `labkit_CurvatureMeasurement_app` | `1.4.6 -> 1.5.0` +component: `labkit_FLIRThermal_app` | `1.4.8 -> 1.5.0` +component: `labkit_FocusStack_app` | `1.5.6 -> 1.6.0` +component: `labkit_ImageEnhance_app` | `1.6.7 -> 1.7.0` +component: `labkit_ImageMatch_app` | `1.6.8 -> 1.7.0` +component: `labkit_VideoMarker_app` | `1.5.7 -> 1.6.0` +component: `labkit_FigureStudio_app` | `0.2.9 -> 0.3.0` +component: `labkit_NerveResponseAnalysis_app` | `1.4.8 -> 1.5.0` +component: `labkit_ResponseReviewStats_app` | `1.4.7 -> 1.5.0` +component: `labkit_RHSPreview_app` | `1.4.6 -> 1.5.0` +component: `labkit_TTestWizard_app` | `1.0.1 -> 1.1.0` +component: `labkit_ECGPrint_app` | `1.4.6 -> 1.5.0` +scope: App Framework +scope: DIC +scope: Electrochem +scope: Gait +scope: Image Measurement +scope: LabKit Core +scope: Neurophysiology +scope: Statistics +scope: Wearable +scope: Project persistence +scope: Result provenance +``` + +## Context + +The retired UI runtime removed substantial per-App lifecycle code, but Apps still +registered callback tables, repeated bound values in presenters, authored +standard file add/remove/clear behavior, and depended on nested event/service +structs. A replacement SDK kernel had already established immutable semantic +values, pre-GUI validation, transactional state/presentation commits, project +documents, result manifests, resources, and portable sources. The migration +then had to restore the complete behavior and visual contract of every tracked +App before the retired production facade could be deleted. + +## Decision and rationale + +Create `labkit.app` as the stable SDK rather than misnaming the expanded +contract `labkit.ui` or adapting it back to Runtime V2 transport structs. Keep +the public root small, partition authoring by capability, and concentrate +complexity in a paved path: direct-callback `layout.*` nodes, strict bindings, +runtime-completed `view.Snapshot` values, standard file lifecycle, fixed +`CreateSession(project,context)`, and private native adapters. + +Migrate all 21 tracked Apps through capability waves, treating their previous +controls, tabs, layout proportions, interactions, project behavior, results, +debug samples, and workflow wording as product contracts rather than reducing +the task to launch compatibility. + +## Changes + +- Added the private native MATLAB adapter with semantic component ownership, + typed RuntimeKernel callbacks, native dialog results, complete-presentation + reconciliation, and rollback to the previous native view after a failed + renderer commit. +- Added `Definition.launch`, fixed renderer `(axes,model)` dispatch, semantic + labels, runtime-owned file add/remove/clear and selection, and transient + session rebuild after source collection changes. +- Added strict table view options, typed complete-data edits, and distinct + `event.TableCellEdit`, `event.TableCellSelection`, and + `event.ListSelection` values; the private adapter absorbs native MATLAB + table-value differences. +- Fixed session construction to `CreateSession(project,context)` so Apps resolve + opaque portable sources without reading their representation. +- Migrated Chrono Overlay to one directly bound export callback, four state + bindings, one directly bound two-axis renderer, and a two-operation view + snapshot. +- Partitioned the public SDK into `layout`, `view`, `event`, `project`, + `result`, and `dialog`; layout nodes, option parsing, stores, adapters, and + runtime execution remain hidden under `internal`. +- Reduced Chrono's noncomment layout/action/presenter code from 277 lines to + 86 while preserving its DTA alignment, plot options, project schema, CSV + columns, and result provenance. +- Migrated T-Test Wizard as the typed editable-table, feature-fragment, and + multi-page workspace proof: table selections and edits have explicit payload + classes, `+workbench` exposes product assembly, workflow packages own their + layout/presentation/actions, and the private adapter owns concrete layout. +- Migrated VT Resistance to direct file and analysis-setting bindings, a + complete summary/table/two-axis snapshot, and an App-owned result-package + export. Plot renderers and scientific choices now live with their owning + analysis capabilities instead of a technical UI package. +- Migrated Gait Analysis to capability-owned source adoption, option + invalidation, deterministic analysis, step selection/navigation, three-axis + rendering, CSV-set export, and result packaging. +- Migrated DIC Preprocess to two role-bound image sources, paired-anchor + registration, managed crop and mask editors, two-axis rendering, edit replay, + and result-package exports owned by its analysis, mask, and result + capabilities. +- Migrated Batch Image Crop to framework-owned source selection plus + App-owned duplicate crop tasks, direct crop-center and scale-reference + interactions, capability snapshots, and result-package export. +- Added `labkit.app.project.sourceRecord` so pure payload migrations can + convert legacy paths into portable sources without constructing the + framework-owned reference representation. +- Corrected folder chooser dispatch to its one-path backend contract and + applied table data before table selection during native reconciliation, so + a selection may legally target rows introduced by the same snapshot. +- Preserved cell-valued interaction payloads in the transactional event queue + and kept multi-target interaction bridge specifications scalar, enabling + paired-anchor gestures across two semantic axes. +- Removed handler objects, callback tables, renderer registries, and their + forwarding from the App authoring contract. Layout controls and plot areas + reference concrete functions directly. +- Migrated the remaining electrochemistry, DIC, image-measurement, + neurophysiology, core, wearable, and high-state workflows, including + editable tables, workspace pages, file roles, multi-axis plots, managed + point/rectangle/interval/scale interactions, long-lived video resources, + project recovery, result packages, and synthetic diagnostic samples. +- Restored shared product presentation: versioned titles and dirty markers, + startup progress and failure surfaces, guarded close behavior, utility + menus, adjustable pane dividers, scroll and grow policies, numeric panners, + adaptive action grids, usage text, plot navigation, pop-out/export tools, + and viewport-preserving overlays. +- Internalized contract compilation, runtime construction, native platform + plans, target inventories, and callback-context creation. `Definition` is + the sole author-created root; `CallbackContext` is a sealed runtime-injected + port; optional concepts remain grouped under purpose-specific packages. +- Deleted the complete retired `labkit.ui` production facade and the + migration-only analyzer, prototypes, compatibility tools, and debt-only + tests after source scans and focused App tests proved that no production + consumer remained. + +## User and data impact + +Chrono Overlay retains its input formats, pulse-gap alignment, plot meanings, +parameter defaults, CSV table, and version-2 project payload. T-Test Wizard +retains its source formats, group/test calculations, plot meaning, two CSV +exports, and version-2 project payload. VT Resistance retains its pulse +detection, resistance calculations, plot semantics, CSV schema, and version-1 +project payload while recomputing the decoded batch under shared settings. +Gait Analysis retains its Video Marker payload contract, project migrations, +step segmentation, gait metrics, CSV set, and version-3 project payload. File +identities and portable paths remain runtime-owned. DIC Preprocess retains its +rigid registration, common crop, mask editing, image/mask exports, and +version-1 project payload. Batch Image Crop retains duplicate tasks per source, +fixed-pixel and physical crops, rotation/padding, scale calibration, scale-bar +placement, image/CSV exports, and its version-2 payload. Existing payload +migrations remain App-owned. + +The other 15 Apps retain their documented source formats, scientific +calculations, units, thresholds, project payload versions, export schemas, and +result meanings. Their product versions advance once from the `main` baseline +to identify the new App SDK source contract and restored complete UI behavior. +No project payload version was increased merely because the UI framework +changed. + +## Compatibility and migration + +`labkit.app` 1 is a source-breaking replacement contract for App definitions, +presenters, callbacks, events, and interactions. Every tracked App migrated +before the retired `labkit.ui` boundary was deleted; there is no runtime +adapter or dual authoring surface. Public App entrypoint commands remain +stable. Project documents retain their format and App payload versions +independently of the facade transition. + +## Validation + +Focused headless tests cover strict values, transactional runtime behavior, +project save/restore, authoring defaults, and Chrono calculations/exports. +Hidden GUI tests cover native semantic construction, typed control and table +callbacks, bound +side effects, standard file lifecycle, transient session rebuild, two-axis +rendering, viewport preservation, renderer rollback, Chrono export, and +project restore. VT Resistance focused tests cover resistance calculations, +CSV compatibility, native layout, shared batch recomputation, two-axis +rendering, result packaging, and project restore. Gait focused tests cover +project migration, pose decoding, scientific calculations, CSV compatibility, +typed table navigation, three-axis rendering, folder selection, result +packaging, and project restore. +DIC Preprocess focused tests cover project state, image loading, edit replay, +registration/crop/mask helpers, export manifests, native two-axis layout, +paired-anchor alignment, and managed crop interaction. Framework regression +tests cover cell-valued event payloads and native multi-axis interaction +bridging. +Batch Image Crop focused tests cover crop geometry, padding, physical scaling, +project migration, duplicate tasks, output planning/writes, native semantic +layout, current-center editing, and standard result manifests. +Focused framework and App tests additionally cover all 21 semantic layouts, +typed events, managed interactions, project migration/recovery, result +writing, synthetic sample packs, diagnostics, resource cleanup, window titles, +startup success/failure, close behavior, and native adapter reconciliation. + +## Evidence + +- [Chrono Overlay](../../../../apps/electrochemistry/chrono-overlay/README.md) +- [VT Resistance](../../../../apps/electrochemistry/vt-resistance/README.md) +- [Gait Analysis](../../../../apps/gait/gait-analysis/README.md) +- [DIC Preprocess](../../../../apps/dic/dic-preprocess/README.md) +- [Batch Image Crop](../../../../apps/image-measurement/batch-crop/README.md) +- [App catalog](../../../../apps/README.md) +- [LabKit App Framework](../../../../framework/README.md) +- [Build a Complete App](../../../../development/build-apps/complete-app.md) + +## Known limitations and follow-up + +Automated GUI evidence does not replace developer-led validation of native +dialogs, editable-table feel, pointer interaction, long-lived resource use, +representative exports, visual quality, or scientific workflow suitability. +That interactive validation remains a release input for the exact integrated +commit. diff --git a/docs/history/records/2026/07/LK-20260720-app-action-tooltips.md b/docs/history/records/2026/07/LK-20260720-app-action-tooltips.md new file mode 100644 index 000000000..350ca0b18 --- /dev/null +++ b/docs/history/records/2026/07/LK-20260720-app-action-tooltips.md @@ -0,0 +1,85 @@ +# App actions require explanatory hover help + +```labkit-change +schema: 2 +id: LK-20260720-app-action-tooltips +date: 2026-07-20 +sequence: 140 +type: feat +compatibility: compatible +component: `labkit.app` | `1.0.0 -> 1.1.0` +component: `labkit_DICPostprocess_app` | `1.5.0 -> 1.5.1` +component: `labkit_DICPreprocess_app` | `1.6.0 -> 1.6.1` +component: `labkit_ChronoOverlay_app` | `1.5.0 -> 1.5.1` +component: `labkit_CIC_app` | `1.5.0 -> 1.5.1` +component: `labkit_CSC_app` | `1.5.0 -> 1.5.1` +component: `labkit_EIS_app` | `1.5.0 -> 1.5.1` +component: `labkit_VTResistance_app` | `1.5.0 -> 1.5.1` +component: `labkit_GaitAnalysis_app` | `2.1.0 -> 2.1.1` +component: `labkit_BatchImageCrop_app` | `1.8.0 -> 1.8.1` +component: `labkit_CurvatureMeasurement_app` | `1.5.0 -> 1.5.1` +component: `labkit_FLIRThermal_app` | `1.5.0 -> 1.5.1` +component: `labkit_FocusStack_app` | `1.6.0 -> 1.6.1` +component: `labkit_ImageEnhance_app` | `1.7.0 -> 1.7.1` +component: `labkit_ImageMatch_app` | `1.7.0 -> 1.7.1` +component: `labkit_VideoMarker_app` | `1.6.0 -> 1.6.1` +component: `labkit_FigureStudio_app` | `0.3.0 -> 0.3.1` +component: `labkit_NerveResponseAnalysis_app` | `1.5.0 -> 1.5.1` +component: `labkit_ResponseReviewStats_app` | `1.5.0 -> 1.5.1` +component: `labkit_RHSPreview_app` | `1.5.0 -> 1.5.1` +component: `labkit_TTestWizard_app` | `1.1.0 -> 1.1.1` +component: `labkit_ECGPrint_app` | `1.5.0 -> 1.5.1` +scope: App Framework +scope: All tracked Apps +``` + +## Context + +The native text-fit adapter copied button text through a column-shaped char +conversion. MATLAB therefore received one newline between every character and +rendered hover help as a narrow vertical strip. The generated tooltip also +only repeated the visible label, so it did not explain the scientific or +workflow consequence of an action. + +## Decision and rationale + +Make explanatory hover help part of the semantic layout contract. Every +`layout.button` produces a nonempty Tooltip, and tracked Apps must replace the +label-based framework fallback with App-owned explanatory text. File-list +actions expose dedicated tooltip fields and retain framework-owned defaults +for generic folder, remove, and clear mechanics. Scientific meaning stays in +the owning App rather than in the native adapter. + +All tracked Apps now describe what their actions consume, calculate, mutate, +or export. The contract guardrail rejects App tooltips that only repeat the +visible action label and also requires an explanatory input-selection tooltip. + +## Changes + +- Preserved char row vectors as one text line during native text fitting, + eliminating the injected character-by-character newlines. +- Added `Tooltip` support with a nonempty label fallback to `layout.button` + and dedicated file-list tooltips for choose, folder, recursive folder, + remove, and clear actions. +- Added scientific and workflow-specific text for all 138 tracked App business + buttons and all 26 App input selectors. +- Updated each owning App manual and the framework authoring examples. + +## Compatibility and user impact + +Existing tracked Apps and third-party layouts retain their actions and +calculations. Hovering now always shows readable text; tracked Apps additionally +carry domain-specific explanations. + +## Validation and evidence + +- App SDK unit coverage for required and compiled tooltip values. +- Cross-App definition guardrail for non-label business and input tooltips. +- Native adapter GUI coverage for exact tooltip text without injected + newlines. +- DIC Postprocess GUI coverage for scientific action and Ncorr input help. + +## Follow-up + +Developer-led interactive checks should confirm tooltip timing, width, and +line wrapping on supported MATLAB releases and desktop platforms. diff --git a/docs/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.md b/docs/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.md new file mode 100644 index 000000000..c44f0c7b8 --- /dev/null +++ b/docs/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.md @@ -0,0 +1,68 @@ +# Launcher adopts the App SDK diagnostic launch contract + +```labkit-change +schema: 2 +id: LK-20260720-launcher-app-sdk-diagnostics +date: 2026-07-20 +sequence: 139 +type: feat +compatibility: compatible +component: `labkit_launcher` | `1.5.2 -> 1.6.0` +scope: LabKit Core +scope: App Framework +``` + +## Context + +The Launcher still passed the retired `RequestAdapter` startup seam after Apps +had moved to `labkit.app.Definition`. Normal launch therefore failed before the +selected App could create its window. Removing that argument restored startup, +but temporarily also removed the user's diagnostic launch path. + +## Decision and rationale + +Treat the App entrypoint and `Definition.launch` as the only Launcher-to-App +runtime boundary. A normal launch uses the SDK defaults. A debug launch passes +one typed `labkit.app.diagnostic.Options` value that requests verbose persisted +events and the App-owned anonymous synthetic sample. + +The Launcher continues to read catalog metadata from the single app-owned +`definition.m` source without executing the SDK. This preserves its recovery +role when an installed framework is incomplete or damaged. + +## Changes + +- Replaced retired runtime-adapter injection with direct App SDK entrypoint + calls for normal launch and profiling. +- Restored **Open Debug** using verbose typed diagnostics and each App's + `BuildDebugSample` contract. +- Added one isolated session folder per debug launch under + `artifacts/diagnostics/launcher/`, with the event stream, manifests, and + anonymous fixture artifacts. +- Kept Launcher UI state in a Launcher-owned view record instead of the retired + framework registry name. +- Updated Definition metadata discovery for current name-value syntax while + retaining the older literal form for repair-oriented catalog compatibility. +- Excluded hidden class-folder implementation methods from public API and + generated-documentation discovery. + +## Compatibility and user impact + +Existing normal launch, profiling, packaging, update, and documentation actions +keep their public behavior. Debug launch now creates explicit diagnostic +artifacts and opens the App with its synthetic scenario; it no longer invokes +the retired string-mode or request-adapter contracts. + +## Validation and evidence + +- Focused Launcher GUI, catalog, progress, profiling, and documentation + contracts. +- App SDK diagnostic integration through DIC Preprocess, including + `events.jsonl` and `sample-pack.json` evidence. +- Documentation source synchronization and public API discovery guardrails. + +## Follow-up + +Developer-led interactive validation should confirm the visible debug launch, +the selected App's synthetic state, and the usefulness of the generated +diagnostic bundle on the deployment MATLAB version. diff --git a/labkit_launcher.m b/labkit_launcher.m index 8a2933197..2cfccceb0 100644 --- a/labkit_launcher.m +++ b/labkit_launcher.m @@ -112,8 +112,8 @@ info = struct( ... "name", "labkit_launcher", ... "displayName", "LabKit App Launcher", ... - "version", "1.5.2", ... - "updated", "2026-07-19"); + "version", "1.6.0", ... + "updated", "2026-07-20"); end function titleText = launcherVersionTitle() @@ -304,7 +304,7 @@ function launchFigureStudioFromAxes(root, ax) ui.controls.selectedDetails = struct('textArea', txtInfo); ui.controls.statusLine = struct('textArea', txtInfo); ui.controls.appTable = struct('table', appTable); - setappdata(fig, 'labkitUiRegistry', ui); + setappdata(fig, 'labkitLauncherView', ui); state = struct('apps', emptyAppStruct(), 'visibleApps', emptyAppStruct(), ... 'selectedRow', 1, 'status', "Loading app list...", ... @@ -422,8 +422,8 @@ function onProfileSelectedApp(varargin) end row = min(max(state.selectedRow, 1), numel(state.visibleApps)); app = state.visibleApps(row); - beginLauncherAction(profileStartStatus(app, false)); - profileSelectedApp(app, false); + beginLauncherAction(profileStartStatus(app)); + profileSelectedApp(app); endLauncherAction(); end @@ -671,12 +671,14 @@ function launchSelectedApp(debugMode) setStatus(launchHandOffStatus(app, debugMode)); updateProgressDialog(dlg, sprintf('Initializing %s...', app.command), NaN); drawnow limitrate; - progressFcn = @(message) updateLauncherStartupProgress( ... - dlg, message); - requestAdapter = @(args) launcherStartupRequest( ... - args, progressFcn, debugMode); - feval(app.command, "RequestAdapter", requestAdapter); - setStatus(launchSuccessStatus(app, debugMode)); + diagnosticFolder = ""; + if debugMode + [diagnostics, diagnosticFolder] = launcherDiagnosticOptions(root, app); + feval(app.command, "Diagnostics", diagnostics); + else + feval(app.command); + end + setStatus(launchSuccessStatus(root, app, debugMode, diagnosticFolder)); catch err setStatus(sprintf(['Failed to launch %s: %s. If project files are missing ' ... 'or damaged, use GitHub Update to repair this install.'], ... @@ -686,13 +688,13 @@ function launchSelectedApp(debugMode) endLauncherAction(); end - function profileSelectedApp(app, debugMode) - setStatus(profileStartStatus(app, debugMode)); + function profileSelectedApp(app) + setStatus(profileStartStatus(app)); drawnow; dlg = []; try dlg = uiprogressdlg(fig, 'Title', 'Profile LabKit App', ... - 'Message', char(profileStartStatus(app, debugMode)), ... + 'Message', char(profileStartStatus(app)), ... 'Indeterminate', 'on'); catch end @@ -700,7 +702,7 @@ function profileSelectedApp(app, debugMode) dlgCleanup = onCleanup(@() close(dlg)); end try - result = runLauncherAppProfile(root, app, debugMode); + result = runLauncherAppProfile(root, app); setStatus(profileSuccessStatus(app, result)); catch err setStatus(sprintf('Performance profile failed for %s: %s', ... @@ -1353,15 +1355,17 @@ function selectTableRow(tableHandle, row, apps) function message = launchStartStatus(app, debugMode) if debugMode - message = sprintf('Launching %s in debug mode...', app.command); + message = sprintf('Launching %s with verbose diagnostics and its synthetic sample...', ... + app.command); else message = sprintf('Launching %s...', app.command); end end -function message = launchSuccessStatus(app, debugMode) +function message = launchSuccessStatus(root, app, debugMode, diagnosticFolder) if debugMode - message = sprintf('Launched %s in debug mode.', app.command); + message = sprintf('Launched %s in debug mode. Diagnostic session: %s', ... + app.command, relativePath(root, diagnosticFolder)); else message = sprintf('Launched %s.', app.command); end @@ -1369,20 +1373,16 @@ function selectTableRow(tableHandle, row, apps) function message = launchHandOffStatus(app, debugMode) if debugMode - message = sprintf('Opening %s in debug mode. Startup phases appear in the progress dialog.', app.command); + message = sprintf(['Opening %s with App SDK verbose diagnostics ' ... + 'and its synthetic sample...'], app.command); else - message = sprintf('Opening %s. Startup phases appear in the progress dialog.', app.command); + message = sprintf('Opening %s with the App SDK runtime...', app.command); end end -function message = profileStartStatus(app, debugMode) - if debugMode - message = sprintf(['Profiling %s in debug mode. Close the app window ' ... - 'to finish the report...'], app.command); - else - message = sprintf(['Profiling %s. Close the app window to finish ' ... - 'the report...'], app.command); - end +function message = profileStartStatus(app) + message = sprintf(['Profiling %s. Close the app window to finish ' ... + 'the report...'], app.command); end function message = profileSuccessStatus(app, result) @@ -1390,6 +1390,15 @@ function selectTableRow(tableHandle, row, apps) char(result.relativeHtmlFile)); end +function [diagnostics, folder] = launcherDiagnosticOptions(root, app) + baseFolder = fullfile(root, 'artifacts', 'diagnostics', 'launcher', ... + safeFilename(app.command)); + ensureFolder(baseFolder); + folder = string(tempname(baseFolder)); + diagnostics = labkit.app.diagnostic.Options( ... + Level="verbose", ArtifactFolder=folder, Sample="synthetic"); +end + function message = cleanArtifactsStatus(result) if isempty(result.errors) message = sprintf('Cleaned %d generated artifact item(s).', result.removedCount); @@ -1562,10 +1571,15 @@ function selectTableRow(tableHandle, row, apps) function value = stringLiteralField(text, fieldName) value = ""; - pattern = ['"' char(fieldName) '"\s*,\s*"([^"]+)"']; - tokens = regexp(char(text), pattern, 'tokens', 'once'); - if ~isempty(tokens) - value = string(tokens{1}); + patterns = { ... + [char(fieldName) '\s*=\s*"([^"]+)"'], ... + ['"' char(fieldName) '"\s*,\s*"([^"]+)"']}; + for k = 1:numel(patterns) + tokens = regexp(char(text), patterns{k}, 'tokens', 'once'); + if ~isempty(tokens) + value = string(tokens{1}); + return; + end end end @@ -1831,7 +1845,7 @@ function reportLauncherProgress(progressFcn, message, value) %% Section: Performance profile action -function result = runLauncherAppProfile(root, app, debugMode) +function result = runLauncherAppProfile(root, app) profileTool = profileToolFile(root); if strlength(string(profileTool)) == 0 error('labkit_launcher:ProfilerUnavailable', ... @@ -1851,7 +1865,7 @@ function reportLauncherProgress(progressFcn, message, value) htmlFile = fullfile(outputRoot, sprintf('profile_%s_%s.html', ... safeFilename(app.command), datestr(now, 'yyyymmdd_HHMMSS'))); targetFile = fullfile(root, char(app.relativePath)); - target = @() launchProfileTarget(app, debugMode); + target = @() launchProfileTarget(app); [htmlFile, artifacts] = profileLabKitTarget(target, htmlFile, ... 'OpenReport', launcherGuiTestMode() ~= "hidden", ... 'WaitForGuiClose', true, ... @@ -1872,12 +1886,8 @@ function reportLauncherProgress(progressFcn, message, value) toolFile = matlabCodeFile(fullfile(root, 'tools', 'profiling', 'profileLabKitTarget')); end -function launchProfileTarget(app, debugMode) - if debugMode - feval(app.command, "debug"); - else - feval(app.command); - end +function launchProfileTarget(app) + feval(app.command); end function name = safeFilename(value) @@ -2465,23 +2475,6 @@ function notifyProgress(progressFcn, message, value) end end -function [request, dispatchArgs] = launcherStartupRequest(args, progressFcn, debugMode) - if ~isempty(args) - error('LabKit:Launcher:UnexpectedStartupArguments', ... - 'Launcher startup request does not accept additional arguments.'); - end - request = struct('startupProgress', progressFcn); - dispatchArgs = {}; - if debugMode - dispatchArgs = {"debug"}; - end -end - -function updateLauncherStartupProgress(dlg, message) - updateProgressDialog(dlg, char(string(message)), NaN); - drawnow limitrate; -end - function updateProgressDialog(dlg, message, value) if isempty(dlg) || ~isvalid(dlg) return; diff --git a/site/apps/dic/dic-postprocess.html b/site/apps/dic/dic-postprocess.html index 4bc88e656..4296e2be4 100644 --- a/site/apps/dic/dic-postprocess.html +++ b/site/apps/dic/dic-postprocess.html @@ -22,6 +22,7 @@

app

DIC Postprocess

+

Every action and input-selection button provides hover help describing its DIC inputs, ROI/strain processing, display-only effects, or exported evidence.

DIC Postprocess converts Ncorr strain fields into EXX and EYY overlays on an optical reference image and calculates descriptive strain statistics over a validated ROI. It is a rendering and summary tool; it does not rerun DIC.

Requirements And Launch

The app uses the LabKit UI framework and Image library. Input MAT files must contain the Ncorr result structure recognized by the app loader.

@@ -94,10 +95,10 @@
  • API Reference
  • Framework Compatibility

    -

    The single definition.m owns product metadata, requirements, layout, and optional runtime capabilities. projectSpec.m keeps the complete version-1 durable schema, creation defaults, and validation together. createSession.m rebuilds file-backed strain, image, mask, and overlay caches because those are transient runtime data rather than saved project fields. The App requires labkit.ui >=7 <8 and labkit.image >=2 <3; busy-state, optional source-slot lookup, and portable-reference serialization remain framework-owned.

    +

    The single definition.m owns product metadata, requirements, layout, and optional runtime capabilities. projectSpec.m keeps the complete version-1 durable schema, creation defaults, and validation together. createSession.m rebuilds file-backed strain, image, mask, and overlay caches because those are transient runtime data rather than saved project fields. The App requires labkit.app >=1 <2 and labkit.image >=2 <3; busy-state, optional source-slot lookup, and portable-reference serialization remain framework-owned.

    The project validator requires the DIC source collection and checks summary table and parameter fields; Runtime validates canonical buckets and each source record first.

    Its session factory returns only App-specific selection and decoded cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    The semantic layout follows the Runtime callback contract: every control and plot names its concrete callback or renderer, and the definition validates those bindings before creating a figure.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/dic/dic-preprocess.html b/site/apps/dic/dic-preprocess.html index fc8016fbe..106337cf3 100644 --- a/site/apps/dic/dic-preprocess.html +++ b/site/apps/dic/dic-preprocess.html @@ -22,6 +22,7 @@

    app

    DIC Preprocess

    +

    Every action and input-selection button provides hover help describing its registration, crop, binary ROI-mask, or downstream DIC effect.

    DIC Preprocess registers a moving optical image to a reference image, applies repeatable crop operations to the pair, and creates a binary analysis mask. Use it when camera motion or framing differences must be removed before an external DIC solver is run.

    Requirements And Launch

    The app uses the LabKit UI framework and Image library. Image IO and registration run in MATLAB; no external registration package is installed.

    @@ -88,11 +89,11 @@
  • API Reference
  • Framework Compatibility

    -

    The single definition.m owns product metadata, requirements, layout, and optional runtime capabilities. projectSpec.m keeps the complete durable version-1 schema, creation defaults, and validation together. createSession.m rebuilds decoded source images and replays applied alignment/crop steps because those images are transient caches rather than project data. The App requires labkit.ui >=7 <8 and labkit.image >=2 <3; busy-state, managed interactions, optional source-slot lookup, and portable-reference serialization remain framework-owned.

    +

    The single definition.m owns product metadata and the immutable App SDK contract. projectSpec.m keeps the complete durable version-1 schema, creation defaults, and validation together. createSession.m rebuilds decoded source images and replays applied alignment/crop steps because those images are transient caches rather than project data. The App requires labkit.app >=1 <2 and labkit.image >=2 <3; managed interactions, portable source resolution, lifecycle, and presentation reconciliation remain framework-owned.

    The project validator requires the DIC image-source collection and checks alignment, crop, mask, history, and preview fields; Runtime validates canonical buckets and each source record first.

    App-owned state operations are grouped by capability: editHistory owns align/crop undo and reset semantics, maskEditing owns mask canvas and mask undo semantics, and sourceFiles.hasImagePair reports whether the transient image cache is ready. There is no generic app-state service or alternate lifecycle layer.

    -

    Its session factory returns only App-specific editing workflow and decoded cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    Its session factory returns only App-specific editing workflow and decoded cache fields. Layout controls bind directly to capability-owned callbacks, while +workbench composes the complete layout and presentation.

    +

    The semantic layout follows the App callback contract: each control and managed interaction references its concrete callback directly and resolves during definition construction.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/electrochemistry/chrono-overlay.html b/site/apps/electrochemistry/chrono-overlay.html index 5dd53bba3..33038519c 100644 --- a/site/apps/electrochemistry/chrono-overlay.html +++ b/site/apps/electrochemistry/chrono-overlay.html @@ -22,12 +22,13 @@

    app

    Chrono Overlay

    +

    Every action and input-selection button provides hover help describing its chrono trace alignment, voltage/current data, or export effect.

    Chrono Overlay compares voltage and current transients from multiple Gamry DTA files on a common pulse-centered time axis and exports the aligned curves.

    Requirements And Launch

    -

    The app uses the LabKit UI framework and DTA library. Each source must contain a readable chrono curve with time, voltage, and current data.

    +

    The app uses the LabKit App SDK and DTA library. Each source must contain a readable chrono curve with time, voltage, and current data.

    labkit_ChronoOverlay_app

    Inputs

    -

    Use Add DTA files to select one or more .DTA files from one directory. The app parses each file as chrono data and reports unreadable items. The file list controls curve order, legend labels, and removal; selection does not discard other loaded curves. Runtime V2 reconciles durable source records with the successfully decoded list, preserving the identity of retained files and allocating collision-free identities after removal and later additions.

    +

    Use Add DTA files to select one or more .DTA files from one directory. The app parses each file as chrono data and reports unreadable items. The file list controls curve order, legend labels, and removal; selection does not discard other loaded curves. The App runtime owns durable source identities, portable project references, add/remove/clear behavior, and selection. It rebuilds the transient decoded session only when the file collection changes.

    Basic Workflow

    1. Add the DTA files to compare.
    2. @@ -67,8 +68,7 @@
    3. VT Resistance
    4. Framework Compatibility

      -

      The single definition.m owns product metadata, requirements, layout, and optional runtime capabilities. projectSpec.m owns the current version-2 domain schema plus one version-aware migration entry; its validator requires the App's source collection. Runtime V2 advances older payloads one version at a time and validates canonical buckets and each source record before the App checks its parameter rules. createSession.m rebuilds decoded DTA items and selection because curves are transient caches. Runtime supplies omitted empty workflow and view buckets. The App requires labkit.ui >=7 <8 and labkit.dta >=2 <3; busy-state, source identity, resolved-path access, and portable-reference serialization remain framework-owned.

      -

      The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

      Change history

      +

      definition.m returns one validated labkit.app.Definition. projectSpec.m returns the current version-2 labkit.app.project.Schema plus its version-aware migration entry. createSession(project,context) resolves the runtime-owned portable sources and rebuilds decoded DTA items because curves remain transient caches. Layout bindings provide all four plot parameters and the file collection without App callbacks or presenter duplication. +workbench/buildLayout.m binds CSV export directly to +resultFiles/exportSelectedCurves.m, while +overlayPlot/draw.m receives the voltage and current axes in declared order. The App requires labkit.app >=1 <2 and labkit.dta >=2 <3.

      Change history

      Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/electrochemistry/cic.html b/site/apps/electrochemistry/cic.html index 478c10c7f..a4ec74390 100644 --- a/site/apps/electrochemistry/cic.html +++ b/site/apps/electrochemistry/cic.html @@ -22,12 +22,13 @@

    app

    Charge-Injection Capacity

    +

    Every action and input-selection button provides hover help describing its pulse data, injected-charge/CIC result, or export effect.

    The CIC app measures charge delivered by a biphasic current pulse, normalizes charge by electrode area, and reports voltage-transient polarization metrics at a controlled delay after each pulse phase.

    Requirements And Launch

    The app uses the LabKit UI framework and DTA library and requires a chrono DTA curve with valid T, Vf, and Im columns.

    labkit_CIC_app

    Inputs And Batch Behavior

    -

    Add one or more chrono .DTA files. The selected row is decoded for immediate preview; batch calculation is performed with the same analysis settings when results are exported. This avoids repeatedly decoding every large file while the user is only switching previews. Runtime V2 reconciles the ordered path list with durable source records, so retained files keep stable identities and new files receive collision-free identities without an App-owned counter.

    +

    Add one or more chrono .DTA files. The selected row is decoded for immediate preview; batch calculation is performed with the same analysis settings when results are exported. This avoids repeatedly decoding every large file while the user is only switching previews. App SDK runtime reconciles the ordered path list with durable source records, so retained files keep stable identities and new files receive collision-free identities without an App-owned counter.

    Electrode area comes from a positive UI override when supplied, otherwise from the parsed DTA metadata. Without a valid positive area, charge in coulombs can still be calculated but area-normalized CIC fields are NaN.

    Basic Workflow

      @@ -78,8 +79,8 @@
    1. API Reference
    2. Framework Compatibility

      -

      The single definition.m owns product metadata, requirements, layout, and optional runtime capabilities. projectSpec.m owns the complete version-1 domain schema, defaults, parameter validation, and the required source collection; Runtime validates canonical buckets and each source record first. createSession.m deliberately decodes only the first source for immediate preview; remaining batch files stay lazy until selection or export. The App requires labkit.ui >=7 <8 and labkit.dta >=2 <3; Runtime also supplies omitted empty session buckets and owns workflow-log initialization. Busy-state, source identity, resolved-path access, and portable-reference serialization remain framework-owned.

      -

      The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

      Change history

      +

      The single definition.m owns product metadata, requirements, layout, and optional runtime capabilities. projectSpec.m owns the complete version-1 domain schema, defaults, parameter validation, and the required source collection; Runtime validates canonical buckets and each source record first. createSession.m deliberately decodes only the first source for immediate preview; remaining batch files stay lazy until selection or export. The App requires labkit.app >=1 <2 and labkit.dta >=2 <3; Runtime also supplies omitted empty session buckets and owns workflow-log initialization. Busy-state, source identity, resolved-path access, and portable-reference serialization remain framework-owned.

      +

      The semantic layout follows the Runtime callback contract: every control and plot names its concrete callback or renderer, and the definition validates those bindings before creating a figure.

      Change history

      Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/electrochemistry/csc.html b/site/apps/electrochemistry/csc.html index bbd39d4cc..12f14f860 100644 --- a/site/apps/electrochemistry/csc.html +++ b/site/apps/electrochemistry/csc.html @@ -22,12 +22,13 @@

    app

    Charge-Storage Capacity

    +

    Every action and input-selection button provides hover help describing its CV/CT data, charge-storage comparison, or export effect.

    The CSC app compares charge obtained from time-domain current integration with charge obtained from cyclic-voltammetry integration for every readable CV/CT cycle in one or more Gamry DTA files.

    Requirements And Launch

    The app uses the LabKit UI framework and DTA library. Each analyzed cycle must expose exact T, Vf, and Im columns and a positive scan rate.

    labkit_CSC_app

    Inputs And Selection

    -

    Add one or more CV/CT .DTA files. The selected file determines the current curve list, readout, and plots. Selecting another file resets the curve selection and default plot quantities to that file; it does not silently keep a cycle from the previous source. Runtime V2 reconciles durable source identities from the successfully decoded file order, preserving retained identities through removal, later additions, save, and reopen.

    +

    Add one or more CV/CT .DTA files. The selected file determines the current curve list, readout, and plots. Selecting another file resets the curve selection and default plot quantities to that file; it does not silently keep a cycle from the previous source. App SDK runtime reconciles durable source identities from the successfully decoded file order, preserving retained identities through removal, later additions, save, and reopen.

    The default curve selection is All cycles. Individual cycle selection updates the comparison readout for that cycle.

    Basic Workflow

      @@ -78,8 +79,8 @@
    1. API Reference
    2. Framework Compatibility

      -

      The single definition.m owns product metadata, requirements, layout, and optional runtime capabilities. projectSpec.m owns the complete version-1 domain schema, defaults, parameter/result validation, and the required source collection; Runtime validates canonical buckets and each source record first. createSession.m rebuilds decoded CV/CT curves and active selection because they are transient runtime data. The App omits empty workflow and view buckets because Runtime canonicalizes them. It requires labkit.ui >=7 <8 and labkit.dta >=2 <3; busy-state, source identity, resolved-path access, and portable-reference serialization remain framework-owned.

      -

      The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

      Change history

      +

      The single definition.m owns product metadata, requirements, layout, and optional runtime capabilities. projectSpec.m owns the complete version-1 domain schema, defaults, parameter/result validation, and the required source collection; Runtime validates canonical buckets and each source record first. createSession.m rebuilds decoded CV/CT curves and active selection because they are transient runtime data. The App omits empty workflow and view buckets because Runtime canonicalizes them. It requires labkit.app >=1 <2 and labkit.dta >=2 <3; busy-state, source identity, resolved-path access, and portable-reference serialization remain framework-owned.

      +

      The semantic layout follows the Runtime callback contract: every control and plot names its concrete callback or renderer, and the definition validates those bindings before creating a figure.

      Change history

      Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/electrochemistry/eis.html b/site/apps/electrochemistry/eis.html index a21406bfd..9ebc7965b 100644 --- a/site/apps/electrochemistry/eis.html +++ b/site/apps/electrochemistry/eis.html @@ -22,12 +22,13 @@

    app

    EIS

    +

    Every action and input-selection button provides hover help describing its impedance input, plotted quantities, or exported EIS data.

    EIS overlays impedance data from one or more Gamry ZCURVE tables, supports Nyquist and Bode-style axis combinations, and exports the values currently selected for plotting.

    Requirements And Launch

    The app uses the LabKit UI framework and DTA library.

    labkit_EIS_app

    Inputs

    -

    Add one or more .DTA files containing a readable EIS ZCURVE. Files that do not contain the required curve are reported and omitted from the plot. The source list is preserved in project state through portable references. Runtime V2 reconciles those records with the successfully decoded file list, preserves the identity of files that remain loaded, and assigns unique identities to new files; EIS does not maintain its own source-ID counter.

    +

    Add one or more .DTA files containing a readable EIS ZCURVE. Files that do not contain the required curve are reported and omitted from the plot. The source list is preserved in project state through portable references. The App SDK runtime reconciles those records with the successfully decoded file list, preserves the identity of files that remain loaded, and assigns unique identities to new files; EIS does not maintain its own source-ID counter.

    Basic Workflow

    1. Add the EIS DTA files.
    2. @@ -66,8 +67,8 @@
    3. API Reference
    4. Framework Compatibility

      -

      The single definition.m owns product metadata, requirements, layout, and optional runtime capabilities. projectSpec.m owns the complete version-1 domain schema, defaults, plot-parameter validation, and the required source collection; Runtime validates canonical buckets and each source record first. createSession.m rebuilds decoded ZCURVE items and selected paths because they are transient runtime data. Empty workflow and view buckets are supplied by Runtime V2 rather than repeated in the App factory. The App requires labkit.ui >=7 <8 and labkit.dta >=2 <3; busy-state, viewport-preserving rendering, resolved-path access, and portable-reference serialization remain framework-owned.

      -

      The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

      Change history

      +

      The single definition.m owns product metadata, requirements, layout, and optional runtime capabilities. projectSpec.m owns the complete version-1 domain schema, defaults, plot-parameter validation, and the required source collection; Runtime validates canonical buckets and each source record first. createSession.m rebuilds decoded ZCURVE items and selected paths because they are transient runtime data. Empty workflow and view buckets are supplied by App SDK runtime rather than repeated in the App factory. The App requires labkit.app >=1 <2 and labkit.dta >=2 <3; busy-state, viewport-preserving rendering, resolved-path access, and portable-reference serialization remain framework-owned.

      +

      The semantic layout follows the Runtime callback contract: every control and plot names its concrete callback or renderer, and the definition validates those bindings before creating a figure.

      Change history

      Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/electrochemistry/index.html b/site/apps/electrochemistry/index.html index 0e3a1053c..157ce645e 100644 --- a/site/apps/electrochemistry/index.html +++ b/site/apps/electrochemistry/index.html @@ -18,7 +18,7 @@
    - +

    app family

    Electrochemistry Apps

    @@ -38,7 +38,7 @@
  • DTA Library
  • API Reference
  • All Apps
  • - +
    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/electrochemistry/vt-resistance.html b/site/apps/electrochemistry/vt-resistance.html index ce64b7afc..7e5717f3b 100644 --- a/site/apps/electrochemistry/vt-resistance.html +++ b/site/apps/electrochemistry/vt-resistance.html @@ -22,12 +22,13 @@

    app

    VT Resistance

    +

    Every action and input-selection button provides hover help describing its pulse voltage/current input, resistance result, or export effect.

    VT Resistance estimates cathodic and anodic steady resistance from a biphasic voltage transient and reports the mean of their absolute values.

    Requirements And Launch

    -

    The app uses the LabKit UI framework and DTA library and requires a chrono DTA curve with valid time, voltage, and current columns.

    +

    The app uses the LabKit App framework and DTA library and requires a chrono DTA curve with valid time, voltage, and current columns.

    labkit_VTResistance_app

    Inputs And Batch Behavior

    -

    Add one or more chrono .DTA files. The selected file is decoded and analyzed for preview; exporting applies the current settings to the full source list. No electrode-area normalization is performed because the reported quantity is electrical resistance in ohms. Runtime V2 reconciles the ordered lazy path list with durable source records, preserving retained identities and allocating collision-free identities for later additions.

    +

    Add one or more chrono .DTA files. The transient session decodes and analyzes the registered batch so shared setting changes update every result together. No electrode-area normalization is performed because the reported quantity is electrical resistance in ohms. The App runtime reconciles the ordered source list with durable source records, preserving retained identities and allocating collision-free identities for later additions.

    Basic Workflow

    1. Add files and select a representative transient.
    2. @@ -73,8 +74,7 @@
    3. API Reference
    4. Framework Compatibility

      -

      The single definition.m owns product metadata, requirements, layout, and optional runtime capabilities. projectSpec.m owns the complete version-1 domain schema, defaults, analysis-parameter validation, and the required source collection; Runtime validates canonical buckets and each source record first. createSession.m deliberately decodes only the first source for preview; the remaining batch stays lazy until selection or export. The App requires labkit.ui >=7 <8 and labkit.dta >=2 <3; Runtime supplies omitted empty session buckets and owns workflow-log initialization. Busy-state, source identity, resolved-path access, and portable-reference serialization remain framework-owned.

      -

      The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

      Change history

      +

      The single definition.m owns product metadata and the immutable App SDK contract. projectSpec.m owns the complete version-1 domain schema, defaults, analysis-parameter validation, and required source collection. +workbench/buildLayout.m binds fields and buttons directly to concrete capability callbacks, while +workbench/present.m produces a complete labkit.app.view.Snapshot. The App requires labkit.app >=1 <2 and labkit.dta >=2 <3. Lifecycle, callback dispatch, source identity, resolved-path access, project documents, result manifests, and native layout remain framework-owned.

      Change history

      Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/gait/gait-analysis.html b/site/apps/gait/gait-analysis.html index eef3a889d..a455e5a3d 100644 --- a/site/apps/gait/gait-analysis.html +++ b/site/apps/gait/gait-analysis.html @@ -22,6 +22,7 @@

    app

    Gait Analysis

    +

    Every action and input-selection button provides hover help describing its pose input, detected-step navigation, gait calculation, or export effect.

    Gait Analysis 2 converts a current Video Marker project into independently segmented treadmill swing steps, per-frame kinematics, per-step gait parameters, visual step reports, and reproducible CSV outputs. Loading and analysis are deliberately separate: loading first shows the complete tracked skeleton trajectory; analysis then enables one-step-at-a-time review.

    Open Gait Analysis

    From the LabKit launcher, select Gait Analysis and choose Open. From a source checkout, run:

    @@ -39,8 +40,8 @@

    Input Contract

    The app stores this input in the standard project inputs.sources collection. Version 2 Gait Analysis projects are upgraded on load from the former singular inputs.source field; the next save writes project payload version 3.

    Coordinates use image convention: the origin is at the upper left and Y increases downward. The skeleton preview preserves that convention. Angle and length time series use conventional plot axes.

    Project And Session State

    -

    gait_analysis.projectSpec owns durable schema version 3, project creation and validation, and the single migration entry for versions 1 and 2. Version 1 renames the legacy step/stride options and invalidates results whose scientific meaning changed. Version 2 moves its singular source into the canonical source collection. Runtime V2 selects each missing step and validates the final project.

    -

    The pose source, analysis options, computed tables/events, and export record are durable. Decoded pose data, selected step, output-folder convenience, workflow log, and duplicate-run fingerprint are transient and rebuilt by gait_analysis.createSession. Source paths are resolved by the Runtime before session construction and are read through sourcePaths.

    +

    gait_analysis.projectSpec owns durable schema version 3, project creation and validation, and the single migration entry for versions 1 and 2. Version 1 renames the legacy step/stride options and invalidates results whose scientific meaning changed. Version 2 moves its singular source into the canonical source collection. The App runtime selects each missing step and validates the final project.

    +

    The pose source, analysis options, computed tables/events, and export record are durable. Decoded pose data, selected step, output-folder convenience, workflow log, and duplicate-run fingerprint are transient and rebuilt by gait_analysis.createSession. Source paths are resolved by the Runtime before session construction through the sealed labkit.app.CallbackContext.

    Two-Stage Workflow

    1. Load And Inspect All Trajectories

    Choose Open Video Marker MAT. Before any analysis runs, the workspace overlays every recorded skeleton and each named point trajectory. This view is intended to expose gross tracking failures, missing landmarks, unexpected skeleton order, and coordinate orientation before derived numbers are produced.

    @@ -106,12 +107,11 @@
  • Gait apps
  • Framework Compatibility

    -

    This App requires labkit.ui >=7 <8. Its single definition.m owns product metadata, requirements, layout, actions, presentation, renderer, and debug capability. projectSpec.m concentrates durable creation, validation, and both historical migration steps; root createSession.m rebuilds transient decoded pose state.

    +

    This App requires labkit.app >=1 <2. Its single definition.m owns product metadata and the immutable App SDK contract. projectSpec.m concentrates durable creation, validation, and both historical migration steps; root createSession.m rebuilds transient decoded pose state.

    The project validator requires the pose-project source collection and checks gait options, numeric limits, and result fields; Runtime validates canonical buckets and each source record first.

    -

    Analysis defaults, source-fact normalization, result construction, duplicate run fingerprints, and gait calculations are co-located under +analysisRun. There is no generic App lifecycle or state package. Migration iteration, portable source references, callback queues, busy state, source relinking, and serialization remain framework-owned.

    +

    Analysis defaults, source-fact normalization, result construction, duplicate run fingerprints, and gait calculations are co-located under +analysisRun. The +workbench package assembles capability-owned callbacks and a complete snapshot; analysis, step navigation, gait rendering, source adoption, and result export remain with their semantic owners. Migration iteration, portable source references, callback queues, source relinking, project documents, result manifests, and serialization remain framework-owned.

    Its synthetic debug fixture writes the documented Video Marker payload shape without loading a sibling App package. A separate producer-consumer integration test builds a project with the current Video Marker contract and reads the saved MAT through Gait, so producer drift is detected without making the consumer's normal launch depend on Video Marker source code.

    -

    Its session factory returns only App-specific step selection, output-folder workflow, and decoded pose cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    Its session factory returns only App-specific step selection, output-folder workflow, and decoded pose cache fields. Layout controls bind directly to concrete semantic callbacks; there is no App-authored action or renderer registry.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/gait/index.html b/site/apps/gait/index.html index d594fbffd..4847a2440 100644 --- a/site/apps/gait/index.html +++ b/site/apps/gait/index.html @@ -18,7 +18,7 @@
    - +

    app family

    Gait Apps

    @@ -40,7 +40,7 @@
  • Image Measurement apps
  • App catalog
  • App Framework
  • - +
    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/batch-crop.html b/site/apps/image-measurement/batch-crop.html index c35772696..b61c5dd60 100644 --- a/site/apps/image-measurement/batch-crop.html +++ b/site/apps/image-measurement/batch-crop.html @@ -22,6 +22,7 @@

    app

    Batch Image Crop

    +

    Every action and input-selection button provides hover help describing its crop task, geometry, physical calibration, scale bar, or export effect.

    Batch Image Crop defines one crop task per image, previews rotation and edge-continuous padding, and exports repeatable same-size crops in pixel or physical-scale mode.

    Requirements And Launch

    The app uses the LabKit UI framework and Image library.

    @@ -49,10 +50,10 @@

    Scale Bar

    Outputs

    Exports support PNG, TIFF, and JPEG. Each task produces one image at the planned output dimensions. The crop manifest records source, task identity, center, rotation, padding, source/output scale, requested physical geometry, format, and output filename. A second LabKit result JSON records project-wide parameters and identifies each output file.

    Project And State

    -

    Saved projects use durable schema version 2. inputs.sources contains the framework's portable source records, while inputs.items contains the crop tasks that refer to those sources by sourceId. Crop dimensions, physical scale settings, output format, output folder, and scale-bar choices are durable project parameters. Loaded image pixels, the current selection, interaction flags, preview graphics, and rotated-canvas caches are transient session state and are reconstructed after load.

    -

    Version-1 projects are upgraded by the single migration entry in batch_crop.projectSpec. It removes embedded image pixels, converts item paths to source records, and preserves each task's crop center, rotation, padding, and calibration. Missing required sources are handled by the Runtime's shared source-reconciliation workflow rather than by Batch Crop-specific path code.

    +

    Saved projects use durable schema version 3. inputs.sources contains one portable source record per crop task, while inputs.items contains the aligned task values that refer to those records by sourceId. Repeated crop tasks may therefore keep distinct source identities while resolving to the same image path. Crop dimensions, physical scale settings, output format, output folder, and scale-bar choices are durable project parameters. Loaded image pixels, the current selection, interaction flags, preview graphics, and rotated-canvas caches are transient session state and are reconstructed after load.

    +

    Version-1 and version-2 projects are upgraded sequentially by the single migration entry in batch_crop.projectSpec. It removes embedded image pixels, converts item paths to source records, expands shared records into aligned task records, and preserves each task's crop center, rotation, padding, and calibration. Missing required sources are handled by the Runtime's shared source-reconciliation workflow rather than by Batch Crop-specific path code.

    Use Without The GUI

    -
    calibration = labkit.ui.interaction.scaleBarCalibration(20, 10, "um");
    +
    calibration = labkit.app.interaction.scaleCalibration(20, 10, "um");
     items = struct("scaleCalibration", calibration);
     physicalOptions = struct( ...
         "physicalWidth", 5, "physicalHeight", 3, ...
    @@ -80,12 +81,12 @@ 
     
  • API Reference
  • Framework Compatibility

    -

    This App requires labkit.ui >=7 <8 and labkit.image >=2 <3. Its single definition.m owns product metadata, dependencies, layout, actions, presentation, renderers, and optional capabilities. Durable creation, validation, and migration are concentrated in projectSpec.m; root createSession.m reconstructs transient state and lazily loads only the first selected image.

    +

    This App requires labkit.app >=1 <2 and labkit.image >=2 <3. Its single definition.m owns product metadata and the immutable App SDK contract. Durable creation, validation, and migration are concentrated in projectSpec.m; root createSession.m resolves task sources, reconstructs transient state, and lazily loads only the selected image.

    The project validator requires the App's item and source collections, validates their relationship and crop parameters, and leaves canonical bucket and source record shape to Runtime.

    -

    Workflow helpers are owned by the capabilities they describe: +sourceFiles, +cropTasks, +cropGeometry, +scaleCalibration, +resultFiles, and +userInterface. There is no generic App lifecycle or state package. Busy state, source serialization, migration iteration, and portable-path reconciliation remain framework responsibilities.

    -

    Variable-length crop manifest outputs begin with the framework's canonical empty output array, so zero-result and multi-result exports never construct an invalid placeholder ID.

    -

    Its session factory returns only App-specific selection, crop workflow, view, and image-cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    Workflow helpers are owned by the capabilities they describe: +sourceFiles, +cropTasks, +cropGeometry, +cropPreview, +scaleCalibration, and +resultFiles; +workbench is the only product assembly boundary. There is no generic App lifecycle, state, handler, or renderer registry. Source serialization, migration iteration, and portable-path resolution remain framework responsibilities.

    +

    Each durable crop task owns one portable source identity. Duplicate tasks may resolve to the same image path, remain independently selectable, and can be removed without removing their siblings.

    +

    Its session factory returns only App-specific selection, crop workflow, view, resolved task paths, and image-cache fields. Runtime owns lifecycle and workflow status.

    +

    The semantic layout follows the App Framework: controls and managed interactions reference their concrete capability-owned callbacks directly and resolve during definition construction.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/curvature.html b/site/apps/image-measurement/curvature.html index e60d39ef1..f9a7dd21a 100644 --- a/site/apps/image-measurement/curvature.html +++ b/site/apps/image-measurement/curvature.html @@ -22,20 +22,24 @@

    app

    Curvature Measurement

    +

    Every action and input-selection button provides hover help describing its curve trace, calibration, curvature/length calculation, or export effect.

    Curvature Measurement fits a circle to an ordered image curve, reports radius and curvature, measures traced arc length, and supports pixel-to-physical scale calibration.

    Requirements And Launch

    The app uses the LabKit UI framework and Image library.

    labkit_CurvatureMeasurement_app

    Basic Workflow

    +

    The Files + Analysis tab contains the complete workflow:

      -
    1. Choose an image.
    2. -
    3. Measure a known scale reference and enter its length/unit when physical values are required.
    4. -
    5. Start curve editing and place ordered anchors along the feature.
    6. -
    7. Drag anchors to refine the trace; undo or clear as needed.
    8. -
    9. Fit the circle and measure curve length.
    10. +
    11. Choose an image in the Image section.
    12. +
    13. Use Measure reference pixels, or enter Reference pixels directly, then enter the known reference length and unit.
    14. +
    15. Configure and place the optional display scale bar.
    16. +
    17. Use Start curve edit and place ordered anchors along the feature.
    18. +
    19. Drag anchors to refine the trace; undo or clear as needed, then finish curve editing.
    20. +
    21. Choose the densification settings, fit the circle, and measure curve length.
    22. Export the result CSV and overlay PNG.
    -

    The canvas title/subtitle identifies curve-edit mode and its placement/removal gesture. Anchor edits and result overlays preserve the current axes zoom.

    +

    The edit buttons change to Finish curve edit or Finish reference edit while their managed interaction is active. Curve and reference edits are mutually exclusive. Anchor edits and result overlays preserve the current axes zoom.

    +

    The Summary + Results tab reports curve length, radius, curvature, RMSE, fit center, and pixels per selected unit. Details explains the next valid step before a result exists and reports the current measurement afterward. The Log tab records file, edit, fit, calibration, and export actions.

    The chosen image is stored in the standard project inputs.sources collection. Version 1 projects using the former singular inputs.source field are upgraded on load and saved as payload version 2.

    Curve Editing

    Curve points are ordered by placement. Undo last point removes the newest anchor and Clear curve removes the complete trace. Neighboring duplicate points are removed before numeric fitting. At least three distinct points are required for a circle fit; length requires at least two.

    @@ -47,7 +51,7 @@

    Fit Parameters And Semantics

    Scale Calibration And Bar

    Measure the pixel distance spanning a known reference, enter its physical length, and choose m, cm, mm, um, or nm. Calibration stores reference pixels, reference length, unit, and pixels per unit. Scale-bar length defaults to 100 units; position defaults to Bottom right and color to Black. Placing or moving the display bar does not modify the fit.

    Outputs

    -

    The CSV records point count, fit settings, center, radius, curvature, traced length, calibration, units, and status. The overlay PNG contains the source image, curve, fitted circle, and configured scale bar when available. A result manifest records source and parameter provenance.

    +

    The CSV records point count, fit settings, center, radius, curvature, traced length, calibration, units, and status. The overlay PNG contains the source image, ordered curve, optional dense samples, fit residuals, fitted circle, center, and configured scale bar when available. Each export writes its standard result manifest with source and parameter provenance.

    Use Without The GUI

    points = [100 80; 130 58; 165 52; 200 66; 225 95];
     fit = curvature.analysisRun.computeCurvatureFit(points, ...
    @@ -66,11 +70,12 @@ 
     
  • API Reference
  • Framework Compatibility

    -

    The single definition.m owns product metadata, requirements, layout, actions, presentation, renderer, and debug-sample capability. projectSpec.m is the only durable-project entry and keeps current creation, validation, and the version-1 source migration together. Runtime V2 owns the migration loop. Root createSession.m reconstructs the decoded image and transient edit state after source relinking.

    +

    The single definition.m owns product metadata, requirements, the composed workbench, project/session boundaries, presentation, and debug-sample capability. +workbench/buildLayout.m composes source selection, curve editing, scale calibration, analysis/export, summary, log, and preview surfaces from their owning capability packages. Layout nodes bind their concrete callbacks and renderer directly; the App has no handler or renderer registry.

    +

    projectSpec.m is the only durable-project entry and keeps current creation, validation, and the version-1 source migration together. The runtime owns the migration loop. Root createSession.m reconstructs the decoded image and transient edit state after source relinking.

    The project validator requires the image-source collection and checks curvature parameters, annotations, and results; Runtime validates canonical buckets and each source record first.

    -

    Fit/length result shapes and deterministic task fingerprints live with their calculations under +analysisRun; there is no generic +appState package. The App requires labkit.ui >=7 <8 and labkit.image >=2 <3; source-path access, persistence, callback lifetime, and managed anchor interactions remain framework-owned.

    +

    Fit/length result shapes and deterministic task fingerprints live with their calculations under +analysisRun; there is no generic state or action package. The App requires labkit.app >=1 <2 and labkit.image >=2 <3; source-path access, persistence, callback lifetime, result manifests, and managed anchor/reference interactions remain framework-owned.

    Its session factory returns only App-specific edit workflow, scale-bar view, and decoded image cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    The semantic layout follows the App framework contract: callbacks name the complete application state, typed event value when present, and CallbackContext at their direct boundary, then delegate scientific work through narrow inputs.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/flir-thermal.html b/site/apps/image-measurement/flir-thermal.html index 5c7c53ab8..82f854211 100644 --- a/site/apps/image-measurement/flir-thermal.html +++ b/site/apps/image-measurement/flir-thermal.html @@ -22,6 +22,7 @@

    app

    FLIR Thermal

    +

    Every action and input-selection button provides hover help describing its radiometric input, calibrated temperature statistic, color mapping, or export.

    FLIR Thermal decodes radiometric FLIR JPEG/RJPEG files, displays calibrated temperature maps, measures rectangular ROI hot/cold/mean values, and exports rendered images with Celsius data.

    Requirements And Launch

    The app uses the LabKit UI framework and Thermal library. A .jpg, .jpeg, or .rjpg extension alone is insufficient; the file must contain a readable FLIR raw thermal record and calibration metadata.

    @@ -33,8 +34,9 @@

    Basic Workflow

  • Load radiometric files and inspect the decoded min/max/metadata summary.
  • Choose a palette and color mapping.
  • Set a range preset, per-image range, or shared group range.
  • -
  • Place a rectangular ROI and choose hot spot, cold spot, or mean reading.
  • -
  • Review the numeric result and marker.
  • +
  • Choose ROI hot spot, ROI cold spot, or ROI mean, then drag its rectangle.
  • +
  • Optionally click the image for an independent manual point reading.
  • +
  • Review the numeric results and markers.
  • Export the current image or the full batch.
  • Placing or dragging a reading ROI and refreshing its marker preserves the current zoom. The reading is recalculated from the thermal matrix, not from screen colors.

    @@ -47,9 +49,9 @@

    Display Controls

    ROI Readings

    The rectangular ROI includes both boundary pixels after its two corners are rounded and clamped to the thermal matrix. Hot and cold readings report value and [x y] location. Mean ignores non-finite pixels and also records ROI geometry and finite pixel count. The marker is a display annotation; moving it does not change source data.

    Outputs

    -

    Current or batch export can write PNG, TIFF, or JPEG rendered thermal images, color scale graphics, Celsius matrices/tables, measurement values, and a manifest. The clean image export excludes interactive toolbar chrome. Numeric temperature outputs remain Celsius regardless of palette or mapping mode.

    +

    Current or batch export writes PNG, TIFF, or JPEG rendered thermal images, matching color scale graphics, Celsius CSV matrices, measurement values, a batch CSV summary, and the standard flir_thermal.labkit.json result manifest. The clean image export excludes interactive toolbar chrome. Numeric temperature outputs remain Celsius regardless of palette or mapping mode.

    Project And State

    -

    Saved projects keep portable source references, display parameters, export settings, and lightweight per-image ranges and readings. Raw sensor matrices and decoded Celsius matrices are transient session data: Runtime V2 resolves the source references and the App decodes only the selected image again when a project opens. Missing source files therefore use the framework's relinking flow rather than embedding local absolute paths in the project. An existing source that is no longer a readable radiometric file aborts the restore and preserves the current document. Batch import may still report and skip rejected selections before they become project sources.

    +

    Saved projects keep portable source references, display parameters, export settings, and lightweight per-image ranges and readings. Raw sensor matrices and decoded Celsius matrices are transient session data: App SDK runtime resolves the source references and the App decodes only the selected image again when a project opens. Missing source files therefore use the framework's relinking flow rather than embedding local absolute paths in the project. An existing source that is no longer a readable radiometric file aborts the restore and preserves the current document. Batch import may still report and skip rejected selections before they become project sources.

    An empty launch does not choose an output directory. Adding files establishes the source-adjacent default; Choose folder remains available before export.

    Use Without The GUI

    record = labkit.thermal.readFile("capture.rjpg");
    @@ -78,11 +80,10 @@ 
     
  • API Reference
  • Framework Compatibility

    -

    The single definition.m owns product metadata, requirements, layout, actions, presentation, renderers, and debug-sample capability. projectSpec.m is the only durable-project entry; the version-1 payload needs creation and validation but no migration. Root createSession.m rebuilds only the selected decoded thermal item after Runtime V2 resolves sources.

    +

    definition.m is the App composition root. It declares product metadata, requirements, project/session/presentation callbacks, the composed workbench, and debug-sample capability through labkit.app.Definition. +workbench/buildLayout.m is the visible product assembly boundary: source navigation, display mapping, reading tools, exports, and preview are composed from their owning capability packages. projectSpec.m is the only durable-project schema entry; the version-1 payload needs creation and validation but no migration. Root createSession.m rebuilds only the selected decoded thermal item after the runtime resolves portable sources.

    The project validator requires the thermal-source collection and checks thermal parameters and annotations; Runtime validates canonical buckets and each source record first.

    -

    Decoded record shape lives with +sourceFiles, point and ROI calculations live with +analysisRun, and lightweight durable readings live with +thermalAnnotations; there is no generic +appState package. The App requires labkit.ui >=7 <8, labkit.image >=2 <3, and labkit.thermal >=1.1 <2. Source-path access, persistence, callback lifetime, busy state, and managed region interaction remain framework-owned. Thermal image, colorbar, and CSV manifest outputs are appended to the framework's canonical empty output array; no invalid placeholder result is created before batch export.

    -

    Its session factory returns only App-specific image selection and decoded thermal cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    Decoded record shape lives with +sourceFiles, point and ROI calculations live with +analysisRun, and lightweight durable readings live with +thermalAnnotations; there is no generic +appState package. The App requires labkit.app >=1 <2, labkit.image >=2 <3, and labkit.thermal >=1.1 <2. Runtime callbacks name the complete application state and injected labkit.app.CallbackContext explicitly. Source-path access, persistence, callback lifetime, diagnostic recording, managed region interaction, render surfaces, and result-manifest writing remain framework-owned.

    +

    Its session factory returns only App-specific image selection and decoded thermal cache fields. Presentation produces one complete labkit.app.view.Snapshot; controls bind directly to concrete semantic callbacks and the paired preview owns its renderer and managed reading interaction. No App-authored handler or renderer registry remains.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/focus-stack.html b/site/apps/image-measurement/focus-stack.html index de2d763f4..f05e4cf0e 100644 --- a/site/apps/image-measurement/focus-stack.html +++ b/site/apps/image-measurement/focus-stack.html @@ -22,6 +22,7 @@

    app

    Focus Stack

    +

    Every action and input-selection button provides hover help describing its z-stack input, local-focus fusion, depth map, or export effect.

    Focus Stack fuses at least two focal planes into one all-in-focus image using multilevel Laplacian focus evidence and exports a focus-depth index map.

    Requirements And Launch

    The app uses the LabKit UI framework and Image library. Inputs should show the same field of view at different focus positions.

    @@ -75,11 +76,11 @@
  • API Reference
  • Framework Compatibility

    -

    The single definition.m owns product metadata, requirements, layout, actions, presentation, renderers, and debug-sample capability. projectSpec.m is the only durable-project entry; the version-1 project needs creation and validation but no migration. Root createSession.m rebuilds decoded images after Runtime V2 resolves sources.

    +

    The single definition.m owns product metadata, requirements, layout, actions, presentation, renderers, and debug-sample capability. projectSpec.m is the only durable-project entry; the version-1 project needs creation and validation but no migration. Root createSession.m rebuilds decoded images after the App SDK runtime resolves sources.

    The project validator requires the image-source collection and checks fusion parameters; Runtime validates canonical buckets and each source record first.

    -

    Fusion result defaults, preset values, and deterministic run fingerprints live with the computation under +analysisRun; there is no generic +appState package. A new empty project performs no App-specific startup callback and chooses an output location only after sources are added or the user exports. The App requires labkit.ui >=7 <8 and labkit.image >=2 <3; source-path access, persistence, busy state, and debug lifecycle remain framework-owned.

    +

    Fusion result defaults, preset values, and deterministic run fingerprints live with the computation under +analysisRun; there is no generic +appState package. A new empty project performs no App-specific startup callback and chooses an output location only after sources are added or the user exports. The App requires labkit.app >=1 <2 and labkit.image >=2 <3; source-path access, persistence, busy state, and debug lifecycle remain framework-owned.

    Its session factory returns only App-specific registration workflow and image cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    The semantic layout follows the Runtime callback contract: every control and plot names its concrete callback or renderer, and the definition validates those bindings before creating a figure.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/image-enhance.html b/site/apps/image-measurement/image-enhance.html index e8e5e8b68..8b8eb702e 100644 --- a/site/apps/image-measurement/image-enhance.html +++ b/site/apps/image-measurement/image-enhance.html @@ -22,6 +22,7 @@

    app

    Image Enhance

    +

    Every action and input-selection button provides hover help describing its pixel-processing history, white-balance ROI, or batch export effect.

    Image Enhance builds an ordered, reversible processing history for one image or a batch and exports the resulting images with the exact step sequence.

    Requirements And Launch

    The app uses the LabKit UI framework and Image library. All processing is implemented with MATLAB and repository-owned code.

    @@ -55,7 +56,7 @@

    Use Without The GUI

    output = image_enhance.analysisRun.applyPipeline(imread("source.png"), steps); imwrite(output{1}, "enhanced.png");

    Project And State

    -

    Saved projects keep portable source references, shared and per-image step histories, white-reference ROIs, export settings, and compact result metadata. Decoded full-size pixels and downsampled previews remain transient. Runtime V2 resolves source references first; createSession.m then rebuilds only the selected source and its preview when a project opens.

    +

    Saved projects keep portable source references, shared and per-image step histories, white-reference ROIs, export settings, and compact result metadata. Decoded full-size pixels and downsampled previews remain transient. App SDK runtime resolves source references first; createSession.m then rebuilds only the selected source and its preview when a project opens.

    An empty launch does not choose an output directory. Adding images establishes the source-adjacent default; Choose folder remains available before export.

    Errors And Limitations

    Framework Compatibility

    -

    The single definition.m owns product metadata, requirements, layout, actions, presentation, renderer, and debug-sample capability. projectSpec.m is the only durable-project entry; the version-1 payload needs creation and validation but no migration. Root createSession.m rebuilds the selected image cache after Runtime V2 resolves sources.

    +

    The single definition.m owns product metadata, requirements, layout, actions, presentation, renderer, and debug-sample capability. projectSpec.m is the only durable-project entry; the version-1 payload needs creation and validation but no migration. Root createSession.m rebuilds the selected image cache after App SDK runtime resolves sources.

    The project validator requires the image-source collection and checks export, shared-history, and per-image annotation relationships; Runtime validates canonical buckets and each source record first.

    -

    Decoded items and lazy preview loading live with +sourceFiles; step shapes, active histories, pipeline replay, and preview-coordinate scaling live with +analysisRun; durable per-image histories live with +enhancementAnnotations; export fingerprints live with +resultFiles. There is no generic +appState package. The App requires labkit.ui >=7 <8 and labkit.image >=2 <3; persistence, source-path access, busy state, and managed ROI interaction remain framework-owned. Batch manifest outputs use the framework's canonical empty output array, so export validation applies only to real enhanced-image records.

    +

    Decoded items and lazy preview loading live with +sourceFiles; step shapes, active histories, pipeline replay, and preview-coordinate scaling live with +analysisRun; durable per-image histories live with +enhancementAnnotations; export fingerprints live with +resultFiles. There is no generic +appState package. The App requires labkit.app >=1 <2 and labkit.image >=2 <3; persistence, source-path access, busy state, and managed ROI interaction remain framework-owned. Batch manifest outputs use the framework's canonical empty output array, so export validation applies only to real enhanced-image records.

    Its session factory returns only App-specific selection, draft workflow, view, and preview-cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    The semantic layout follows the Runtime callback contract: every control and plot names its concrete callback or renderer, and the definition validates those bindings before creating a figure.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/image-match.html b/site/apps/image-measurement/image-match.html index 1a0f11de3..ed45d3763 100644 --- a/site/apps/image-measurement/image-match.html +++ b/site/apps/image-measurement/image-match.html @@ -22,6 +22,7 @@

    app

    Image Match

    +

    Every action and input-selection button provides hover help describing its reference distribution, match history, source pixels, or batch export effect.

    Image Match transfers tone and color statistics from one reference image to one or more source images while preserving each source image's geometry.

    Requirements And Launch

    The app uses the LabKit UI framework and Image library. It performs appearance matching only; it does not geometrically register images.

    @@ -71,12 +72,12 @@
  • API Reference
  • Framework Compatibility

    -

    The single definition.m owns product metadata, requirements, layout, actions, presentation, renderers, and debug-sample capability. projectSpec.m is the only durable-project entry; the version-1 project needs creation and validation but no migration. Root createSession.m reconstructs only the selected source, reference, and preview caches after Runtime V2 resolves sources.

    +

    The single definition.m owns product metadata, requirements, layout, actions, presentation, renderers, and debug-sample capability. projectSpec.m is the only durable-project entry; the version-1 project needs creation and validation but no migration. Root createSession.m reconstructs only the selected source, reference, and preview caches after App SDK runtime resolves sources.

    The project validator requires the image-source collection and checks matching parameters and durable steps; Runtime validates canonical buckets and each source record first.

    -

    Source item records live in +sourceFiles, matching steps in +analysisRun, and deterministic export tasks in +resultFiles; there is no generic +appState package. A new empty project performs no App-specific startup callback and chooses its output directory after source selection or explicit user choice. The App requires labkit.ui >=7 <8 and labkit.image >=2 <3; source-path access, persistence, busy state, and debug lifecycle remain framework-owned.

    +

    Source item records live in +sourceFiles, matching steps in +analysisRun, and deterministic export tasks in +resultFiles; there is no generic +appState package. A new empty project performs no App-specific startup callback and chooses its output directory after source selection or explicit user choice. The App requires labkit.app >=1 <2 and labkit.image >=2 <3; source-path access, persistence, busy state, and debug lifecycle remain framework-owned.

    Matched-image manifest outputs use the framework's canonical empty output array, so export validation applies only to real output records.

    Its session factory returns only App-specific selection, draft workflow, view, and matched-preview cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    The semantic layout follows the Runtime callback contract: every control and plot names its concrete callback or renderer, and the definition validates those bindings before creating a figure.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/video-marker.html b/site/apps/image-measurement/video-marker.html index 329fcaef6..abb8be82c 100644 --- a/site/apps/image-measurement/video-marker.html +++ b/site/apps/image-measurement/video-marker.html @@ -18,13 +18,14 @@
    - +

    app

    Video Marker

    -

    Video Marker defines an ordered landmark skeleton, records coordinates across video frames, predicts forward positions between manual anchors, and saves a portable project with autosave and recovery.

    +

    Every action and input-selection button provides hover help describing its landmark/skeleton state, frame annotation, calibration, or coordinate export.

    +

    Video Marker defines an ordered landmark skeleton, records coordinates across video frames, predicts forward positions between manual anchors, and saves a portable project with an explicit source-adjacent autosave copy.

    Requirements And Launch

    -

    The app declares compatibility with LabKit UI 7.x and uses the image functions shipped with the same workbench. Video decoding uses MATLAB's available video support. Predictive navigation is implemented in repository-owned MATLAB code; no model weights or third-party runtime package are downloaded.

    +

    The app declares compatibility with labkit.app 1.x and uses image functions shipped with the same workbench. Video decoding uses MATLAB's available video support. Predictive navigation is implemented in repository-owned MATLAB code; no model weights or third-party runtime package are downloaded.

    labkit_VideoMarker_app

    Start Or Open A Project

    The Session panel appears first in the Video tab. Open MAT uses the same loader as the window's top-level Load State action and accepts an explicit project or compatible autosave. Save autosave immediately updates Video Marker Autosaves/<video>.video_marker.autosave.mat beside the source video without asking for a path and without turning that recovery copy into the named project file. New setup offers Cancel, Save and start new, or Discard and start new; it never silently clears the current session.

    @@ -51,10 +52,10 @@

    Scale And Coordinate Export

    Marker CSV is the round-trip editing format. Coordinate CSV is the plotting-oriented table after optional calibration, origin shift, and Y-axis conversion. Raw pixel coordinates remain available in the project.

    Autosave, Recovery, And Portability

    -

    Changes to the skeleton or annotations are atomically saved to Video Marker Autosaves beside the source video. Autosave and explicit project MAT files use the same project data. They store frame count, frame rate, duration, image dimensions, skeleton edges, annotation status/source, and calibration alongside the durable coordinates. The portable source record stores the video path relative to the MAT file, the original path, and same-folder filename fallbacks. Downstream apps such as Gait Analysis therefore use the MAT document as their scientific data source without reopening the original video.

    -

    When a project tree moves between folders, users, or operating systems, the relative reference is tried first. If no candidate exists, the app asks the user to locate the video without discarding skeleton or annotations. Opening a video with adjacent recovery data asks whether to restore it or start new. A compatible old Video Marker project or autosave opens with an unsaved marker; choosing the top-level Save State action atomically upgrades that same MAT path to the current labkitProject format.

    +

    Save autosave writes atomically to Video Marker Autosaves beside the source video. It is an explicit action, not a background timer. Autosave and named project MAT files contain the same project data: frame count, frame rate, duration, image dimensions, skeleton edges, annotation status/source, calibration, and durable coordinates. Portable source records let the runtime resolve or relink the video while rebuilding the transient reader and decoded frame cache.

    +

    When a project tree moves between folders, users, or operating systems, the relative reference is tried first. If no candidate exists, the runtime asks the user to locate the video without discarding skeleton or annotations. A compatible old Video Marker project or autosave opens through Open MAT or the top-level Load State action. Saving it writes the current labkitProject envelope.

    Project And Session State

    -

    video_marker.projectSpec is the single durable-project contract. It owns the current schema factory and validator, the version-1 payload upgrade, the former videoMarkerProject MAT-variable importer, and the lightweight current-frame resume policy. Runtime V2 performs migration iteration, validates the imported payload, resolves required video sources, and only then calls video_marker.createSession.

    +

    video_marker.projectSpec is the single durable-project contract. It owns the current schema factory and validator, the version-1 payload upgrade, the former videoMarkerProject MAT-variable importer, and the lightweight current-frame resume policy. The App SDK performs migration iteration, validates the imported payload, resolves required video sources, and only then calls video_marker.createSession.

    Durable state consists of video metadata, the portable video source, skeleton, frame annotations and provenance, calibration, export parameters, and output manifest paths. The video reader, decoded frame, current interaction, selection, preview graphics, and frame cache are transient session resources. Only the current frame number is saved as navigation convenience; annotations remain the authoritative scientific data.

    Outputs

      @@ -63,6 +64,9 @@

      Outputs

    • coordinate CSV for analysis and plotting;
    • output manifests recording coordinate options and file roles.
    +

    CSV dialogs start in a source-adjacent video_marker output folder. Result manifests are written beside the chosen CSV.

    +

    Debug Diagnostics

    +

    Launch with Diagnostics=labkit.app.diagnostic.Options(Level="verbose") to record the richer sanitized callback, checkpoint, count, project, dialog, resource, and failure trace. Add Sample="synthetic" and an artifact folder to run the App's BuildDebugSample contract, which creates a synthetic video, a valid initial marking project, and declared marker/coordinate output targets without including user filenames or laboratory data.

    Use Without The GUI

    previousFrame = peaks(61);
     nextFrame = circshift(previousFrame, [-2 3]);
    @@ -98,12 +102,12 @@ 
     
  • API Reference
  • Framework Compatibility

    -

    This App requires labkit.ui >=7 <8. Its single definition.m owns product metadata, requirements, layout, actions, presentation, renderers, and optional capabilities. projectSpec.m concentrates all durable creation, validation, migration, legacy import, and resume hooks; root createSession.m rebuilds the transient video state.

    +

    This App requires labkit.app >=1 <2. Its single definition.m creates one immutable labkit.app.Definition; feature-owned layout nodes bind directly to semantic callbacks, workbench.present returns a complete labkit.app.view.Snapshot, and plot rendering stays with the video-preview capability. projectSpec.m concentrates durable creation, validation, migration, legacy import, and resume hooks; root createSession.m rebuilds transient video state.

    The project validator requires the video-source collection and checks metadata, coordinate parameters, skeleton, and frame-array relationships; Runtime validates canonical buckets and each source record first.

    -

    The App reads video locations only through labkit.ui.runtime.sourcePaths. Legacy portable references are passed intact to sourceRecord for framework validation and canonicalization; no App helper knows the Runtime's nested path schema. Busy state, callback queues, resource cleanup, source relinking, serialization envelopes, and migration iteration remain framework-owned.

    +

    Callbacks receive labkit.app.CallbackContext as an injected runtime port. They resolve portable sources, register the document-scoped video reader/cache, open dialogs, restore or create project documents, write result packages, and record diagnostics through specifically named methods. No App helper reads a runtime registry or nested source schema. Busy state, callback queues, resource cleanup, source relinking, serialization envelopes, and migration iteration remain framework-owned.

    Its session factory returns only App-specific frame selection, editing workflow, scale-bar view, and decoded video cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The skeleton preset selector is a direct session binding; choosing a label does not require an App callback until Use preset applies the selected schema. The decoded-video cache is a session resource registered with Runtime default cleanup rather than an App-owned empty cleanup hook.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    The skeleton preset selector is a direct session binding; choosing a label does not require an App callback until Use preset applies the selected schema. The decoded-video cache is a document-scoped runtime resource reused across frame navigation and cleared by project replacement or runtime close.

    +

    The semantic layout follows the Runtime callback contract: every control and plot names its concrete callback or renderer, and the definition validates those bindings before creating a figure.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/index.html b/site/apps/index.html index 319a62a70..f528d713d 100644 --- a/site/apps/index.html +++ b/site/apps/index.html @@ -18,7 +18,7 @@
    - +

    overview

    LabKit Apps

    @@ -75,7 +75,7 @@
  • API Reference
  • App Development
  • Component History
  • - +
    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/labkit-core/figure-studio.html b/site/apps/labkit-core/figure-studio.html index 860dee843..993b43226 100644 --- a/site/apps/labkit-core/figure-studio.html +++ b/site/apps/labkit-core/figure-studio.html @@ -18,18 +18,19 @@
    - +

    app

    Figure Studio

    +

    Every action and input-selection button provides hover help describing its FIG source, editable styling artifact, plotted data, or graphics export.

    Figure Studio restyles MATLAB figures, exports presentation copies, and extracts supported visible graphics into a portable data package. It changes presentation properties, not the calculation that produced the plot.

    Open Figure Studio

    From the launcher, select Figure Studio and choose Open. From a source checkout, run:

    labkit_FigureStudio_app

    A LabKit plot can also send its current axes to Figure Studio through the plot context menu. That handoff embeds a serializable plot snapshot in the project.

    -

    Initialization And Runtime Services

    -

    figure_studio.definition declares an optional Runtime V2 Start capability named figure_studio.initializeWorkbench. Runtime calls it after the semantic layout and preview axes exist but before startup readiness is released. This is why axes handoff and resize-resource registration do not belong in createSession(project), which is deliberately GUI-free and receives no runtime services.

    -

    The initializer receives the canonical state, the startup event, and injected services. services.request carries the optional axes handoff prepared by figure_studio.launchRequest; services.previews resolves the managed preview axes; services.resources registers cleanup-owned resize state; services.dialogs, services.workflow, and services.debug provide domain-neutral runtime behavior. Figure Studio does not construct these services or control callback queueing, busy state, or readiness.

    +

    Initialization And Runtime Context

    +

    figure_studio.definition declares an optional App SDK OnStart capability named figure_studio.initializeWorkbench. Runtime calls it after the semantic layout and first complete view exist but before startup readiness is released. The entrypoint converts an optional axes handoff into the normal InitialProject launch value. createSession(project,callbackContext) then rebuilds only transient FIG data, and the initializer establishes the default output folder and records restored-source status.

    +

    The initializer receives canonical applicationState and the sealed labkit.app.CallbackContext. It does not receive axes, a service bag, native components, or a launch-request object. Fixed-canvas resize reflow, callback queueing, busy state, and readiness remain runtime-owned.

    Load And Select Figures

    On Figures, choose Add FIG files or scan folder. The source list accepts MATLAB .fig files and keeps one current selection. Selecting another source loads that figure's plot snapshot and original style. Clear figures removes all sources from the project.

    For a source using FIG default, Figure Studio adopts its font, line, grid, canvas, and axes appearance. Selecting LabKit figure applies the LabKit preset while retaining the source canvas size.

    @@ -71,10 +72,10 @@

    Framework Compatibility

    -

    This App's definition.m owns its product metadata, labkit.ui >=7 <8 requirement, layout, and optional capabilities. projectSpec.m is the single durable-schema entry and keeps project creation and validation local; createSession.m separately rebuilds decoded FIG data because it is transient runtime state. The entrypoint only adapts the optional axes handoff and delegates to Runtime V2. App code uses semantic actions, injected project services, and the stable resolved-path accessor; busy-state and portable-reference serialization mechanics remain framework-owned.

    +

    This App's definition.m owns its product metadata, labkit.app >=1 <2 requirement, layout, and optional capabilities. projectSpec.m is the single durable-schema entry and keeps project creation and validation local; createSession.m separately rebuilds decoded FIG data because it is transient runtime state. The entrypoint only adapts the optional axes handoff and delegates to App SDK runtime. App code binds controls directly to semantic callbacks and uses the sealed callback context for status, dialogs, project operations, resources, and resolved paths; busy-state and portable-reference serialization mechanics remain framework-owned.

    The project validator requires the figure-source collection and checks style and embedded-plot fields; Runtime validates canonical buckets and each source record first.

    Its session factory returns only App-specific source selection, status workflow, and decoded plot cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    The semantic layout follows the Runtime callback contract: every control and plot names its concrete callback or renderer, and the definition validates those bindings before creating a figure.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/labkit-core/launcher/index.html b/site/apps/labkit-core/launcher/index.html index c8df7d9dd..bb5b42273 100644 --- a/site/apps/labkit-core/launcher/index.html +++ b/site/apps/labkit-core/launcher/index.html @@ -22,13 +22,14 @@

    app manual

    LabKit Launcher

    -

    The LabKit Launcher is the installed workbench entry point. It discovers apps, prepares their MATLAB paths, checks requirements, starts normal or debug sessions, manages installed versions, opens app documentation, and exposes source-checkout maintenance tools. It is intentionally self-contained so a single surviving labkit_launcher.m can repair an incomplete ZIP installation.

    +

    The LabKit Launcher is the installed workbench entry point. It discovers apps, prepares their MATLAB paths, checks requirements, starts App SDK sessions, manages installed versions, opens app documentation, and exposes source-checkout maintenance tools. It is intentionally self-contained so a single surviving labkit_launcher.m can repair an incomplete ZIP installation.

    Start The Launcher

    labkit_launcher

    The window paints before app discovery completes. Its status area first reports that the app list is loading, then shows the selected app, integrity problems, tool availability, or the active maintenance operation.

    Launcher Window

    -
    GroupActionBehavior
    Run AppsOpen Selected AppChecks the selected app requirements, adds the app root, and launches normally.
    Run AppsOpen DebugLaunches the same app with diagnostic tracing enabled.
    Run AppsRefresh App ListRepeats public and configured private-app discovery without restarting the launcher.
    Run AppsDocumentation and HistoryOpens the generated manual for the selected app.
    Versions and InstallLatestInstalls the current main branch archive.
    Versions and InstallReleaseInstalls the latest stable GitHub release.
    Versions and InstallVersionsOpens the release, tag, and commit selector for deliberate upgrade or rollback.
    Development and MaintenanceUpdate DocumentationRegenerates site/ with the repository-owned documentation compiler.
    Development and MaintenanceRun Code AnalyzerScans the checkout and writes JSON and HTML Code Analyzer reports.
    Development and MaintenanceProfile Selected AppStarts the selected app under the MATLAB profiler and saves its report when the app closes.
    Development and MaintenanceClean ArtifactsRemoves ignored generated reports under artifacts/; it does not delete app projects or exported laboratory results.
    Package and PublishPackage CheckedCreates one source ZIP containing all checked apps and their runtime support.
    Package and PublishChecked P-codeCreates a runtime-only ZIP with MATLAB source encoded as P-code.
    +
    GroupActionBehavior
    Run AppsOpen Selected AppChecks the selected app requirements, adds the app root, and calls its App SDK entrypoint without retired runtime launch arguments.
    Run AppsOpen DebugStarts the same App through its typed SDK diagnostics contract, records verbose structured events under artifacts/diagnostics/launcher/, and loads the App-owned anonymous synthetic sample.
    Run AppsRefresh App ListRepeats public and configured private-app discovery without restarting the launcher.
    Run AppsDocumentation and HistoryOpens the generated manual for the selected app.
    Versions and InstallLatestInstalls the current main branch archive.
    Versions and InstallReleaseInstalls the latest stable GitHub release.
    Versions and InstallVersionsOpens the release, tag, and commit selector for deliberate upgrade or rollback.
    Development and MaintenanceUpdate DocumentationRegenerates site/ with the repository-owned documentation compiler.
    Development and MaintenanceRun Code AnalyzerScans the checkout and writes JSON and HTML Code Analyzer reports.
    Development and MaintenanceProfile Selected AppStarts the selected app under the MATLAB profiler and saves its report when the app closes.
    Development and MaintenanceClean ArtifactsRemoves ignored generated reports under artifacts/; it does not delete app projects or exported laboratory results.
    Package and PublishPackage CheckedCreates one source ZIP containing all checked apps and their runtime support.
    Package and PublishChecked P-codeCreates a runtime-only ZIP with MATLAB source encoded as P-code.

    Double-clicking an app row is equivalent to selecting it and opening it normally. The checkbox column controls package membership; ordinary launch selection does not change the checked set.

    +

    Debug sessions use a new isolated artifact folder on every launch. The folder contains the runtime event stream, session manifest, synthetic sample manifest, and any anonymous fixture files declared by the selected App. Normal launches keep the SDK's bounded standard diagnostics in memory and do not create this verbose artifact set.

    Programmatic Calls

    The launcher exposes a small non-GUI surface:

    fig = labkit_launcher;
    @@ -71,7 +72,7 @@ 
     
  • Architecture
  • Private Apps
  • Release Process
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/neurophysiology/nerve-response-analysis.html b/site/apps/neurophysiology/nerve-response-analysis.html index fa4b7174d..26897d436 100644 --- a/site/apps/neurophysiology/nerve-response-analysis.html +++ b/site/apps/neurophysiology/nerve-response-analysis.html @@ -22,6 +22,7 @@

    app

    Nerve Response Analysis

    +

    Every action and input-selection button provides hover help describing its protocol/filter input, nerve-response calculation, reset, or exported evidence.

    Nerve Response Analysis reads the recording list prepared in RHS Preview, finds stimulation events, groups them into trains, and measures compound action potential responses in the assigned channels.

    Open Nerve Response Analysis

    From the launcher, select Nerve Response Analysis and choose Open. From a source checkout, run:

    @@ -40,7 +41,7 @@

    Analyze A Recording Set

    Project And Session State

    The durable project stores portable references for the filter record and optional protocol, the two run limits, and the last export paths. The fixed source IDs are filterRecord and protocol; each uses the same value as its role so loading, presentation, and relinking address one unambiguous record.

    Parsed JSON, analysis tables, issue details, preview selection, log messages, and output-folder convenience are transient session state. Opening a project reparses its JSON sources but does not persist or silently reuse old analysis tables; choose Analyze Filtered Files to calculate them again. If an existing selected JSON file is malformed, project restore stops and preserves the current document. An absent optional protocol remains valid.

    -

    For developers, nerve_response_analysis.definition is the complete product contract. nerve_response_analysis.projectSpec owns project creation, validation, and the version-1 upgrade in one file, while nerve_response_analysis.createSession rebuilds transient state. Runtime V2 owns the migration loop and source-reference representation.

    +

    For developers, nerve_response_analysis.definition is the complete product contract. nerve_response_analysis.projectSpec owns project creation, validation, and the version-1 upgrade in one file, while nerve_response_analysis.createSession rebuilds transient state. App SDK runtime owns the migration loop and source-reference representation.

    What The Analysis Does

    For each readable recording, the app selects an event source from the protocol and recording metadata, reads the required waveform, and finds sharp changes using the absolute first difference. A robust noise estimate sets the default detection threshold. Nearby peaks are reduced according to the minimum event spacing, then grouped into trains by their time gaps and train rules.

    For every event and response channel, the app measures a pre-event baseline, baseline noise, positive and negative peaks, peak-to-peak amplitude, peak time, latency, and SNR. The response search starts after the pulse-blanking interval to avoid measuring the stimulation artifact itself.

    @@ -90,10 +91,10 @@
  • Response Review and Stats
  • Framework Compatibility

    -

    This App uses the Runtime V2 lifecycle and requires labkit.ui >=7 <8 and labkit.rhs >=1.0 <2. App code uses semantic actions, sourcePaths, and the injected source-upsert service; migration iteration, busy state, and portable reference serialization remain framework-private.

    +

    This App uses the App SDK runtime lifecycle and requires labkit.app >=1 <2 and labkit.rhs >=1.0 <2. App code uses semantic actions, sourcePaths, and the sealed callback context; migration iteration, busy state, and portable reference serialization remain framework-private.

    The project validator requires the source collection and retains the App-specific filterRecord/protocol ID-role rules, run limits, and export state; Runtime validates canonical buckets and each source record first.

    Its session factory returns only App-specific workflow, preview view, and decoded analysis cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    The semantic layout follows the Runtime callback contract: every control and plot names its concrete callback or renderer, and the definition validates those bindings before creating a figure.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/neurophysiology/response-review-stats.html b/site/apps/neurophysiology/response-review-stats.html index 243b07c95..1efe905ab 100644 --- a/site/apps/neurophysiology/response-review-stats.html +++ b/site/apps/neurophysiology/response-review-stats.html @@ -22,6 +22,7 @@

    app

    Response Review And Stats

    +

    Every action and input-selection button provides hover help describing its response metrics, statistical refresh, reset, or export effect.

    Response Review and Stats opens a Nerve Response Analysis result or a segment table, displays the measurements, and exports a clean CSV for review or downstream statistics. Segment tables can also be aligned and measured again with new baseline and noise windows.

    Open Response Review And Stats

    From the launcher, select Response Review Stats and choose Open. From a source checkout, run:

    @@ -94,11 +95,11 @@
  • Nerve Response Analysis
  • Framework Compatibility

    -

    This App requires labkit.ui >=7 <8. Its single definition.m owns product metadata, requirements, layout, actions, presentation, renderer, and debug capability. projectSpec.m concentrates durable creation, validation, and migration; root createSession.m rebuilds transient analysis tables.

    -

    Source paths are read through labkit.ui.runtime.sourcePaths; no App code inspects portable-reference fields. Callback queues, busy state, migration iteration, source relinking, serialization, and resource lifetime remain framework-owned.

    +

    This App requires labkit.app >=1 <2. Its single definition.m owns product metadata, requirements, layout, actions, presentation, renderer, and debug capability. projectSpec.m concentrates durable creation, validation, and migration; root createSession.m rebuilds transient analysis tables.

    +

    Source paths are read through CallbackContext.resolveSourcePaths; no App code inspects portable-reference fields. Callback queues, busy state, migration iteration, source relinking, serialization, and resource lifetime remain framework-owned.

    The project validator requires the response source collection and checks metric windows and export state; Runtime validates canonical buckets and each source record first.

    Its session factory returns only App-specific workflow, preview view, and decoded metrics cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    The semantic layout follows the Runtime callback contract: every control and plot names its concrete callback or renderer, and the definition validates those bindings before creating a figure.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/neurophysiology/rhs-preview.html b/site/apps/neurophysiology/rhs-preview.html index b6ff28aef..8a7aa7bb7 100644 --- a/site/apps/neurophysiology/rhs-preview.html +++ b/site/apps/neurophysiology/rhs-preview.html @@ -22,6 +22,7 @@

    app

    RHS Preview

    +

    Every action and input-selection button provides hover help describing its RHS/protocol input, waveform window, response ROI, filter, or JSON output.

    RHS Preview lets you inspect an Intan RHS recording without loading the entire waveform into memory. Use it to check channels, move through short waveform windows, choose the channels to plot, and prepare protocol or file-filter JSON for later analysis.

    Open RHS Preview

    From the LabKit launcher, select RHS Preview and choose Open. From a source checkout, run:

    @@ -45,9 +46,9 @@

    Prepare A File Filter

    Saved Project Sources

    The preview recording, optional protocol, and filter recordings share the standard project inputs.sources collection. Each record keeps its recording, protocol, or filterRecording role, so reopening restores the correct control without teaching the framework app-specific field names. Version 1 RHS Preview projects stored three separate source fields; they are combined on load and the next save writes payload version 2.

    Project And Session State

    -

    The durable project stores portable references for one preview recording, one optional protocol, and an ordered collection of filter recordings. It also stores preview settings, channel-role drafts, manual filter labels/comments, and compact export records. The App owns the recording, protocol, and filterRecording roles; Runtime V2 owns each reference's portable path data.

    +

    The durable project stores portable references for one preview recording, one optional protocol, and an ordered collection of filter recordings. It also stores preview settings, channel-role drafts, manual filter labels/comments, and compact export records. The App owns the recording, protocol, and filterRecording roles; App SDK runtime owns each reference's portable path data.

    Header indices, decoded preview windows, table presentation state, current ROI and window position, status text, and log messages are transient session data. They are reconstructed from the project sources when a project is opened.

    -

    For developers, rhs_preview.definition is the complete product contract. rhs_preview.projectSpec owns project creation, validation, and the version-1 upgrade; rhs_preview.createSession rebuilds transient state. Fixed recording and protocol sources use the injected upsert service. The variable filter collection uses the injected reconcile service so existing source IDs remain stable when files are added, removed, or rediscovered. The App-local rhs_preview.sourceFiles.pathsForRole function selects its role ordering and delegates portable-reference decoding to labkit.ui.runtime.sourcePaths.

    +

    For developers, rhs_preview.definition is the complete product contract. rhs_preview.projectSpec owns project creation, validation, and the version-1 upgrade; rhs_preview.createSession rebuilds transient state. Fixed recording and protocol sources, plus the variable filter collection, are updated as ordinary App-owned portable source values. Framework file-list bindings preserve stable source IDs while files are added, removed, or rediscovered. The App-local rhs_preview.sourceFiles.pathsForRole function selects its role ordering and delegates portable-reference decoding to the sealed CallbackContext.resolveSourcePaths operation.

    Review Recording Information

    The Review tab shows the indexed duration, channel counts, current window, selected channels, and recent action. The preview plot uses a separate vertical band for each selected channel so traces do not obscure one another.

    Read An RHS Window Without The App

    @@ -78,10 +79,10 @@
  • RHS library
  • Framework Compatibility

    -

    This App uses the Runtime V2 lifecycle and requires labkit.ui >=7 <8 and labkit.rhs >=1.0 <2. App code uses semantic actions, managed interval interaction, sourcePaths, and injected upsert/reconcile services; migration iteration, busy state, and portable-reference serialization remain framework-private.

    +

    This App uses the App SDK runtime lifecycle and requires labkit.app >=1 <2 and labkit.rhs >=1.0 <2. App code uses semantic actions, managed interval interaction, direct layout callbacks, and resolveSourcePaths; migration iteration, busy state, and portable-reference serialization remain framework-private.

    The project validator requires the source collection and retains recording, protocol, and filter role/cardinality rules plus preview and annotation fields; Runtime validates canonical buckets and each source record first.

    Its session factory returns only App-specific status, time-window view, and indexed preview cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    The semantic layout follows the Runtime callback contract: every control and plot names its concrete callback or renderer, and the definition validates those bindings before creating a figure.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/statistics/ttest-wizard.html b/site/apps/statistics/ttest-wizard.html index ca11c9196..5ff75a87c 100644 --- a/site/apps/statistics/ttest-wizard.html +++ b/site/apps/statistics/ttest-wizard.html @@ -22,12 +22,14 @@

    app

    T-Test Wizard

    +

    Every action and input-selection button provides hover help describing its group assignment, hypothesis test, alpha decision, or portable export.

    T-Test Wizard captures two or more numeric groups, compares every group after the first with the first group, and draws one publication-oriented mean and standard-deviation plot. The first group is always the reference group.

    Open T-Test Wizard

    From the LabKit launcher, select T-Test Wizard and choose Open. From a source checkout, run:

    labkit_TTestWizard_app

    The App requires Base MATLAB and the LabKit App Framework. It does not require Statistics and Machine Learning Toolbox.

    -

    Current App version: 1.0.1.

    +

    Current App version: 1.1.0.

    +

    The App uses labkit.app 1.x. Its definition names the project schema, transient session factory, semantic workbench, derived view, and plot renderer. Runtime-injected callback context and table payload types are declared in MATLAB arguments blocks; the App does not receive a component registry or an untyped service bag.

    Workflow

    The left controls use three short pages:

      @@ -132,7 +134,7 @@
    1. Simple Scientific CSV Exchange
    2. Figure Studio
    3. App Framework
    4. -

      Change history

      +

      Change history

      Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/wearable/ecg-print.html b/site/apps/wearable/ecg-print.html index 477ee234e..e0e7d1cb6 100644 --- a/site/apps/wearable/ecg-print.html +++ b/site/apps/wearable/ecg-print.html @@ -22,6 +22,7 @@

    app

    ECG Print

    +

    Every action and input-selection button provides hover help describing its ECG import, ROI processing, beat/template analysis, SNR, or export effect.

    ECG Print reads a wearable recording, filters one channel, detects beats, builds event-centered segments and a representative template, and reports signal quality over time. It can export the segment measurements and a printable waveform image.

    Open ECG Print

    From the LabKit launcher, select ECG Print and choose Open. From a source checkout, run:

    @@ -46,7 +47,7 @@

    Project And Session State

  • the compact last-analysis summary and export records.
  • Decoded recordings, signal arrays, events, segments, templates, measurements, header previews, and plot models are transient session data. They are rebuilt from the recording and durable parameters when a project is opened. This keeps saved projects portable and avoids duplicating large waveform caches.

    -

    For developers, ecg_print.definition is the complete product contract. ecg_print.projectSpec keeps project creation, validation, and the version-1 upgrade in one file; ecg_print.createSession reconstructs transient state. Runtime V2 performs the version loop and calls the migration entry once for each older payload version.

    +

    For developers, ecg_print.definition is the complete product contract. ecg_print.projectSpec keeps project creation, validation, and the version-1 upgrade in one file; ecg_print.createSession reconstructs transient state. The App SDK runtime performs the version loop and calls the migration entry once for each older payload version.

    Analyze ECG

    1. Open and parse a recording.
    2. @@ -57,6 +58,7 @@

      Analyze ECG

    3. Review the four plots: waveform and peaks, template-noise RMS, template SNR, and the template with either a residual band or individual segments.

    The filter is applied to the full selected channel before the time region is cropped. This reduces boundary artifacts at the region edges.

    +

    The Files + Analysis tab keeps the workflow in five ordered sections: Recording, Import Parsing, Channel + ROI, Signal Processing + SNR, and Exports. Bounded numeric settings use paired spinner-and-slider controls. Summary + Results contains the analysis summary and file-header preview, while Log records the current session workflow. The ECG Preview workspace keeps four vertically stacked time-series axes available on every tab.

    Analysis Parameters

    ParameterDefaultMeaning
    Fallback sample rate2000 HzUsed only when import cannot derive time spacing
    Bandpass0.5 to 40 HzApplied before peak detection; the upper cutoff is kept below 45% of sample rate
    Peak methodQRS streamingQRS streaming, Pan-Tompkins, or local peaks
    Peak distance0.28 sMinimum interval between accepted detections
    Segment half-window0.7 sData retained before and after each event
    Template top N30Number of best segments used to build the template
    Smooth beats15Smoothing span used in exported per-segment trends
    Template plotTemplate + residual bandAlternative view is template plus individual segments

    Peak polarity is selected automatically. The default detector threshold is 2.8 standard deviations inside the app calculation.

    @@ -97,10 +99,10 @@

    Framework Compatibility

    -

    This App uses the Runtime V2 lifecycle and requires labkit.ui >=7 <8 and labkit.biosignal >=1.0 <2. App code uses semantic actions, sourcePaths, and injected project services; migration iteration, busy state, and portable reference serialization remain framework-private.

    +

    This App uses labkit.app.Definition, semantic labkit.app.layout.* controls, complete labkit.app.view.Snapshot presentation, typed events, and the injected labkit.app.CallbackContext. It requires labkit.app >=1 <2 and labkit.biosignal >=1.0 <2. The runtime owns launch, busy state, portable-source serialization, project migration, result-manifest provenance, log presentation, and native component lifecycle.

    The project validator requires the recording source collection and checks import, filter, detection, and result fields; Runtime validates canonical buckets and each source record first.

    -

    Its session factory returns only App-specific import workflow and decoded signal cache fields. Runtime supplies absent canonical buckets and owns workflow-log initialization.

    -

    The semantic layout follows the Runtime callback contract: every referenced action must be registered and resolves during layout construction.

    Change history

    +

    Its session factory resolves the portable recording through CallbackContext, then returns only App-specific import workflow and decoded signal cache fields. A failed project/session reconstruction reaches the runtime's atomic failure boundary. A failed user-requested Parse / refresh file operation keeps the source and header preview available so import settings can be corrected in place.

    +

    Layout controls bind directly to ECG capability callbacks and the four-axis plot area binds directly to its renderer; there is no App-authored action or renderer registry. See the App SDK runtime contract.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/assets/search-index.js b/site/assets/search-index.js index 5af1b9fd5..ef3f481be 100644 --- a/site/assets/search-index.js +++ b/site/assets/search-index.js @@ -1 +1 @@ -window.LABKIT_SEARCH_INDEX = [{"title":"LabKit Documentation","url":"index.html","kind":"overview","text":"LabKit Documentation LabKit Documentation LabKit documentation is organized by the question you are trying to answer. Start with a task guide, then open an API reference only when you need exact MATLAB call syntax or returned data shapes. Choose A Starting Point I want to Start here Install, update, or open LabKit [Getting started](getting-started/README.md) Understand every launcher action or call it from MATLAB [LabKit Launcher](apps/labkit-core/launcher/README.md) Choose an app and understand its inputs and outputs [App guide](apps/README.md) Call a reusable `labkit.*` function [Public API reference](reference/README.md) Understand ownership and package boundaries [Architecture](development/build-apps/architecture.md) Create or modify an app [App development](development/build-apps/app-development.md) Run tests or diagnose performance [Testing](development/maintain-and-release/testing.md) Call packaging, profiling, codecheck, or documentation tools [Maintainer tools](development/tools/README.md) Understand documentation sources and generated HTML [Documentation system](development/maintain-and-release/documentation.md) Maintain a private app workspace [Private apps](development/maintain-and-release/private-apps.md) Prepare a release [Release process](development/maintain-and-release/release.md) Documentation Layers getting-started/ installation, launcher, updates, and first-run concepts apps/ one directory per family and one subdirectory per app including the LabKit Launcher under LabKit Core framework/ UI runtime concepts, behavior, and app-authoring contracts libraries/ one directory per reusable public MATLAB facade reference/ generated-function reference landing page development/ reader-task folders for app building, maintenance, and tools history/ chronological change records and the project history index Every Markdown file is published automatically. Its path owns navigation and its first level-one heading owns the page title. Public App manuals are matched to `labkit_launcher(\"list\")`; App-owned function pages are discovered from complete public MATLAB help contracts. Reference Conventions Public function pages follow the MATLAB reference pattern: summary and syntax description and behavior input arguments and options output arguments and data shapes algorithms or scientific semantics where relevant errors and limitations executable examples version history, See Also, and related topics Each app family has a landing page. Each concrete app owns a directory whose `README.md` is its Get Started and detailed behavior page; complex apps can add focused workflow, file-format, or algorithm topics beside it. App pages explain launch, task flow, interaction rules, inputs, outputs, persistence, scientific meaning, non-GUI APIs, errors, limitations, examples, related topics, and component history as applicable. They do not document internal callbacks. Framework and library landing pages follow the Qt module pattern: overview and ownership first, followed by grouped concepts, supported public members, detailed behavior, examples, and related modules. Generated function pages are owned by MATLAB source help blocks. Handwritten HTML is never a source. Project History And Support [Project history](history/README.md) lists all change records and connects them to the affected apps, framework, and libraries. [Support](../.github/SUPPORT.md) explains how to report workflow problems."},{"title":"EIS","url":"apps/electrochemistry/eis.html","kind":"app","text":"apps electrochemistry eis EIS EIS EIS overlays impedance data from one or more Gamry `ZCURVE` tables, supports Nyquist and Bode-style axis combinations, and exports the values currently selected for plotting. Requirements And Launch The app uses the LabKit UI framework and DTA library. labkit_EIS_app Inputs Add one or more `.DTA` files containing a readable EIS `ZCURVE`. Files that do not contain the required curve are reported and omitted from the plot. The source list is preserved in project state through portable references. Runtime V2 reconciles those records with the successfully decoded file list, preserves the identity of files that remain loaded, and assigns unique identities to new files; EIS does not maintain its own source-ID counter. Basic Workflow Add the EIS DTA files. Choose X and Y quantities. Enable logarithmic X or Y scaling only for strictly positive plotted data. Adjust marker, line, grid, and legend presentation. Export the current plot data CSV. Axis Quantities The available quantities are frequency, log10 frequency, time, point number, real impedance, imaginary impedance, negative imaginary impedance, impedance magnitude, phase, DC current, and DC voltage. Default axes are `Zreal (ohm)` and `-Zimag (ohm)`. Use `Zreal` versus `-Zimag` for the conventional Nyquist orientation. Use frequency versus magnitude or phase for Bode-style views. The log-axis checkbox changes MATLAB axes scaling; choosing `log10(Freq)` changes the data coordinate itself. Do not apply both transformations unless that is explicitly intended. Plot Parameters Parameter Default Line width 1.4 Marker size 6 Show markers on Log X / Log Y off / off Legend / Grid on / on Axis and styling changes preserve the current source set. The plot refits when the selected data quantities change; ordinary interaction zoom is otherwise owned by the App Framework. Output **Export current plot CSV** writes the selected X/Y values for each valid file on a shared row index. Each file retains its own X and Y pair, so unequal curve lengths do not imply interpolation. A result manifest records the selected axes, plot parameters, source references, and output role. Use Without The GUI [item, status] = labkit.dta.loadFile(\"spectrum.DTA\", \"eis\"); assert(status.ok, status.message); curve = labkit.dta.getZCurve(item); x = eis.analysisRun.valuesForAxis(curve, \"Zreal (ohm)\"); y = eis.analysisRun.valuesForAxis(curve, \"-Zimag (ohm)\"); plot(x, y, \"o-\"); axis equal `valuesForAxis` is app-owned and not currently part of the published app API catalog. The DTA loader and `getZCurve` are supported reusable APIs. Errors And Limitations Log axes omit or reject nonpositive coordinates according to MATLAB axes behavior; inspect the data rather than treating missing points as zero. Overlaying files does not normalize electrode area or fixture geometry. Axis labels describe parsed DTA columns; they do not validate the experiment configuration recorded by the instrument. Related Topics [Electrochemistry family](../README.md) [DTA Library](../../../libraries/dta/README.md) [API Reference](../../../reference/README.md) Framework Compatibility The single `definition.m` owns product metadata, requirements, layout, and optional runtime capabilities. `projectSpec.m` owns the complete version-1 domain schema, defaults, plot-parameter validation, and the required source collection; Runtime validates canonical buckets and each source record first. `createSession.m` rebuilds decoded ZCURVE items and selected paths because they are transient runtime data. Empty workflow and view buckets are supplied by Runtime V2 rather than repeated in the App factory. The App requires `labkit.ui >=7 <8` and `labkit.dta >=2 <3`; busy-state, viewport-preserving rendering, resolved-path access, and portable-reference serialization remain framework-owned. The semantic layout follows the [Runtime callback contract](../../../framework/guides/runtime.md#layout-and-action-rules): every referenced action must be registered and resolves during layout construction. 2026-07-17 - Explicit layout action contract refactor | compatible labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_GaitAnalysis_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_VideoMarker_app labkit_FigureStudio_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app 2026-07-16 - Electrochemistry source-field validation boundary fix | compatible labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app 2026-07-16 - Runtime-owned project shape validation refactor | compatible labkit.ui labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app 2026-07-16 - EIS delegates source identity reconciliation to Runtime V2 refactor | compatible labkit_EIS_app 2026-07-16 - Electrochem Apps stop reading portable references refactor | compatible labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app 2026-07-16 - EIS consolidates its product and project contracts refactor | compatible labkit_EIS_app 2026-07-16 - UI 7 public runtime boundary refactor | breaking labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_GaitAnalysis_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_VideoMarker_app labkit_FigureStudio_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app 2026-07-15 - Runtime V2 lifecycle ownership across the app fleet refactor | breaking labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_GaitAnalysis_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_VideoMarker_app labkit_FigureStudio_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app 2026-07-06 - UI 5 facade redesign, app migration, and plot refresh refactor | breaking labkit_launcher labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_FigureStudio_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app 2026-07-03 - CSC export and viewport policy feat | compatible labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app 2026-07-03 - UI groups migration refactor | compatible labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app 2026-07-03 - Declarative app runtime refactor | compatible labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app 2026-07-01 - Debug sample packs feat | compatible labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app 2026-06-30 - App alerts through UI facade feat | compatible labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_ECGPrint_app 2026-06-24 - File-panel migration refactor | breaking labkit.dta labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app 2026-06-23 - Version metadata baseline feat | compatible labkit_launcher labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app"},{"title":"2.1: RHS apps and shared runtime stability","url":"history/records/2026/06/LK-20260621-v2-1-rhs-and-runtime-stability.html","kind":"history","text":"LK-20260621-v2-1-rhs-and-runtime-stability 2026-06-21 7 feat compatible 2.1: RHS apps and shared runtime stability schema: 2 id: LK-20260621-v2-1-rhs-and-runtime-stability date: 2026-06-21 sequence: 7 type: feat compatibility: compatible scope: historical project evolution Context After v2.0, the UI runtime supported several app families but still repeated important behavior around busy actions, path events, preview zoom, and layout ownership. A new neurophysiology family would amplify those inconsistencies if each app solved them independently. Decision and rationale Add RHS reading and review as a first-class app family while centralizing only the runtime mechanics shared across domains. Filtering choices and response analysis remained in the RHS workflows; transactions, path events, zoom, and layout ownership belonged to the UI layer. Changes Added RHS Preview, Nerve Response Analysis, and Response Review Stats. Folded the separate screening step into preview filtering so file review used one workflow rather than two overlapping stages. Added duplicate Batch Crop tasks for repeated crop layouts. Unified preview scroll zoom and centralized action busy transactions. Centralized path-event contracts and app layout ownership. Added a ZIP-based updater and stabilized image and app interactions before release. Reduced focused test discovery and guardrail cost as the repository grew. User and data impact Neurophysiology users gained file preview, response analysis, and review statistics apps. Existing users saw more consistent zoom, busy feedback, path updates, and responsive layout behavior. The updater provided a repository- owned way to install a packaged release. Compatibility and migration The RHS family was additive. UI implementations that maintained their own copies of busy, path, or layout mechanics were migrated to the shared runtime contracts. Validation The stage added RHS-focused tests, interaction regressions, compatibility input checks, path-event checks, and faster focused test routing. Tag `2.1` points to `76ddf7d0`. Evidence RHS app family `7ddc036f` and preview filtering `ca8a37dd`. Shared interaction mechanics `eb52eb17`, `f3e42b8e`, `1fe89ba2`. Layout ownership `3ce4c8f7`, `09baf7fe`. Updater `e4b02f3a`. 2.1 release stabilization `76ddf7d0`. Known limitations and follow-up The updater still depended on how the launcher was installed, and several image workflows reset zoom or recomputed previews during edits. The following 2.2 and 2.3 releases concentrated on self-contained launch and direct image manipulation."},{"title":"Adjacent navigation for history records","url":"history/records/2026/07/LK-20260716-history-adjacent-navigation.html","kind":"history","text":"LK-20260716-history-adjacent-navigation 2026-07-16 64 docs additive documentation Adjacent navigation for history records schema: 2 id: LK-20260716-history-adjacent-navigation date: 2026-07-16 sequence: 64 type: docs compatibility: additive component: `documentation` scope: `tools/docs/` scope: `site/history/records/` Context The complete timeline linked every recorded change, but a reader who opened one detail page had to return to the index before continuing chronologically. Dates and filenames could not safely identify adjacent records because several changes may share one date. Decision and rationale Use validated history `sequence` metadata to add adjacent-record links at the end of every rendered history detail page. **Previous change** means the record with `sequence - 1`; **Next change** means `sequence + 1`. This preserves one unambiguous project-history order without binding authored content to Git. Changes Added responsive previous/next navigation immediately before the generated page footer. Showed only existing directions at the oldest and newest ends. Added regression coverage for link targets, ordering, and page placement. User and data impact Readers can traverse project history linearly without returning to the index. No MATLAB runtime, app state, scientific data, or exports change. Compatibility and migration Existing history URLs and record metadata remain valid. Regenerating `site/` adds navigation to every record page and updates the search index normally. Validation Renderer regression tests verify middle and endpoint records against source sequence metadata. The documentation consistency check independently rebuilds and byte-compares the tracked site. Evidence `renderLabKitHistorySequenceNavigation` owns adjacent link generation. `DocumentationRendererRegressionTest` verifies the generated navigation. Known limitations and follow-up Navigation is intentionally chronological across all project components. It does not filter to the current app or library; component-specific history remains available from each component manual."},{"title":"App alerts through UI facade","url":"history/records/2026/06/LK-20260630-app-alerts-through-ui-facade.html","kind":"history","text":"LK-20260630-app-alerts-through-ui-facade 2026-06-30 22 feat compatible labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_ECGPrint_app App alerts through UI facade schema: 2 id: LK-20260630-app-alerts-through-ui-facade date: 2026-06-30 sequence: 22 type: feat compatibility: compatible component: `labkit.ui` | `3.2.7 -> 3.2.8` component: `labkit_DICPostprocess_app` | `1.2.2 -> 1.2.3` component: `labkit_DICPreprocess_app` | `1.2.1 -> 1.2.2` component: `labkit_ChronoOverlay_app` | `1.2.0 -> 1.2.1` component: `labkit_CIC_app` | `1.2.0 -> 1.2.1` component: `labkit_CSC_app` | `1.2.0 -> 1.2.1` component: `labkit_EIS_app` | `1.2.0 -> 1.2.1` component: `labkit_VTResistance_app` | `1.2.0 -> 1.2.1` component: `labkit_BatchImageCrop_app` | `1.3.6 -> 1.3.7` component: `labkit_CurvatureMeasurement_app` | `1.2.2 -> 1.2.3` component: `labkit_FocusStack_app` | `1.2.4 -> 1.2.5` component: `labkit_ImageEnhance_app` | `1.3.3 -> 1.3.4` component: `labkit_ImageMatch_app` | `1.3.4 -> 1.3.5` component: `labkit_ECGPrint_app` | `1.2.1 -> 1.2.2` Context Apps opened alerts directly, which made hidden GUI tests unreliable and led to small differences in modal behavior across workflows. Decision and rationale Route user-visible alerts through one UI service that can display a modal message normally and record it safely during hidden tests. Keep each app responsible for the message and the decision that triggers it. Changes `labkit.ui` `3.2.7 -> 3.2.8` DIC, electrochem, image-measurement, and ECG apps patch bumped where alert routing changed. Routed app alerts through hidden-test-safe `labkit.ui.app.showAlert`. User and data impact Errors continued to appear as app alerts, but test and debug runs could capture them without blocking on an invisible dialog. No saved or exported data changed. Compatibility and migration App error conditions and messages remained app-owned. Only the display route changed, so user data and app project formats required no conversion. Validation Commit `8d7c83b1` migrated alert call sites and updated hidden workflow tests across the listed apps. Evidence Main commit `8d7c83b1`. Known limitations and follow-up The historical `labkit.ui.app.showAlert` entry point was later replaced by the injected `services.dialogs.alert` service in Runtime V2. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"App diagnostics and hardened UI workflows","url":"history/records/2026/06/LK-20260628-app-diagnostics-and-hardened-ui-workflows.html","kind":"history","text":"LK-20260628-app-diagnostics-and-hardened-ui-workflows 2026-06-28 15 feat compatible labkit_launcher labkit.ui labkit.ui labkit_BatchImageCrop_app labkit_BatchImageCrop_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app App diagnostics and hardened UI workflows schema: 2 id: LK-20260628-app-diagnostics-and-hardened-ui-workflows date: 2026-06-28 sequence: 15 type: feat compatibility: compatible component: `labkit_launcher` | `1.1.1 -> 1.1.2` component: `labkit.ui` | `3.1.0 -> 3.1.2` component: `labkit.ui` | `3.1.2 -> 3.1.3` component: `labkit_BatchImageCrop_app` | `1.3.0 -> 1.3.1` component: `labkit_BatchImageCrop_app` | `1.3.1 -> 1.3.2` component: `labkit_FocusStack_app` | `1.2.0 -> 1.2.1` component: `labkit_ImageEnhance_app` | `1.2.0 -> 1.2.1` component: `labkit_ImageEnhance_app` | `1.2.1 -> 1.2.2` component: `labkit_ImageMatch_app` | `1.2.0 -> 1.2.1` component: `labkit_NerveResponseAnalysis_app` | `1.2.0 -> 1.2.1` component: `labkit_ResponseReviewStats_app` | `1.2.0 -> 1.2.1` component: `labkit_RHSPreview_app` | `1.2.0 -> 1.2.1` Context When an app stalled or caught an exception, screenshots and the visible Log tab often omitted the active callback and stack information needed to reproduce the problem. Decision and rationale Capture active operations, uncaught crashes, caught errors, and suspected stalls in structured local reports while hardening the UI paths that generate those events. Keep reports outside project data so diagnostics cannot alter results. Changes `labkit.ui` `3.1.0 -> 3.1.3` Batch Crop, Focus Stack, Image Enhance/Match, neurophysiology apps, and the launcher patch bumped where runtime behavior changed. Hardened LabKit UI workflows. Added crash reports, active-operation reports, caught-error reports, and stall diagnostics. User and data impact Debug runs produced evidence that could identify the failing operation and app state without copying laboratory inputs into the repository. Normal projects and exports kept their previous formats. Compatibility and migration The diagnostic files were additive and existing app inputs and saved results remained readable. Debug runs began producing more detailed local artifacts. Validation Commits `e966457b` and `f5bc6f98` updated launcher, framework, and affected app workflow tests for diagnostic creation and hardened callbacks. Evidence Main commits `e966457b` and `f5bc6f98`. Known limitations and follow-up Diagnostics report the state and stack available at capture time; they do not replace a reproducible input or a focused regression test. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"App file-selection and electrochem control fixes","url":"history/records/2026/07/LK-20260703-app-file-selection-and-electrochem-control-fixes.html","kind":"history","text":"LK-20260703-app-file-selection-and-electrochem-control-fixes 2026-07-03 33 fix compatible labkit_CIC_app labkit_CIC_app labkit_CSC_app labkit_CSC_app labkit_VTResistance_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app App file-selection and electrochem control fixes schema: 2 id: LK-20260703-app-file-selection-and-electrochem-control-fixes date: 2026-07-03 sequence: 33 type: fix compatibility: compatible component: `labkit_CIC_app` | `1.3.1 -> 1.3.2` component: `labkit_CIC_app` | `1.3.2 -> 1.3.3` component: `labkit_CSC_app` | `1.3.1 -> 1.3.2` component: `labkit_CSC_app` | `1.3.2 -> 1.3.3` component: `labkit_VTResistance_app` | `1.3.1 -> 1.3.2` component: `labkit_VTResistance_app` | `1.3.2 -> 1.3.3` component: `labkit_BatchImageCrop_app` | `1.6.2 -> 1.6.3` component: `labkit_FLIRThermal_app` | `1.2.1 -> 1.2.2` component: `labkit_FocusStack_app` | `1.4.1 -> 1.4.2` component: `labkit_ImageEnhance_app` | `1.5.1 -> 1.5.2` component: `labkit_ImageMatch_app` | `1.5.1 -> 1.5.2` scope: historical project evolution Context Adding files to an existing multi-file session could replace or select the wrong entry in several apps. CIC, CSC, and VT Resistance also exposed manual plot controls that no longer matched their intended result-review workflow. Decision and rationale Preserve the complete file list when new selections are appended and select a newly added item predictably. Remove obsolete electrochem controls instead of keeping buttons whose effect was ambiguous or redundant. Changes CIC, CSC, VT Resistance, Batch Crop, FLIR Thermal, Focus Stack, Image Enhance, and Image Match patch bumped for appended file selections. CIC, CSC, and VT Resistance patch bumped again for manual plot-control removal. Preserved appended file selections. Removed electrochem manual plot controls that no longer matched the workflow. User and data impact Users could add another batch without losing files already loaded, across the listed electrochem and image apps. The electrochem workbenches became simpler; calculations and exported result schemas were unchanged. Compatibility and migration Existing loaded-file and result formats remained valid. Appended selections now preserved the active item, and the removed manual plot controls did not alter the underlying calculation options. Validation The file-selection commit added GUI regression coverage to every affected app. The control-removal commit updated the three electrochem GUI workflows and shared test helpers. Exact historical commands were not recorded. Evidence Main commits `6348185e` and `674d5d4b`. Known limitations and follow-up The change covered app-level append handling. Later framework work unified the native file-selection behavior across platforms."},{"title":"App validation no longer relies on sibling App paths","url":"history/records/2026/07/LK-20260717-isolated-app-validation.html","kind":"history","text":"LK-20260717-isolated-app-validation 2026-07-17 128 test compatible App validation no longer relies on sibling App paths schema: 2 id: LK-20260717-isolated-app-validation date: 2026-07-17 sequence: 128 type: test compatibility: compatible scope: Public App path isolation scope: Changed-file validation routing scope: GUI CI evaluation Context The ordinary public test setup adds every App entry folder so family and cross-App contract suites can run efficiently. That broad path can conceal an accidental dependency on a sibling App package. Static source scans rejected direct sibling calls, and individual Gait/private launch tests used isolated paths, but there was no uniform executable contract for every public App or automatic changed-file route to that contract. Decision and rationale Keep the convenient broad path for ordinary suites, but add one fast independent proof. For each public App, restore MATLAB's default path, add only the repository root and the owning App root, load its complete definition, check declared facade compatibility, and write its synthetic debug sample. Run this contract whenever public App source changes. Static call scanning and dynamic isolation cover different risks and remain separate. The former identifies the forbidden source reference; the latter proves metadata, framework requirements, and debug generation work in the same path shape as a single-App package. Changes Added one isolated-path contract covering all 20 public Apps. Routed every App source change to that contract in both conservative and fast changed-file plans. Added a planner regression so future routing changes cannot silently drop isolation coverage. Documented why independent private workspaces must run their own compatibility checks without inheriting public App roots. Evaluated, but did not add, a hidden-GUI job to every pull request. User and developer impact There is no App or saved-data behavior change. A new sibling package dependency, stale facade requirement, incomplete definition, or broken debug sample now fails in a focused non-GUI contract instead of passing because all Apps happened to be on the developer's MATLAB path. Compatibility and migration No migration is required. Existing test commands remain unchanged; the changed-file planner adds the focused isolation selection automatically. Validation The all-App isolation contract completed in about 14 seconds locally. Existing changed-file evidence measured representative hidden image workflows at about 18 seconds each and a Gait hidden launch at about 30 seconds, before MATLAB startup and license acquisition. Since those synthetic GUI checks still do not prove native dialogs, pointer feel, or visual quality, ordinary CI keeps the fast non-GUI contract while App-specific GUI checks remain changed-file, scheduled, manual, and release validation. The private workspace's independent suite passes after removing public App roots from its runner path. Evidence [Testing and validation](../../../../development/maintain-and-release/testing.md) [Architecture](../../../../development/build-apps/architecture.md) [App development](../../../../development/build-apps/app-development.md) Known limitations and follow-up MATLAB process-global function caches cannot replace package-boundary source review, so the static sibling-call guard remains required. Hidden GUI tests remain bounded semantic checks rather than substitutes for manual interaction and visual assessment."},{"title":"Base-MATLAB image compatibility","url":"history/records/2026/07/LK-20260713-base-matlab-image-compatibility.html","kind":"history","text":"LK-20260713-base-matlab-image-compatibility 2026-07-13 45 feat compatible labkit.image labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app Base-MATLAB image compatibility schema: 2 id: LK-20260713-base-matlab-image-compatibility date: 2026-07-13 sequence: 45 type: feat compatibility: compatible component: `labkit.image` | `1.1.0 -> 1.2.0` component: `labkit_DICPostprocess_app` | `1.3.4 -> 1.3.5` component: `labkit_DICPreprocess_app` | `1.3.5 -> 1.3.6` component: `labkit_FocusStack_app` | `1.4.7 -> 1.4.8` component: `labkit_ImageEnhance_app` | `1.5.6 -> 1.5.7` component: `labkit_ImageMatch_app` | `1.5.6 -> 1.5.7` Context Several image workflows appeared toolbox-optional but still called Image Processing Toolbox functions for conversion, grayscale luminance, resizing, smoothing, alignment, or warping. Development machines with the toolbox hid those calls, so a nominally supported Base MATLAB installation could fail only after the user entered a particular workflow. Decision and rationale Own the essential image operations used by LabKit and make their Base MATLAB paths the tested behavior. Optional toolbox acceleration could remain only when the call was explicit, a repository-owned fallback existed, and parity tests protected the app-consumed result. Changes `labkit.image` `1.1.0 -> 1.2.0` `labkit_DICPreprocess_app` `1.3.5 -> 1.3.6` `labkit_DICPostprocess_app` `1.3.4 -> 1.3.5` `labkit_FocusStack_app` `1.4.7 -> 1.4.8` `labkit_ImageEnhance_app` `1.5.6 -> 1.5.7` `labkit_ImageMatch_app` `1.5.6 -> 1.5.7` Added `labkit.image.toDouble` and `labkit.image.toLuma`, and replaced hard Image Processing Toolbox calls in shared image facade code and image-app workflow paths with base-MATLAB implementations. DIC preprocessing now uses a toolbox-free phase-correlation translation path for automatic alignment and a base-MATLAB rigid warp for control-point alignment. DIC postprocessing, Focus Stack, Image Enhance, and Image Match now use app-local or facade-owned image normalization, resizing, smoothing, and luma helpers instead of requiring toolbox functions. Added a project hygiene guardrail that rejects unguarded toolbox image helper calls under `apps/` and `+labkit/`, while still allowing explicit optional toolbox paths with fallbacks. User and data impact DIC alignment and overlays, Focus Stack, Image Enhance, and Image Match could run without Image Processing Toolbox. Users with the toolbox retained compatible workflows, while CI now exercised the installation that previously failed late and silently. Compatibility and migration Existing app workflows and exported schemas are preserved. Optional toolbox acceleration paths remain allowed only when a base-MATLAB fallback is present. Validation The commit expanded DIC, Focus Stack, Image Enhance, Image Match, and image- facade unit suites and added `ToolboxDependencyGuardrailTest` to detect new unguarded calls. GUI layout tests covered the affected DIC workflows. The exact historical command was not recorded. Evidence Mainline commit `bcd5f51f`. Known limitations and follow-up Base MATLAB compatibility does not mean every optional accelerated algorithm is numerically identical by construction. Where a toolbox branch affects scientific values, representative parity and idempotency tests remain required until the repository-owned implementation fully replaces it. labkit.image.adjustBrightnessContrast Apply simple brightness and contrast adjustment. labkit.image.adjustHueSaturation Apply HSV hue and saturation adjustment. labkit.image.assertSupportedPaths Throw when any path has an unsupported image extension. labkit.image.displayName Return a short image-file display name. labkit.image.ensureRgb Return image data with exactly three color channels. labkit.image.fileDialogFilter Return a file-chooser-compatible image filter. labkit.image.grayWorldWhiteBalance Apply generic gray-world white balance. labkit.image.im2double Convert image data to double using MATLAB's im2double contract. labkit.image.isSupportedPath Return true when a path has a supported image extension. labkit.image.localContrast Enhance local value-channel contrast with a mean-filter mask. labkit.image.meanFilter2 Apply normalized 2-D mean filtering with edge correction. labkit.image.normalizePaths Normalize image file path inputs to a string column. labkit.image.previewBudget Downsample image data to fit a display pixel budget. labkit.image.readFiles Read image files into path/name/image records. labkit.image.resizeToFit Resize an image to fit within maximum row/column limits. labkit.image.rgb2gray Convert RGB data using MATLAB's rgb2gray call contract. labkit.image.sharpen Apply generic unsharp-mask sharpening. labkit.image.supportedExtensions Return extensions supported by LabKit image file inputs. labkit.image.version Return the LabKit image facade contract version. labkit.image.writeFile Write one image file, creating the parent folder when needed."},{"title":"Batch Crop assigns state to workflow capabilities","url":"history/records/2026/07/LK-20260716-batch-crop-structure.html","kind":"history","text":"LK-20260716-batch-crop-structure 2026-07-16 101 refactor compatible labkit_BatchImageCrop_app Batch Crop assigns state to workflow capabilities schema: 2 id: LK-20260716-batch-crop-structure date: 2026-07-16 sequence: 101 type: refactor compatibility: compatible component: `labkit_BatchImageCrop_app` | `1.7.2 -> 1.7.3` scope: Image Measurement scope: App structure Context Batch Crop split static metadata across separate files, spread project creation, validation, migration, and session construction through a generic lifecycle package, and placed source decoding, crop-task operations, geometry, scale calibration, export planning, and transient caches together under `+appState`. The App also retained an unused scale-unit adapter and a startup callback whose only job duplicated the shared source-adjacent output-folder default. Decision and rationale Use the compact Runtime V2 definition and one project contract, then assign remaining helpers to the workflow capability that owns their meaning. Crop geometry, scale calibration, and export planning are real App complexity and remain App-owned; generic lifecycle and state buckets are structural debt and are removed. Changes Consolidated product metadata, version, requirements, layout, actions, presentation, renderers, and optional capabilities in `definition.m`. Concentrated durable creation, validation, and the version-1 upgrade in the single `projectSpec.m` entry; root `createSession.m` rebuilds transient state and lazily loads the selected source. Replaced embedded image data and direct path fields with canonical source records and `sourceId` task references. Assigned file reconstruction to `+sourceFiles`, task operations to `+cropTasks`, coordinate and cache behavior to `+cropGeometry`, calibration semantics to `+scaleCalibration`, and export state to `+resultFiles`. Removed the generic lifecycle/state packages, separate metadata files, redundant startup callback, and unused scale-unit wrapper. User and developer impact Multiple crop tasks per source, draggable and resizable ROIs, zoom-preserving preview edits, rotation, edge-continuous padding, pixel and physical crop modes, per-source calibration, scale bars, manifests, and exports keep their existing behavior. Empty launch no longer computes an output folder; importing sources still selects the source-adjacent default. Developers can now find each calculation and state transition under its owning workflow concept without learning a generic App state package. Compatibility and migration Version-1 saved projects upgrade automatically to schema version 2. The migration discards reconstructible embedded pixels, creates portable source records for legacy paths, and preserves crop-task settings. Runtime source reconciliation handles moved or missing required files. Validation Focused unit tests cover project creation, validation and migration, source reading, crop geometry, scale planning, export planning, and manifest output. The hidden GUI workflow loads an image, manipulates the crop workflow, and exports a synthetic crop through the real Runtime callbacks. Evidence [Batch Image Crop](../../../../apps/image-measurement/batch-crop/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) [Image Library](../../../../libraries/image/README.md) Known limitations and follow-up Automated GUI tests do not replace manual judgment of ROI pointer feel, resampling quality, scale-reference placement, or export suitability for a particular quantitative workflow. Other Apps that still use generic lifecycle or state packages remain scheduled for the same ownership review."},{"title":"Batch Crop file workflow feedback","url":"history/records/2026/06/LK-20260628-batch-crop-file-workflow-feedback.html","kind":"history","text":"LK-20260628-batch-crop-file-workflow-feedback 2026-06-28 14 feat compatible labkit.ui labkit_BatchImageCrop_app Batch Crop file workflow feedback schema: 2 id: LK-20260628-batch-crop-file-workflow-feedback date: 2026-06-28 sequence: 14 type: feat compatibility: compatible component: `labkit.ui` | `3.0.1 -> 3.1.0` component: `labkit_BatchImageCrop_app` | `1.2.0 -> 1.3.0` Context In a multi-file Batch Crop session, the window and preview did not consistently show which list item supplied the current image and crop settings. Decision and rationale Make the selected file part of the shared window-title context and update Batch Crop feedback whenever selection changes. Keep the title derived from the file panel so the app does not maintain a second selection label. Changes `labkit.ui` `3.0.1 -> 3.1.0` Batch Crop `1.2.0 -> 1.3.0` Added selected-file title context. Improved Batch Crop file workflow feedback. User and data impact The active filename became visible while reviewing or editing a crop task, reducing the chance of applying settings to the wrong image. Crop geometry and exported data were unchanged. Compatibility and migration Batch Crop tasks and exports kept their existing format. The change affected file-selection context and feedback in the window only. Validation Commit `61e8edd3` updated shared title-context and Batch Crop GUI workflow coverage. Evidence Main commit `61e8edd3`. Known limitations and follow-up Later file-panel work extended the same title context and append behavior to the rest of the app fleet. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Binding-only controls and default resource cleanup","url":"history/records/2026/07/LK-20260717-binding-and-resource-defaults.html","kind":"history","text":"LK-20260717-binding-and-resource-defaults 2026-07-17 122 refactor compatible labkit.ui labkit_VideoMarker_app Binding-only controls and default resource cleanup schema: 2 id: LK-20260717-binding-and-resource-defaults date: 2026-07-17 sequence: 122 type: refactor compatibility: compatible component: `labkit.ui` | `7.4.5 -> 7.4.6` component: `labkit_VideoMarker_app` | `1.5.4 -> 1.5.5` scope: Runtime V2 action registration scope: Runtime V2 resource ownership Context Runtime V2 already supported controls whose complete behavior is writing one bound state path, but Video Marker still registered an empty action for its skeleton-preset selector. The private resource registry also had a default cleanup policy, while the injected App service required every call to supply a fourth cleanup function. Video Marker therefore kept a second empty function for its plain decoded-video resource. Decision and rationale Use the existing binding-only contract for state-only controls and expose the resource registry's existing default cleanup through the injected service. Apps should declare an action only when a user event has workflow behavior beyond committing the bound value. Plain structs and values require no App-owned cleanup placeholder. Changes Removed Video Marker's empty skeleton-preset change action and left the dropdown as a direct session binding. Made the injected `services.resources.set(scope,id,value)` form select the Runtime's default cleanup, while retaining the optional fourth custom cleanup function. Removed Video Marker's empty video-resource cleanup function. Added Runtime GUI coverage for binding-only state commits and resources registered without a custom cleanup. User and data impact There is no workflow, scientific, project-data, or saved-file change. Selecting a skeleton preset still updates the session immediately, and **Use preset** still performs the only durable skeleton edit. Video cache replacement and session cleanup retain the same observable behavior. Compatibility and migration The change is source-compatible. Existing four-argument resource registration continues to use the supplied cleanup function. No project migration is required. Validation The focused Runtime V2 GUI test exercises both service forms and proves that a binding without `Event` commits state without invoking an App action. Video Marker structure and targeted behavior tests cover the simplified definition. Evidence [Runtime and Lifecycle](../../../../framework/guides/runtime.md) documents bound controls and injected resource ownership. [Video Marker](../../../../apps/image-measurement/video-marker/README.md) describes the preset and decoded-video resource behavior. Known limitations and follow-up Other bound events still require App actions when they normalize values, invalidate results, update previews, or log workflow changes. They should be reviewed by behavior rather than removed merely because their handlers are short. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"CIC consolidates its project contract without losing lazy loading","url":"history/records/2026/07/LK-20260716-cic-project-spec.html","kind":"history","text":"LK-20260716-cic-project-spec 2026-07-16 87 refactor compatible labkit_CIC_app CIC consolidates its project contract without losing lazy loading schema: 2 id: LK-20260716-cic-project-spec date: 2026-07-16 sequence: 87 type: refactor compatibility: compatible component: `labkit_CIC_app` | `1.4.1 -> 1.4.2` scope: Electrochemistry scope: Project lifecycle Context CIC split static metadata across three files and its first-version project across a generic lifecycle package. Its session factory also encodes an important performance policy: restore only the first source for preview rather than parsing an entire large batch. Decision and rationale Consolidate product metadata in `definition.m` and durable schema behavior in `projectSpec.m`. Keep `createSession.m` separate and explicit because lazy source decoding is real transient reconstruction and a user-visible performance boundary, not lifecycle boilerplate. Changes Moved command metadata, version, update date, and requirements into the definition. Consolidated project defaults and validation behind one project spec. Moved lazy preview restoration to one package-root session factory. Removed separate metadata files and the generic lifecycle package. Kept pulse detection, CIC formulas, units, limits, plots, and exports unchanged. User and developer impact Launch, source selection, large-batch behavior, computation, save/load, and CSV exports behave unchanged. The App structure is smaller while the reason for its lazy session factory is now documented next to the code. Compatibility and migration The project remains version 1 with identical fields, defaults, validation, and source records. Existing CIC projects require no migration. Validation Unit tests cover the project contract, CIC calculation, units, failed rows, CSV schema, and presentation. The hidden GUI workflow covers real launch, source selection, calculation, save/load, and export through Runtime V2. Evidence [CIC](../../../../apps/electrochemistry/cic/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up The session currently reads `reference.originalPath` directly. That internal portable-reference representation will be replaced by a simple framework source-path service across all Apps rather than patched only in CIC."},{"title":"CIC uses Runtime-owned source and workflow mechanics","url":"history/records/2026/07/LK-20260716-cic-runtime-source-reconciliation.html","kind":"history","text":"LK-20260716-cic-runtime-source-reconciliation 2026-07-16 112 refactor compatible labkit_CIC_app CIC uses Runtime-owned source and workflow mechanics schema: 2 id: LK-20260716-cic-runtime-source-reconciliation date: 2026-07-16 sequence: 112 type: refactor compatibility: compatible component: `labkit_CIC_app` | `1.4.3 -> 1.4.4` scope: Electrochemistry scope: App structure scope: Runtime adoption Context CIC intentionally registers paths before decoding them so large batches remain lazy, but it still generated, appended, and deleted portable source records in App code. It also wrote directly to `session.workflow.logLines`, requiring its session factory to reproduce Runtime-owned empty workflow and view buckets. Those details increased the amount of framework structure an App maintainer had to understand without expressing CIC analysis behavior. Decision and rationale CIC continues to own the ordered lazy path set, current-index policy, deferred decoding, scientific options, and result workflow. It now passes that ordered path set to `services.project.reconcileSources` after add, removal, and clear operations. Runtime preserves matching identities and allocates new unique ones. Analysis messages use `services.workflow.log`, the same service as other workflow messages. `createSession` returns only CIC-specific selection and decoded-cache state; Runtime creates missing transient buckets before actions run. Changes Removed App-local source-ID generation and source append helpers. Reconciled registered paths through the Runtime project service. Routed analysis messages through the Runtime workflow service. Removed empty workflow and view boilerplate from `createSession`. Added GUI checks for stable and unique identities across batch add, save, reopen, removal, and re-addition. Advanced the CIC App version to 1.4.4. User and data impact Lazy decoding, file order, selected preview, analysis values, plots, exports, logs, and project files retain their behavior. Source identities remain stable for retained files and collision-free for newly registered files. Scientific calculations and output schemas are unchanged. Compatibility and migration The current project payload remains version 1 and needs no migration. Existing source records and their identities are preserved when reopened. Validation The CIC hidden GUI workflow covers three-source lazy registration, selection, whole-batch recomputation, export, save, clear, reopen, remove, re-add, source identity, viewport refresh, and final clear. Focused calculation/view, App structure, version, documentation, and history tests cover the surrounding contracts. Evidence [Charge-Injection Capacity](../../../../apps/electrochemistry/cic/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) [App Development](../../../../development/build-apps/app-development.md) Known limitations and follow-up CSC and VT Resistance still own similar source registration helpers. Their result indexing and lazy-load behavior must be checked independently before adopting the Runtime service."},{"title":"CSC consolidates its product and project contracts","url":"history/records/2026/07/LK-20260716-csc-project-spec.html","kind":"history","text":"LK-20260716-csc-project-spec 2026-07-16 88 refactor compatible labkit_CSC_app CSC consolidates its product and project contracts schema: 2 id: LK-20260716-csc-project-spec date: 2026-07-16 sequence: 88 type: refactor compatibility: compatible component: `labkit_CSC_app` | `1.4.1 -> 1.4.2` scope: Electrochemistry scope: Project lifecycle Context CSC split product metadata across three files and its durable schema across a generic lifecycle package even though it has no historical payload migration. The additional entry points did not own distinct electrochemical behavior. Decision and rationale Make `definition.m` the complete product declaration and `projectSpec.m` the sole durable-schema entry. Keep `createSession.m` explicit because reloading CV/CT curves and restoring file/cycle selection is genuine transient work. Changes Consolidated command metadata, version, update date, and requirements in the definition. Consolidated project defaults and validation behind one project spec. Moved decoded curve and selection restoration to a package-root session factory. Removed separate metadata files and the generic lifecycle package. Kept integration formulas, sign splitting, scan-rate handling, area normalization, plots, and export schemas unchanged. User and developer impact Launch, source and cycle selection, save/load, calculations, plots, and both export paths behave unchanged. The durable contract is now readable in one place without hiding scientific behavior in the framework. Compatibility and migration The project remains version 1 with identical fields, defaults, validation, and source records. Existing CSC projects require no migration. Validation Unit tests cover CT/CV calculations, edge-cycle policy, normalization, result tables, export schemas, project/session contracts, and presentation. The hidden GUI workflow covers real launch, file/cycle selection, calculation, save/load, plot overlays, and exports. Evidence [CSC](../../../../apps/electrochemistry/csc/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Decoded source restoration still reaches into portable-reference internals. That cross-App framework boundary remains scheduled for one shared source-path service rather than an App-specific wrapper."},{"title":"CSC delegates decoded-source identity to Runtime V2","url":"history/records/2026/07/LK-20260716-csc-runtime-source-reconciliation.html","kind":"history","text":"LK-20260716-csc-runtime-source-reconciliation 2026-07-16 113 refactor compatible labkit_CSC_app CSC delegates decoded-source identity to Runtime V2 schema: 2 id: LK-20260716-csc-runtime-source-reconciliation date: 2026-07-16 sequence: 113 type: refactor compatibility: compatible component: `labkit_CSC_app` | `1.4.3 -> 1.4.4` scope: Electrochemistry scope: App structure scope: Runtime adoption Context CSC eagerly decodes accepted CV/CT files and keeps decoded item order aligned with durable source order. Its action file nevertheless generated source IDs, appended records, and deleted records by index. The session factory also declared empty workflow and view buckets already guaranteed by Runtime V2. Those mechanics were framework persistence responsibilities rather than CSC curve-selection or scientific behavior. Decision and rationale Continue using successfully decoded item order as the App-owned source order, then reconcile durable records through `services.project.reconcileSources` after additions, removals, and clearing. Runtime preserves records for retained paths and allocates collision-free IDs for new paths. CSC continues to own decoding, active file and curve selection, plot defaults, calculation, exports, and failure messages. Return only the CSC-specific selection fields and decoded item cache from `createSession`; Runtime supplies missing transient buckets. Changes Removed App-local source-ID generation and source append helpers. Reconciled source records from the decoded item list. Replaced the growing failure array with a single first-failure record while retaining per-file workflow logs. Removed empty workflow and view session boilerplate. Added GUI checks for canonical buckets and stable, unique identities across add, save, reopen, removal, and re-addition. Advanced the CSC App version to 1.4.4. User and data impact File order, selected curve, plot resets, CSC calculations, reload, exports, logs, and saved projects retain their behavior. Retained source identities no longer depend on App-local append/delete logic. Scientific values and result schemas are unchanged. Compatibility and migration The current project payload remains version 1 and needs no migration. Existing portable records retain their IDs when the corresponding paths remain loaded. Validation The CSC hidden GUI workflow covers two-file decoding, file and cycle selection, plot changes, calculation display, export, save, clear, reopen, remove, re-add, source identity, and final plot clearing. Focused CSC calculation/view and repository contract tests cover the remaining behavior. Evidence [Charge-Storage Capacity](../../../../apps/electrochemistry/csc/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) [App Development](../../../../development/build-apps/app-development.md) Known limitations and follow-up VT Resistance remains the last electrochem batch App with the same local source-ID and append pattern and requires a separate behavior audit."},{"title":"CSC export and viewport policy","url":"history/records/2026/07/LK-20260703-csc-export-and-viewport-policy.html","kind":"history","text":"LK-20260703-csc-export-and-viewport-policy 2026-07-03 35 feat compatible labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app CSC export and viewport policy schema: 2 id: LK-20260703-csc-export-and-viewport-policy date: 2026-07-03 sequence: 35 type: feat compatibility: compatible component: `labkit.ui` | `4.0.0 -> 4.1.0` component: `labkit_DICPostprocess_app` | `1.3.2 -> 1.3.3` component: `labkit_DICPreprocess_app` | `1.3.2 -> 1.3.3` component: `labkit_ChronoOverlay_app` | `1.3.2 -> 1.3.3` component: `labkit_CIC_app` | `1.3.4 -> 1.3.5` component: `labkit_CSC_app` | `1.3.4 -> 1.3.6` component: `labkit_EIS_app` | `1.3.2 -> 1.3.3` component: `labkit_VTResistance_app` | `1.3.4 -> 1.3.5` component: `labkit_BatchImageCrop_app` | `1.6.4 -> 1.6.5` component: `labkit_CurvatureMeasurement_app` | `1.3.2 -> 1.3.3` component: `labkit_FLIRThermal_app` | `1.2.3 -> 1.2.4` component: `labkit_FocusStack_app` | `1.4.3 -> 1.4.4` component: `labkit_ImageEnhance_app` | `1.5.3 -> 1.5.4` component: `labkit_ImageMatch_app` | `1.5.3 -> 1.5.4` component: `labkit_NerveResponseAnalysis_app` | `1.3.2 -> 1.3.3` component: `labkit_ResponseReviewStats_app` | `1.3.2 -> 1.3.3` component: `labkit_RHSPreview_app` | `1.3.2 -> 1.3.3` component: `labkit_ECGPrint_app` | `1.3.3 -> 1.3.4` Context CSC could export the selected cycle, but comparing activation and stability across a recording required all cycles in one dataset. In parallel, app layouts made their own assumptions about minimum window size and scrollable content, so the same control panel could behave differently in a small viewport. Decision and rationale Add an explicit all-cycle CSC export without changing the existing selected- cycle result. Define viewport behavior in the UI contract so each app declares its content needs while the framework decides when scrolling or minimum sizing is necessary. Changes `labkit.ui` `4.0.0 -> 4.1.0` All supported apps received aligned patch bumps. Added CSC all-cycle export. Added viewport policy support and aligned app contracts with the UI 4.x line. User and data impact CSC users could export every cycle for downstream comparison while retaining the existing focused export. Across the workbench, small windows followed one scrolling and sizing policy instead of clipping controls according to the app that happened to create them. Compatibility and migration The selected-cycle CSC export remained available and the all-cycle table was a new option. Existing app definitions inherited the default viewport behavior until they declared more specific needs. Validation Commit `a69829c6` added a dedicated CSC export suite, extended the CSC GUI layout test, and added workbench viewport coverage. Evidence Main commit `a69829c6`. Known limitations and follow-up All-cycle CSV output was a separate export choice rather than a new session format. Later CSC work refined column naming and voltage/current organization. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Canonical role-based source collections","url":"history/records/2026/07/LK-20260716-canonical-role-based-sources.html","kind":"history","text":"LK-20260716-canonical-role-based-sources 2026-07-16 73 refactor compatible labkit_RHSPreview_app labkit_NerveResponseAnalysis_app Canonical role-based source collections schema: 2 id: LK-20260716-canonical-role-based-sources date: 2026-07-16 sequence: 73 type: refactor compatibility: compatible component: `labkit_RHSPreview_app` | `1.4.0 -> 1.4.1` component: `labkit_NerveResponseAnalysis_app` | `1.4.0 -> 1.4.1` scope: project payload schema scope: source roles and relinking Context RHS Preview and Nerve Response Analysis stored external files in separate app-specific fields such as `rhsSource`, `filterSources`, and `protocolSource`. The records already carried explicit roles, but Runtime V2 could only discover the canonical `project.inputs.sources` collection during portable save/load and missing-file relinking. Decision and rationale Store every external dependency in one canonical collection and select records by their app-owned role. The framework remains domain-neutral, while each app continues to distinguish preview recordings, filter recordings, filter JSON, and optional protocols without duplicating persistence mechanics. Changes Advanced both project payload schemas from version 1 to version 2. Added ordered migrations that combine the former role-specific fields without changing source record contents or order. Added app-local role selection and replacement operations used consistently by lifecycle creation, actions, presenters, exports, and validation. Kept at most one primary recording/protocol per relevant app while allowing RHS Preview to retain an ordered collection of filter recordings. User and data impact Project reopen, result provenance, and future missing-file recovery can now see all selected dependencies. Preview, filtering, event detection, CAP metrics, and output formats are unchanged. Compatibility and migration Existing version 1 payloads remain readable and preserve every source record. The old fields are removed only from the migrated in-memory project; saving writes the version 2 canonical collection. Validation Focused unit tests verify role-preserving migrations and project versions. Focused GUI workflows verify RHS indexing, multiple filter-file management, project save/reopen, filter analysis, export, and transient cache rebuilding. Evidence [RHS Preview](../../../../apps/neurophysiology/rhs-preview/README.md) documents its three source roles. [Nerve Response Analysis](../../../../apps/neurophysiology/nerve-response-analysis/README.md) documents filter/protocol restoration and relinking. [App Framework](../../../../framework/README.md) defines the canonical project source collection. Known limitations and follow-up This change makes every source discoverable but does not yet rebase relative paths at the final project save destination. That framework serialization change is tracked as the next persistence batch."},{"title":"Canonical single-source app projects","url":"history/records/2026/07/LK-20260716-canonical-single-source-projects.html","kind":"history","text":"LK-20260716-canonical-single-source-projects 2026-07-16 72 refactor compatible labkit_GaitAnalysis_app labkit_CurvatureMeasurement_app labkit_ECGPrint_app labkit_ResponseReviewStats_app Canonical single-source app projects schema: 2 id: LK-20260716-canonical-single-source-projects date: 2026-07-16 sequence: 72 type: refactor compatibility: compatible component: `labkit_GaitAnalysis_app` | `2.0.0 -> 2.0.1` component: `labkit_CurvatureMeasurement_app` | `1.4.0 -> 1.4.1` component: `labkit_ECGPrint_app` | `1.4.0 -> 1.4.1` component: `labkit_ResponseReviewStats_app` | `1.4.0 -> 1.4.1` scope: project payload schema scope: source relinking Context The Runtime V2 project contract discovers external files through the `project.inputs.sources` collection. Four single-source apps instead stored their record under a singular `project.inputs.source` field. Their own actions could open that path, but framework save/load relinking could not discover it. Decision and rationale Use the same canonical source collection for every single-source app. The plural field is a collection even when it currently contains zero or one record, so persistence and relinking do not need app-specific field knowledge. Changes Moved Gait Analysis, Curvature Measurement, ECG Print, and Response Review Stats source ownership to `project.inputs.sources`. Advanced each payload schema by one version and added the corresponding ordered migration. Updated action, presenter, session, validation, export, and manual references to the canonical collection. Corrected the Gait family overview to describe its current strict Video Marker project input instead of retired generic MAT/table inputs. User and data impact New saves expose their source records to the common portable-project resolver. Scientific calculations, UI choices, annotations, parameters, and result values are unchanged. Compatibility and migration Existing payloads remain readable. Loading copies the former singular field to the canonical collection and removes the retired field. A later save writes the new payload version; source record contents are not transformed. Validation Focused unit tests verify each field migration, current project version, and existing calculations. Focused hidden-GUI tests verify that each app still launches and follows its source workflow with the renamed durable field. Evidence [App Framework](../../../../framework/README.md) defines the canonical source collection used by project persistence. [Gait Analysis](../../../../apps/gait/gait-analysis/README.md), [Curvature Measurement](../../../../apps/image-measurement/curvature/README.md), [ECG Print](../../../../apps/wearable/ecg-print/README.md), and [Response Review Stats](../../../../apps/neurophysiology/response-review-stats/README.md) describe their upgraded project behavior. Known limitations and follow-up This record covers single-source apps. Multi-role neurophysiology projects are audited separately because preserving source roles requires a richer migration than renaming one field."},{"title":"Chrono Overlay adopts one version-aware project migration entry","url":"history/records/2026/07/LK-20260716-chrono-overlay-project-spec.html","kind":"history","text":"LK-20260716-chrono-overlay-project-spec 2026-07-16 86 refactor compatible labkit_ChronoOverlay_app Chrono Overlay adopts one version-aware project migration entry schema: 2 id: LK-20260716-chrono-overlay-project-spec date: 2026-07-16 sequence: 86 type: refactor compatibility: compatible component: `labkit_ChronoOverlay_app` | `1.4.1 -> 1.4.2` scope: Electrochemistry scope: Project lifecycle Context Chrono Overlay split product metadata across three files and represented its only payload upgrade as a separately named `migrateProjectV1ToV2.m` file in a generic lifecycle package. Adding another schema version would have added yet another file and exposed migration sequencing to the App definition. Decision and rationale Make `definition.m` the product declaration and `projectSpec.m` the sole durable-schema entry. Its `Migrate(project, fromVersion)` callback owns App semantics for one version step; Runtime V2 owns the loop, version increment, validation after every step, and unsupported-envelope handling. Changes Consolidated project creation, validation, and migration in one project spec. Replaced the per-version migration file with one version-aware local function. Moved transient DTA reconstruction to a package-root session factory. Removed separate requirements, version, and generic lifecycle files. Kept DTA parsing, pulse-gap alignment, interpolation, plots, and exports unchanged. User and developer impact Launch, plotting, save/load, v1 project upgrade, and CSV export behavior remain unchanged. Maintainers now add future schema cases in one ordered migration entry instead of editing both definition wiring and file inventories. Compatibility and migration Version-1 payloads still remove the obsolete decoded `inputs.items` field so portable source records remain the only durable input. Version-2 payloads are unchanged and need no migration. Validation Unit tests call the new migration entry with a synthetic v1 payload and verify validation, session reconstruction, pulse alignment, interpolation, and presenter output. The hidden GUI workflow covers real launch, source loading, plotting, and export through the consolidated definition. Evidence [Chrono Overlay](../../../../apps/electrochemistry/chrono-overlay/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Only v1-to-v2 is currently required. Future cases remain App-owned in the same function; they must not bypass the framework's stepwise validation loop."},{"title":"Chrono Overlay removes App-owned source identity bookkeeping","url":"history/records/2026/07/LK-20260716-chrono-runtime-source-reconciliation.html","kind":"history","text":"LK-20260716-chrono-runtime-source-reconciliation 2026-07-16 111 fix compatible labkit_ChronoOverlay_app Chrono Overlay removes App-owned source identity bookkeeping schema: 2 id: LK-20260716-chrono-runtime-source-reconciliation date: 2026-07-16 sequence: 111 type: fix compatibility: compatible component: `labkit_ChronoOverlay_app` | `1.4.3 -> 1.4.4` scope: Electrochemistry scope: App structure scope: Project identity Context Chrono Overlay generated a new source ID from the current record count. After removing an earlier source, adding another file could therefore reuse an ID still owned by a retained record. Runtime validation correctly rejects duplicate IDs, but the App should not have been maintaining this persistence invariant itself. The App also duplicated source append/removal helpers and empty transient workflow/view buckets already supplied by Runtime V2. Decision and rationale After every successful add, removal, or clear operation, derive the ordered path list from the decoded items and delegate durable-record reconciliation to `services.project.reconcileSources`. Chrono Overlay continues to own decoding, pulse-gap alignment, item order, selection, logs, and failure wording. Runtime owns stable identity reuse and collision-free allocation. Only App-specific selection and decoded cache data are returned by `createSession`; Runtime canonicalizes the remaining transient buckets. Changes Removed App-local source record append, removal, and count-based ID logic. Reconciled source records from the accepted decoded item order. Removed empty workflow and view boilerplate from the session factory. Replaced the dynamically growing failure list with one first-failure record while retaining per-file log messages. Added a GUI regression sequence that removes the first of two sources, adds it again, checks unique IDs, saves, clears, and reopens the project. Advanced the Chrono Overlay App version to 1.4.4. User and data impact Normal file loading, ordering, selection, plots, exports, and saved project payloads retain their behavior. The remove-then-add sequence no longer risks a duplicate source ID and failed callback. Persistence IDs remain internal and do not change curve values or scientific output. Compatibility and migration The current payload remains version 2 and no new migration is needed. Existing records retain their IDs when their paths remain in the project. Validation The Chrono Overlay hidden GUI workflow covers two-file loading, selection, remove and re-add, unique identity, export, save, clear, reopen, option redraw, and final plot clearing. Focused unit, structure, version, documentation, and history tests protect the calculation and product contracts. Evidence [Chrono Overlay](../../../../apps/electrochemistry/chrono-overlay/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) [App Development](../../../../development/build-apps/app-development.md) Known limitations and follow-up CIC, CSC, and VT Resistance still have equivalent App-local source identity helpers and require their own ordering and behavior audits before conversion."},{"title":"Clean-room cross-platform CI","url":"history/records/2026/07/LK-20260717-clean-room-cross-platform-ci.html","kind":"history","text":"LK-20260717-clean-room-cross-platform-ci 2026-07-17 131 ci compatible Clean-room cross-platform CI schema: 2 id: LK-20260717-clean-room-cross-platform-ci date: 2026-07-17 sequence: 131 type: ci compatibility: compatible scope: project validation and release automation Context The previous MATLAB workflow combined ordinary CI, scheduled reports, release validation, and raw tag creation. Its optional Base MATLAB job also mixed static source checks with installed-product dependency analysis, even though that analysis can only report products present on the runner. The design made it difficult to tell which evidence was required and did not exercise every candidate outside the developer's operating system. Decision and rationale Make a Toolbox-free MATLAB installation the baseline for all required CI behavior. Let one CI workflow run complete headless and hidden-GUI tasks on Linux, macOS, and Windows. Keep coverage as an explicit local report. Release creation remains a developer-initiated process after manual App validation, requires a successful main-push CI run for the exact commit, and stops at a draft for final human review. This directly tests the promised runtime instead of trying to infer the same fact from a runner with optional products installed. Changes Replaced the monolithic MATLAB workflow with one ordinary/reusable CI workflow and one manual release workflow. Made cross-platform Base MATLAB headless and hidden-GUI validation mandatory for pull requests and main pushes, and required exact same-commit CI evidence before release. Removed the installed-product dependency-analysis task and all CI Toolbox installation. Kept static known-Toolbox call detection and representative shadowed-helper workflows inside the ordinary headless suite. Kept coverage out of CI because it has no failure threshold and repeats the non-GUI tests; maintainers can still request the local report explicitly. Replaced raw Git-ref creation with a validated annotated tag, tag-blob launcher staging, remote asset verification, and a draft GitHub Release. User and data impact No App runtime, calculation, project, or saved-data behavior changes. CI may now expose operating-system, path, case-sensitivity, layout, or missing- dependency failures that a configured development machine did not reveal. Compatibility and migration Maintainers should use `Continuous Integration` for required checks, `buildtool coverage` for an on-demand local report, and the manual `Release` workflow only after interactive App validation. The retired `buildtool baseMatlab` task has no compatibility alias because clean Base MATLAB execution is now the environment of every required build task rather than a separate partial check. Validation The CI policy contract checks trigger separation, read-only ordinary permissions, three-platform matrices, absence of `products:` installation, public build-task use, timeouts, diagnostics, exact-sha CI evidence, manual release confirmation, tag-blob asset generation, and draft-only publication. Build-task and Toolbox guardrails verify the simplified catalog and retained static/fallback checks. Evidence `.github/workflows/ci.yml` and `release.yml` define the executable pipeline. `tests/cases/contract/project/ci/CiValidationPolicyGuardrailTest.m` protects the new boundaries. Known limitations and follow-up Hidden GUI tests cannot drive native dialogs or judge visual and pointer quality. Developer-led manual App testing therefore remains mandatory before starting a release. CI also detects only Toolbox calls reached by tests or covered by the maintained static-call set; temporary product use still needs its declared fallback, idempotency, and parity evidence."},{"title":"Close guards and caught-exception diagnostics","url":"history/records/2026/06/LK-20260630-close-guards-and-caught-exception-diagnostics.html","kind":"history","text":"LK-20260630-close-guards-and-caught-exception-diagnostics 2026-06-30 21 feat compatible labkit.ui labkit_DICPostprocess_app labkit_BatchImageCrop_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FocusStack_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app Close guards and caught-exception diagnostics schema: 2 id: LK-20260630-close-guards-and-caught-exception-diagnostics date: 2026-06-30 sequence: 21 type: feat compatibility: compatible component: `labkit.ui` | `3.2.6 -> 3.2.7` component: `labkit_DICPostprocess_app` | `1.2.1 -> 1.2.2` component: `labkit_BatchImageCrop_app` | `1.3.4 -> 1.3.5` component: `labkit_BatchImageCrop_app` | `1.3.5 -> 1.3.6` component: `labkit_CurvatureMeasurement_app` | `1.2.1 -> 1.2.2` component: `labkit_FocusStack_app` | `1.2.2 -> 1.2.3` component: `labkit_FocusStack_app` | `1.2.3 -> 1.2.4` component: `labkit_ImageEnhance_app` | `1.3.2 -> 1.3.3` component: `labkit_ImageMatch_app` | `1.3.2 -> 1.3.3` component: `labkit_ImageMatch_app` | `1.3.3 -> 1.3.4` component: `labkit_NerveResponseAnalysis_app` | `1.2.3 -> 1.2.4` component: `labkit_ResponseReviewStats_app` | `1.2.2 -> 1.2.3` component: `labkit_RHSPreview_app` | `1.2.1 -> 1.2.2` component: `labkit_ECGPrint_app` | `1.2.0 -> 1.2.1` Context Many app callbacks caught an exception, showed its message, and returned. That protected the UI but discarded the stack and callback context needed to diagnose the failure. Image apps also tracked unsaved or incomplete work in slightly different ways when deciding whether a window could close. Decision and rationale Report caught exceptions to the existing debug trace before presenting the user-facing error, and promote the file-index and close-guard mechanics that were shared by several image apps. Preserve app ownership of what counts as dirty or incomplete work. Changes `labkit.ui` `3.2.6 -> 3.2.7` DIC, Batch Crop, Curvature, Focus Stack, Image Match, neurophysiology apps, and ECG Print patch bumped for diagnostics or close-guard work. Reported caught app-runner exceptions through framework debug diagnostics. Promoted file-entry index helpers. Connected dirty/incomplete workflow state to close guards. User and data impact An app could still recover from a failed load, calculation, or export, while its debug report retained the exception evidence. Closing an image workflow with unfinished state produced a consistent warning instead of silently discarding work. Scientific results and saved schemas were unchanged. Compatibility and migration Existing app state and result files remained valid. Closing an incomplete workflow gained an additional confirmation, and debug reports gained exception details. Validation The exception-reporting commit expanded app/library compatibility checks across the affected runners. The close-guard commit added public-surface and compatibility coverage for promoted file indices and app close behavior. Exact historical commands were not recorded. Evidence Main commits `c0028a81` and `a81853ef`. Known limitations and follow-up Runtime V2 later replaced direct debug-log and close-guard calls with lifecycle and diagnostic services, but retained the requirement that caught exceptions remain observable. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Consistent electrochemistry batch analysis","url":"history/records/2026/07/LK-20260713-electrochem-batch-consistency.html","kind":"history","text":"LK-20260713-electrochem-batch-consistency 2026-07-13 46 fix additive labkit_CIC_app labkit_VTResistance_app Consistent electrochemistry batch analysis schema: 2 id: LK-20260713-electrochem-batch-consistency date: 2026-07-13 sequence: 46 type: fix compatibility: additive component: `labkit_CIC_app` | `1.3.7 -> 1.3.8` component: `labkit_VTResistance_app` | `1.3.7 -> 1.3.8` Context CIC and VT Resistance could retain per-file derived values from different control settings, and CIC could sample outside the recorded time range while displaying a delay without units. Decision and rationale Treat analysis controls as one batch contract and recompute every file before display/export so rows cannot silently mix stale and current parameters. Changes Recompute all loaded files when shared controls change and once more before CSV export. Label CIC delay in microseconds, reject out-of-range sampling, and export the area and delay used for each result. Added family regression coverage and audited sibling electrochem apps. User and data impact Batch rows now represent one consistent area, delay, pulse-detection, resistance-window, and voltage-mode configuration. Invalid delay choices fail instead of extrapolating a misleading value. Compatibility and migration CIC CSV adds trailing `Area_cm2` and `Delay_us` columns. Existing columns keep their names and order; VT Resistance exports remain schema-compatible. Validation Electrochem unit and GUI tests cover batch recomputation, display units, out-of-range handling, and export-time refresh. Evidence The guarded calculations and export builders are app-owned. Commit `67ea2286` introduced the consistent batch-analysis and export behavior. Known limitations and follow-up The fix enforces internal batch consistency but does not choose scientifically appropriate area, delay, or resistance windows for the user."},{"title":"Core, neurophysiology, and ECG project validation ownership","url":"history/records/2026/07/LK-20260716-core-neuro-ecg-project-validation.html","kind":"history","text":"LK-20260716-core-neuro-ecg-project-validation 2026-07-16 121 refactor compatible labkit_FigureStudio_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app Core, neurophysiology, and ECG project validation ownership schema: 2 id: LK-20260716-core-neuro-ecg-project-validation date: 2026-07-16 sequence: 121 type: refactor compatibility: compatible component: `labkit_FigureStudio_app` | `0.2.6 -> 0.2.7` component: `labkit_NerveResponseAnalysis_app` | `1.4.4 -> 1.4.5` component: `labkit_ResponseReviewStats_app` | `1.4.4 -> 1.4.5` component: `labkit_RHSPreview_app` | `1.4.4 -> 1.4.5` component: `labkit_ECGPrint_app` | `1.4.4 -> 1.4.5` scope: remaining public App project schemas scope: App maintenance cost Context The remaining public project validators still surrounded their own style, recording-role, metric-window, protocol, ECG, and result rules with repeated canonical bucket and source-record format checks. Decision and rationale Complete the public-App validation ownership split. Runtime owns canonical bucket structs and source-record internals. Each App continues to require its source collection and to enforce its own fields, role/cardinality rules, numeric limits, tables, annotations, and results. Changes Removed 66 net lines of repeated framework structure checks from Figure Studio, the three neurophysiology Apps, and ECG Print. Preserved Nerve Response Analysis's fixed source ID-role pairs and RHS Preview's recording/protocol/filter roles and cardinalities. Added focused project-spec contracts for default acceptance, missing-source rejection, and the retained neurophysiology role rules. User and data impact Valid projects, migrations, signal calculations, previews, and exports are unchanged. Framework-shape failures now have one Runtime owner; domain-schema failures remain attributable to the App that defines them. Compatibility and migration No payload format changed and no migration is required. All supported saved projects continue through their existing centralized `projectSpec.m` migration entry. Validation Focused project-spec tests cover all five defaults and missing-source cases, plus invalid Nerve and RHS source roles. Representative unit and hidden-GUI workflows cover project reconstruction, preview, analysis, and export behavior. Evidence [Figure Studio](../../../../apps/labkit-core/figure-studio/README.md) [Neurophysiology Apps](../../../../apps/neurophysiology/README.md) [ECG Print](../../../../apps/wearable/ecg-print/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Project validator boilerplate is now removed across the public App fleet. Further App simplification must target independently evidenced action, presentation, or cache patterns rather than weakening domain validation."},{"title":"Curvature consolidates migration and analysis state","url":"history/records/2026/07/LK-20260716-curvature-project-structure.html","kind":"history","text":"LK-20260716-curvature-project-structure 2026-07-16 97 refactor compatible labkit_CurvatureMeasurement_app Curvature consolidates migration and analysis state schema: 2 id: LK-20260716-curvature-project-structure date: 2026-07-16 sequence: 97 type: refactor compatibility: compatible component: `labkit_CurvatureMeasurement_app` | `1.4.2 -> 1.4.3` scope: Image Measurement scope: App structure Context Curvature split static metadata across files, exposed four generic lifecycle functions including a filename-encoded migration, and stored five computation tasks/results under `+appState`. Session restoration also read the nested portable source path directly. Decision and rationale Use one project specification with a version-aware migration entry. Runtime V2 calls `Migrate(project,fromVersion)` for each missing version; Curvature keeps only the actual version-1 transformation. Put fit and length task/result shapes beside the analysis functions that consume them. Changes Consolidated product metadata and requirements in `definition.m`. Consolidated project creation, validation, and the v1 source migration in `projectSpec.m`. Moved decoded-image reconstruction to root `createSession.m`. Moved all five generic state helpers to `+analysisRun`. Removed separate metadata and lifecycle files. Replaced direct portable-reference access with the Runtime path accessor. Kept managed anchor editing, scale calibration, curve fitting, length measurement, overlay rendering, and exports unchanged. User and developer impact Opening current or version-1 projects, tracing/removing anchors, calibrating scale, fitting curvature, measuring length, and exporting results behave unchanged. Developers now find every durable-version rule in one file and every numerical result/task next to the numerical implementation. Compatibility and migration The current payload stays at version 2. Version-1 `inputs.source` is still moved to `inputs.sources` exactly once. No new payload migration is introduced. Validation Unit tests cover current project/session construction, v1 migration, fit and length numerics, physical/pixel scales, densification, invalid points, result tables, and deterministic task fingerprints. The hidden GUI suite covers image loading, anchor editing guidance, managed interactions, viewport behavior, scale calibration, results, and save/load. Evidence [Curvature Measurement](../../../../apps/image-measurement/curvature/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Other versioned Apps still use filename-per-step migrations and will move to one project entry during their own behavior audits."},{"title":"DIC Postprocess adopts one product and project declaration","url":"history/records/2026/07/LK-20260716-dic-postprocess-project-spec.html","kind":"history","text":"LK-20260716-dic-postprocess-project-spec 2026-07-16 83 refactor compatible labkit_DICPostprocess_app DIC Postprocess adopts one product and project declaration schema: 2 id: LK-20260716-dic-postprocess-project-spec date: 2026-07-16 sequence: 83 type: refactor compatibility: compatible component: `labkit_DICPostprocess_app` | `1.4.1 -> 1.4.2` scope: DIC scope: Project lifecycle Context DIC Postprocess split static product metadata across `definition.m`, `requirements.m`, and `version.m`, while its version-1 durable schema occupied three generic `+appLifecycle` files. None of those separations represented a separate scientific capability or independently evolving contract. Decision and rationale Make `definition.m` the product declaration and `projectSpec.m` the only durable-schema entry. Keep `createSession.m` separate because it restores file-backed strain, image, mask, and prepared overlay caches that must not be serialized into the project. Changes Moved command metadata, version, update date, and facade requirements into the definition. Consolidated project defaults and validation behind one project spec. Moved transient reconstruction to one explicitly named package-root entry. Removed the two metadata files and generic lifecycle package. Replaced the stale GUI-free manual example with an executable call matching the real input structure, complete parameter contract, and three outputs. Kept all calculation, parameter, action, presentation, and export behavior unchanged. User and developer impact Ncorr loading, overlay preparation, statistics, save/load, and exports behave unchanged. Maintainers can now find the complete product contract in one file and the complete durable schema in one adjacent file. Compatibility and migration The command, project ID, payload version, fields, defaults, validation rules, and source records are unchanged. Existing DIC Postprocess projects require no migration. Validation Unit tests exercise Ncorr loading, cached session reconstruction, numerical preparation, summary/export behavior, and presentation. The hidden GUI flow launches the real App and generates overlays and a summary through Runtime V2. Evidence [DIC Postprocess](../../../../apps/dic/dic-postprocess/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up The action file remains a meaningful workflow boundary because it coordinates input selection, calculation, export, diagnostics, and dialogs. Its naming and possible smaller capability boundaries will be compared across the DIC family instead of changed in isolation."},{"title":"DIC Preprocess consolidates product and project declarations","url":"history/records/2026/07/LK-20260716-dic-preprocess-project-spec.html","kind":"history","text":"LK-20260716-dic-preprocess-project-spec 2026-07-16 84 refactor compatible labkit_DICPreprocess_app DIC Preprocess consolidates product and project declarations schema: 2 id: LK-20260716-dic-preprocess-project-spec date: 2026-07-16 sequence: 84 type: refactor compatibility: compatible component: `labkit_DICPreprocess_app` | `1.5.1 -> 1.5.2` scope: DIC scope: Project lifecycle Context DIC Preprocess split static product metadata across three files and split one version-1 durable schema across a generic lifecycle package. That structure made a maintainer traverse six declarations before reaching any registration, crop, or mask behavior. Decision and rationale Make `definition.m` the complete product declaration and `projectSpec.m` the only durable-schema entry. Keep `createSession.m` explicit because decoding source images and replaying alignment/crop steps reconstructs transient cache state rather than defining persistence. Changes Moved command metadata, version, update date, and facade requirements into the definition. Consolidated project defaults and validation behind one project spec. Moved transient image and edit replay to one package-root session factory. Removed the metadata files and generic lifecycle package. Kept registration, crop, mask, history, managed interactions, export, and scientific parameter behavior unchanged. User and developer impact Manual and automatic alignment, zoom-preserving point/ROI editing, crop and mask history, project save/load, and exports behave unchanged. The durable and transient state boundaries are now visible from two adjacent files. Compatibility and migration The command, project ID, payload version, fields, defaults, validation, source records, edit steps, and mask history are unchanged. Existing projects require no migration. Validation Unit tests cover project/session reconstruction, state history, registration, crop/mask calculations, source IO, exports, and presenter models. The hidden GUI workflow covers real launch, manual point matching, ROI crop, mask editing, viewport preservation, and generated outputs. Evidence [DIC Preprocess](../../../../apps/dic/dic-preprocess/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up The `+appState` package mixes edit-history and mask-geometry capabilities. It is retained for this compatibility-preserving checkpoint and will be split or renamed by responsibility after its callers and tests are audited."},{"title":"DIC Preprocess replaces its generic state bucket","url":"history/records/2026/07/LK-20260716-dic-preprocess-state-capabilities.html","kind":"history","text":"LK-20260716-dic-preprocess-state-capabilities 2026-07-16 85 refactor compatible labkit_DICPreprocess_app DIC Preprocess replaces its generic state bucket schema: 2 id: LK-20260716-dic-preprocess-state-capabilities date: 2026-07-16 sequence: 85 type: refactor compatibility: compatible component: `labkit_DICPreprocess_app` | `1.5.2 -> 1.5.3` scope: DIC scope: App ownership Context The generic `+appState` package mixed three unrelated concepts: durable align/crop undo history, mask editing and undo, and a predicate over transient source-image cache state. The name exposed storage mechanics without helping a maintainer find the behavior they needed. Decision and rationale Group these operations by the capability that owns their invariants. `editHistory` owns align/crop snapshots and reset behavior, `maskEditing` owns mask canvas composition and undo snapshots, and `sourceFiles` owns the loaded image-pair predicate. None is promoted to LabKit because the project fields, undo limits, image semantics, and operation order are specific to DIC Preprocess. Changes Moved four edit-history operations into `+editHistory`. Moved five mask composition/history operations into `+maskEditing`. Moved the image-pair readiness predicate into `+sourceFiles`. Updated actions, presenters, preview requests, and direct state tests to use the capability paths. Removed the generic `+appState` package without adding an adapter. User and developer impact All controls, calculations, undo limits, project fields, masks, alignment/crop history, and viewport behavior are unchanged. Code navigation now starts from the user capability rather than an undifferentiated state bucket. Compatibility and migration This is an app-internal ownership change. Project payloads and public GUI-free scientific APIs are unchanged, so saved projects require no migration. Validation State tests cover history trimming, reset/restore behavior, mask composition, and image-pair readiness through the new capability paths. The full DIC Preprocess unit and hidden GUI workflows verify the unchanged callers. Evidence [DIC Preprocess](../../../../apps/dic/dic-preprocess/README.md) [Architecture](../../../../development/build-apps/architecture.md) Known limitations and follow-up `definitionActions.m` remains a large but cohesive workflow coordinator. Its callbacks will be compared with other interaction-heavy image Apps before any further extraction; file length alone is not a reason to create another technical package."},{"title":"DIC and Gait project validation ownership","url":"history/records/2026/07/LK-20260716-dic-gait-project-validation-ownership.html","kind":"history","text":"LK-20260716-dic-gait-project-validation-ownership 2026-07-16 120 refactor compatible labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_GaitAnalysis_app DIC and Gait project validation ownership schema: 2 id: LK-20260716-dic-gait-project-validation-ownership date: 2026-07-16 sequence: 120 type: refactor compatibility: compatible component: `labkit_DICPostprocess_app` | `1.4.4 -> 1.4.5` component: `labkit_DICPreprocess_app` | `1.5.5 -> 1.5.6` component: `labkit_GaitAnalysis_app` | `2.0.5 -> 2.0.6` scope: DIC and Gait project schemas scope: App maintenance cost Context DIC Postprocess, DIC Preprocess, and Gait repeated Runtime's canonical project and source-record shape checks around their real domain constraints. The duplication made it harder to see which saved fields these Apps actually own. Decision and rationale Retain each App's required source collection and domain schema while relying on Runtime for canonical bucket structs and source-record internals. DIC continues to own alignment, crop, mask, summary, and parameter fields; Gait continues to own pose-analysis options, numeric limits, and result structure. Changes Removed 26 net lines of repeated framework structure checks from the three project specifications. Preserved every App-required source field and domain-specific validator. Added GUI-free DIC and Gait contracts that accept default projects and reject projects missing their required source collection. User and data impact Valid DIC and Gait projects, migrations, calculations, and exports are unchanged. Malformed framework structures still fail in Runtime and malformed domain fields still fail in the owning App. Compatibility and migration No payload format changed and no migration is required. Supported older projects continue through the same App-owned migration callbacks. Validation Focused project-spec tests cover default acceptance and missing-source rejection. Hidden GUI workflows cover representative DIC load/edit/analysis and Gait project load/step-analysis behavior. Evidence [DIC Apps](../../../../apps/dic/README.md) [Gait Analysis](../../../../apps/gait/gait-analysis/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up This boundary cleanup does not change the scientific gait analysis or image registration algorithms. Their domain complexity remains App-owned."},{"title":"DIC uses framework-owned optional source slots","url":"history/records/2026/07/LK-20260716-dic-source-slots.html","kind":"history","text":"LK-20260716-dic-source-slots 2026-07-16 93 refactor compatible labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app DIC uses framework-owned optional source slots schema: 2 id: LK-20260716-dic-source-slots date: 2026-07-16 sequence: 93 type: refactor compatibility: compatible component: `labkit.ui` | `7.4.0 -> 7.4.1` component: `labkit_DICPostprocess_app` | `1.4.2 -> 1.4.3` component: `labkit_DICPreprocess_app` | `1.5.3 -> 1.5.4` scope: DIC scope: Runtime source boundary Context DIC Preprocess and Postprocess use stable semantic source slots such as `referenceImage`, `movingImage`, `maskImage`, and `dicMat`. Those slots are legitimately absent before the user selects a file. Each App therefore carried an identical `pathForId.m` wrapper that returned empty text for an unset slot and otherwise inspected the nested portable reference. The first source accessor required every requested ID to exist. Applying that rule would have preserved the duplicate wrapper instead of lowering App cost. Decision and rationale Make ID-based source lookup preserve the requested shape and return an empty string for a semantic slot that has not been added. Continue to reject malformed records and references. This matches ordinary presenter and session behavior without hiding corrupt project data. Changes Refined `sourcePaths(sources,ids)` for optional semantic slots. Removed both DIC `pathForId.m` wrappers and their duplicate schema knowledge. Migrated DIC loading, summaries, presenters, save defaults, and export paths to the Runtime accessor. Preserved alignment, crop, mask, anchor, rendering, calculation, and export behavior. User and developer impact An empty DIC project still presents empty file controls and enables them as the workflow requires. Selected and relinked files load through the same paths. Developers no longer implement a source-ID lookup helper for each App. Compatibility and migration The accessor change relaxes one error case and is compatible within UI 7. No saved project shape changed and no DIC payload migration is required. Validation Runtime unit tests cover mixed present and absent IDs. DIC unit and hidden-GUI tests cover empty startup, file selection, point matching, ROI and mask edits, viewport-preserving interaction, project state, and result export. Evidence [DIC Preprocess](../../../../apps/dic/dic-preprocess/README.md) [DIC Postprocess](../../../../apps/dic/dic-postprocess/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Other families still contain direct portable-reference reads. They will move to the same accessor before the repository-wide no-leak contract is enabled. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"DTA facade and app ownership boundaries","url":"history/records/2026/05/LK-20260529-dta-facade-and-app-boundaries.html","kind":"history","text":"LK-20260529-dta-facade-and-app-boundaries 2026-05-29 2 refactor compatible DTA facade and app ownership boundaries schema: 2 id: LK-20260529-dta-facade-and-app-boundaries date: 2026-05-29 sequence: 2 type: refactor compatibility: compatible scope: historical project evolution Context The first extraction pass separated calculations from the imported GUIs, but the electrochem apps still depended on a broad internal package and shared session helpers. A new app could reuse a parser only by learning implementation details that had nothing to do with reading a DTA file. Decision and rationale Make DTA loading the reusable boundary and keep workflow decisions in the app that owns them. The public layer would load one file, discover files, or load a folder and return documented report structures. Plot labels, callback order, session state, result summaries, and export choices would remain app concerns. This distinction became an early form of LabKit's present architecture: a small library presents stable data operations, while an app owns the laboratory workflow assembled around those operations. Changes Added GUI-independent DTA loading and adopted it in the EIS and Chrono Overlay apps. Added discovery and folder-loading operations, including validation for empty selections, missing folders, and expected experiment kinds. Documented and tested the report schemas returned by batch operations. Moved EIS, Chrono Overlay, CSC, VT Resistance, and CIC out of the original `gamrywb` namespace and into app-owned entry points. Returned calculation details, labels, callback flow, and exports to their owning apps when they were not genuinely reusable. Introduced boundary tests to prevent UI, data, IO, analysis, and utility packages from growing through accidental cross-dependencies. User and data impact Electrochem users kept the same principal workflows, but file-loading failures became more explicit and batch loading gained a defined result report. App authors could use the DTA layer without starting a GUI or depending on the layout of an existing app. No laboratory file format or scientific result schema was intentionally changed by the ownership cleanup. Compatibility and migration The supported app commands replaced direct use of the old `gamrywb` package. Code that reached into that package's internal helpers needed to move either to the DTA facade or into the app that owned the behavior. Validation The period added DTA schema tests, empty- and missing-folder tests, architectural boundary checks, and behavior-focused calculation tests. The repository did not yet have the later unified MATLAB test command, so no single historical validation invocation is available. Evidence GUI-free loading facade `88b19851` and initial app adoption `789ef507`, `0ccc3ad6`. App ownership migration `1ee8e82d` through `1b6042bf`. Discovery and folder loading `c06946d9`, `82f4146d`. Report documentation and contract tests `669eea38`, `e04292c0`. Boundary and failure-handling work from `ad2b6c74` through `7506fa32`. Known limitations and follow-up The public surface was still organized around the first electrochem use cases. The next stage renamed the workbench, narrowed the package map further, and tested whether the same app-first approach could support image and biosignal workflows."},{"title":"Debug sample packs","url":"history/records/2026/07/LK-20260701-debug-sample-packs.html","kind":"history","text":"LK-20260701-debug-sample-packs 2026-07-01 28 feat compatible labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app Debug sample packs schema: 2 id: LK-20260701-debug-sample-packs date: 2026-07-01 sequence: 28 type: feat compatibility: compatible component: `labkit.ui` | `3.3.1 -> 3.4.0` component: `labkit_DICPostprocess_app` | `1.2.4 -> 1.3.0` component: `labkit_DICPreprocess_app` | `1.2.2 -> 1.3.0` component: `labkit_ChronoOverlay_app` | `1.2.1 -> 1.3.0` component: `labkit_CIC_app` | `1.2.1 -> 1.3.0` component: `labkit_CSC_app` | `1.2.1 -> 1.3.0` component: `labkit_EIS_app` | `1.2.1 -> 1.3.0` component: `labkit_VTResistance_app` | `1.2.1 -> 1.3.0` component: `labkit_BatchImageCrop_app` | `1.5.1 -> 1.6.0` component: `labkit_CurvatureMeasurement_app` | `1.2.4 -> 1.3.0` component: `labkit_FLIRThermal_app` | `1.1.2 -> 1.2.0` component: `labkit_FocusStack_app` | `1.3.0 -> 1.4.0` component: `labkit_ImageEnhance_app` | `1.4.1 -> 1.5.0` component: `labkit_ImageMatch_app` | `1.4.1 -> 1.5.0` component: `labkit_NerveResponseAnalysis_app` | `1.2.4 -> 1.3.0` component: `labkit_ResponseReviewStats_app` | `1.2.3 -> 1.3.0` component: `labkit_RHSPreview_app` | `1.2.4 -> 1.3.0` component: `labkit_ECGPrint_app` | `1.2.2 -> 1.3.0` Context Debug reports could identify a failed callback, but reproducing the failure still depended on the original laboratory file and the user's exact sequence of actions. Those inputs were often unavailable to the person investigating the report and were unsuitable as permanent test fixtures. Decision and rationale Let every app create a small, synthetic sample pack that exercises its normal loading path without containing laboratory data. Store the generated inputs and expected output location beside the debug artifacts so a report can be replayed from one self-contained folder. Changes `labkit.ui` `3.3.1 -> 3.4.0` All supported apps moved into the `1.3.x`, `1.4.x`, `1.5.x`, or `1.6.x` debug-sample-pack lines. Added app-owned debug sample packs. Added debug artifact sample and output folders. User and data impact Debug mode gained a repeatable demonstration dataset for each supported app. These packs were synthetic and intended for diagnosis, not as substitutes for real experiment files or as reference scientific results. Compatibility and migration The sample packs were additive debug assets. Existing app inputs and outputs were unchanged, and no user file was copied into a pack. Validation Commit `279befbc` added coverage tests for every app family, focused tests for DIC, electrochem, image, RHS, and wearable sample writers, and layout checks that exercised the generated packs. Evidence Main commit `279befbc`. Known limitations and follow-up Sample packs were deliberately app-owned because each workflow knows which inputs make a useful reproduction. They do not contain or archive a user's original files. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Debug workflows, launcher tools, and changelog governance","url":"history/records/2026/07/LK-20260707-debug-workflows-launcher-tools-and-changelog-governance.html","kind":"history","text":"LK-20260707-debug-workflows-launcher-tools-and-changelog-governance 2026-07-07 39 feat compatible labkit_launcher labkit_launcher labkit_launcher labkit.ui labkit.ui labkit_DICPreprocess_app labkit_BatchImageCrop_app labkit_FocusStack_app labkit_FigureStudio_app Debug workflows, launcher tools, and changelog governance schema: 2 id: LK-20260707-debug-workflows-launcher-tools-and-changelog-governance date: 2026-07-07 sequence: 39 type: feat compatibility: compatible component: `labkit_launcher` | `1.2.4 -> 1.2.5` component: `labkit_launcher` | `1.2.5 -> 1.2.6` component: `labkit_launcher` | `1.2.6 -> 1.2.7` component: `labkit.ui` | `5.0.0 -> 5.0.1` component: `labkit.ui` | `5.0.1 -> 5.0.2` component: `labkit_DICPreprocess_app` | `1.3.4 -> 1.3.5` component: `labkit_BatchImageCrop_app` | `1.6.6 -> 1.6.7` component: `labkit_FocusStack_app` | `1.4.5 -> 1.4.6` component: `labkit_FigureStudio_app` | `0.1.4 -> 0.1.5` Context The first complete debug sample packs exposed several false failures. A modal file chooser could be mistaken for a stalled callback, DIC could read an older ROI snapshot than the editor displayed, duplicate crops needed confirmation before export, and Focus Stack's folder workflow was hard to reach from its sample instructions. The launcher was also becoming a practical maintenance and distribution tool. Code Analyzer reports needed a durable home outside launcher source, individual apps needed an offline package format, and private app workspaces needed to remain discoverable without entering the public repository. Finally, the growing changelog needed to explain the direction of the project, not merely repeat tags and commit subjects. Decision and rationale Use v3.1.0 as a stabilization release for reproducible debug workflows. Treat native modal dialogs as active UI, read live editor state when it is newer than the stored snapshot, and make required gestures visible in the app. Move code analysis and packaging into dedicated tools, let the launcher invoke them, and keep private app discovery separate from public release contents. Changes Release tag `v3.1.0` `labkit_launcher` `1.2.4 -> 1.2.7` `labkit.ui` `5.0.1 -> 5.0.2` `labkit_FigureStudio_app` `0.1.4 -> 0.1.5` `labkit_DICPreprocess_app` `1.3.4 -> 1.3.5` `labkit_BatchImageCrop_app` `1.6.6 -> 1.6.7` `labkit_FocusStack_app` `1.4.5 -> 1.4.6` DIC Preprocess ROI mask export now reads the live ROI editor anchors when building a mask, so preview/save do not misreport a drawn ROI as empty when editor state is newer than the app state snapshot. DIC Preprocess keeps the double-click ROI anchor workflow and makes the double-click requirement explicit in the visible details text. Batch Image Crop duplicate tasks now redraw with finite preview overlay coordinates while still requiring users to confirm the duplicated crop center before export. Figure Studio quick PNG/JPG/SVG export actions use runtime-compatible handler signatures. Focus Stack exposes a direct `Choose folder` action for loading all supported images from a focus-stack folder. Debug trace diagnostics no longer write stalled-callback crash reports while a file chooser modal is active. Moved the launcher Code Analyzer scan into `tools/codecheck`, which writes timestamped JSON/HTML report pairs under `artifacts/code-check/` without overwriting earlier runs. Added launcher actions and a deployment tool that package one selected LabKit app into a standalone zip, either as source `.m` files or encoded `.p` files. Added launcher discovery for local private app workspaces under `private_apps/apps/` and roots named by `LABKIT_PRIVATE_APP_ROOTS`. Clarified the public changelog model as a project evolution map organized by reader-facing evolution entries, with release tags and commits kept as anchors and evidence rather than the primary structure. User and data impact The supplied debug packs could be followed without producing a false crash report or an apparently disabled workflow. DIC mask export used the anchors currently visible in the editor, duplicate crops remained blocked until their centers were confirmed, and Focus Stack offered a direct folder action. Maintainers gained timestamped HTML and JSON Code Analyzer reports. A selected app could be packaged for production or offline use without tests, unrelated apps, or repository metadata. Private apps could live beside the checkout, appear in the ordinary launcher, and retain their own repository and history. Compatibility and migration DIC ROI editing still uses double-click to add anchors; no interaction-mode migration is required. Existing file-panel image selection remains available in Focus Stack. Code Analyzer report consumers should read the timestamped `artifacts/code-check/matlab_code_issues_*.json` files. Full LabKit checkout installs are unchanged. Single-app packages can start through either the packaged launcher or the direct run file; P-code packages require MATLAB to run the generated `.p` files. Public apps, public releases, and public CI remain scoped to `apps/`. Validation PR #34 updated debug sample, ROI, crop, folder-loading, launcher-tool, deployment, and private-workspace tests before its squash merge. Tag `v3.1.0` points to release commit `9db01952`; the exact local command sequence was not recorded in this page. Evidence PR #34 squash merge `9db01952` and release tag `v3.1.0`. Known limitations and follow-up The first deployment tool packaged one app at a time. Multi-app bundles were added later. The changelog model introduced here was subsequently replaced by component-linked history pages generated with the documentation site. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Declarative app runtime","url":"history/records/2026/07/LK-20260703-declarative-app-runtime.html","kind":"history","text":"LK-20260703-declarative-app-runtime 2026-07-03 32 refactor compatible labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app Declarative app runtime schema: 2 id: LK-20260703-declarative-app-runtime date: 2026-07-03 sequence: 32 type: refactor compatibility: compatible component: `labkit.ui` | `3.4.4 -> 3.4.5` component: `labkit_DICPostprocess_app` | `1.3.0 -> 1.3.1` component: `labkit_DICPreprocess_app` | `1.3.0 -> 1.3.1` component: `labkit_ChronoOverlay_app` | `1.3.0 -> 1.3.1` component: `labkit_CIC_app` | `1.3.0 -> 1.3.1` component: `labkit_CSC_app` | `1.3.0 -> 1.3.1` component: `labkit_EIS_app` | `1.3.0 -> 1.3.1` component: `labkit_VTResistance_app` | `1.3.0 -> 1.3.1` component: `labkit_BatchImageCrop_app` | `1.6.1 -> 1.6.2` component: `labkit_CurvatureMeasurement_app` | `1.3.0 -> 1.3.1` component: `labkit_FLIRThermal_app` | `1.2.0 -> 1.2.1` component: `labkit_FocusStack_app` | `1.4.0 -> 1.4.1` component: `labkit_ImageEnhance_app` | `1.5.0 -> 1.5.1` component: `labkit_ImageMatch_app` | `1.5.0 -> 1.5.1` component: `labkit_NerveResponseAnalysis_app` | `1.3.0 -> 1.3.1` component: `labkit_ResponseReviewStats_app` | `1.3.0 -> 1.3.1` component: `labkit_RHSPreview_app` | `1.3.0 -> 1.3.1` component: `labkit_ECGPrint_app` | `1.3.1 -> 1.3.2` Context Apps had adopted common shells and controls, but each runner still assembled callbacks, startup steps, refresh behavior, and debug hooks in its own order. Understanding an app meant reading a long construction procedure before reaching the scientific workflow. Decision and rationale Describe an app through a definition containing its layout, actions, state, startup, and rendering contracts, then let the runtime perform the common wiring. Keep action bodies, calculations, and result structures in the app so the definition exposes workflow structure without becoming a generic analysis language. Changes `labkit.ui` `3.4.4 -> 3.4.5` All supported apps received patch bumps. Migrated apps to declarative workflow runtime. User and data impact The visible workflows were intended to remain the same. For maintainers, each app gained a recognizable definition and lifecycle, while repeated setup moved out of its runner. Debug sample packs and focused app tests continued to call the same app-owned operations. Compatibility and migration Users kept the same app entry points and data files. App implementations moved to definitions and runtime actions; unsupported code that reached into old runner construction details needed to adopt the declarative APIs. Validation The migration changed 545 files and updated the affected app, layout, export, calculation, sample-pack, and package-boundary suites in the same commit. The commit message records final profiler and debug evidence; the exact local command was not preserved in this history. Evidence Main commit `568b3e9b`. Known limitations and follow-up The first declarative vocabulary still used the mixed `app`, `spec`, `view`, `tool`, and `diag` package names. The UI 4 and UI 5 passes refined how groups, plots, interactions, and lifecycle responsibilities were named. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Default LabKit close protection","url":"history/records/2026/07/LK-20260709-default-labkit-close-protection.html","kind":"history","text":"LK-20260709-default-labkit-close-protection 2026-07-09 43 fix compatible labkit.ui labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app Default LabKit close protection schema: 2 id: LK-20260709-default-labkit-close-protection date: 2026-07-09 sequence: 43 type: fix compatibility: compatible component: `labkit.ui` | `5.0.2 -> 5.0.3` component: `labkit_FocusStack_app` | `1.4.6 -> 1.4.7` component: `labkit_ImageEnhance_app` | `1.5.5 -> 1.5.6` component: `labkit_ImageMatch_app` | `1.5.5 -> 1.5.6` Context Only apps that installed a dirty-state guard warned before closing. A newly created public or private app could therefore discard work silently until its author implemented and tested a separate guard. Repeated close shortcuts could also race with a modal confirmation. Decision and rationale Make close confirmation a default property of every framework-owned app window and remove the optional app-facing guard API. Keep one in-window prompt active at a time; a repeated close shortcut confirms that prompt instead of opening another one. Changes `labkit.ui` `5.0.2 -> 5.0.3` `labkit_FocusStack_app` `1.4.6 -> 1.4.7` `labkit_ImageEnhance_app` `1.5.5 -> 1.5.6` `labkit_ImageMatch_app` `1.5.5 -> 1.5.6` LabKit runtime figures now show an in-window confirmation prompt before any framework-owned app window closes, even when the app has not marked itself dirty. Removed the app-facing `labkit.ui.runtime.setCloseGuard` API and migrated existing app close-guard dirty checks to the framework default behavior. Repeating or holding the app close shortcut while the in-window prompt is active confirms the close. User and data impact Every public or private LabKit app asked before its window closed, even if the app had no custom dirty-state logic. Users gained a consistent protection against accidental closure at the cost of one confirmation step. Compatibility and migration Closing LabKit apps now requires one confirmation step by default. App code that calls `labkit.ui.runtime.setCloseGuard` must remove that call; close confirmation is framework-owned. Validation Commit `0c9f472b` expanded the UI busy-state GUI suite for initial close, cancel, confirmation, and repeated-shortcut behavior, then updated public- surface and app-compatibility checks. Evidence Mainline commit `0c9f472b`. Known limitations and follow-up This policy protected the window, not the semantic completeness of each saved project. Runtime V2 later combined default close handling with app-owned dirty state and lifecycle services. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Destination-rebased source references","url":"history/records/2026/07/LK-20260716-destination-rebased-source-references.html","kind":"history","text":"LK-20260716-destination-rebased-source-references 2026-07-16 74 fix compatible labkit.ui Destination-rebased source references schema: 2 id: LK-20260716-destination-rebased-source-references date: 2026-07-16 sequence: 74 type: fix compatibility: compatible component: `labkit.ui` | `6.0.4 -> 6.0.5` scope: project serialization scope: named save, explicit autosave, and recovery Context Runtime source records were created before an App knew its future project-file location, so they began with an empty `relativePath`. Project-envelope creation copied those records unchanged. As a result, the resolver supported relative references, but ordinary Runtime V2 saves did not actually generate them. Decision and rationale Rebase source references at the serialization boundary using the actual MAT destination. A named project, app-owned autosave, and framework recovery file can live in different folders, so each write must calculate its own relative path rather than reusing a path cached in live state. Changes Passed the real destination path into every Runtime V2 envelope writer. Copied the durable project and refreshed reference schema, relative path, original path, and filename immediately before serialization. Preserved additive app/reference fields while replacing standard path fields. Kept resolved absolute paths in live state so current-session readers do not need to interpret portable references. User and data impact A project and its source directory can now move together to another root or machine and reopen through the saved relative relationship even when the old absolute path no longer exists. Existing projects without a relative path remain readable through their original-path and relink fallbacks. Compatibility and migration The envelope and source-reference schema versions are unchanged. No payload migration is needed: the next named save, explicit autosave, or recovery write adds the destination-correct relative path. Validation The Runtime V2 project GUI test saves a source/project directory tree, verifies the generated relative path, moves the tree, and reloads from the new root. Video Marker verifies that its source-adjacent explicit autosave is rebased from the autosave destination. Existing atomic-save, relink, recovery, and additive field tests remain in the same focused suites. Evidence [Runtime and Data Model](../../../../framework/guides/runtime.md) explains when relative paths are created and how they are resolved. [UI Framework](../../../../framework/README.md) describes Runtime V2 project ownership and persistence. Known limitations and follow-up The portable-reference creation and resolution algorithms remain public in UI 6 for compatibility. They are implementation mechanics rather than App-facing workflow APIs and are reviewed with the remaining Runtime public surface for a single future major-boundary cleanup. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Documentation navigation follows topic hierarchy","url":"history/records/2026/07/LK-20260717-hierarchical-documentation-navigation.html","kind":"history","text":"LK-20260717-hierarchical-documentation-navigation 2026-07-17 132 docs compatible Documentation navigation follows topic hierarchy schema: 2 id: LK-20260717-hierarchical-documentation-navigation date: 2026-07-17 sequence: 132 type: docs compatibility: compatible scope: MATLAB documentation site navigation Context The documentation site exposed its major areas in the top bar, but each area's left sidebar was a flat list. Related app families, development tasks, and API packages were not visually distinguished, so readers could not readily see the path from an area to a topic and then to a specific page. Decision and rationale Render contextual hierarchy from the existing page and catalog metadata. App families, libraries, development tasks, and MATLAB packages become labeled sidebar branches. This preserves one structured source of navigation truth while making downstream topics visible from their area landing pages. Changes Grouped app navigation by family and indented app manuals below family pages. Grouped function, framework, and development navigation by topic. Grouped API siblings under their owning MATLAB package. Defined the second `nav` entry in `docs/site.json` as the branch label and ordered branches by their earliest page. Added responsive sidebar styling and renderer regression coverage for the hierarchy. User and data impact Readers can move from a top-level area to a topic branch and then to a specific manual without searching a flat list. Documentation content, URLs, MATLAB behavior, and scientific data are unchanged. Compatibility and migration Existing page URLs and top-level navigation remain compatible. Documentation authors should assign new multi-page topics a stable second-level `nav` label in `docs/site.json`. Validation The documentation renderer regression verifies Development topic groups, App family branches, and API package labels. `docsCheck` rebuilds and compares the complete generated site. Evidence The generated area landing pages contain labeled branch headings and parent/child link classes derived from tracked page and app catalog metadata. Known limitations and follow-up Single-page areas do not display an artificial empty branch. History records remain in the generated timeline because reproducing the full record set in the sidebar would make navigation less usable."},{"title":"Documentation rendering avoids repeated model capture","url":"history/records/2026/07/LK-20260717-documentation-render-performance.html","kind":"history","text":"LK-20260717-documentation-render-performance 2026-07-17 130 perf compatible Documentation rendering avoids repeated model capture schema: 2 id: LK-20260717-documentation-render-performance date: 2026-07-17 sequence: 130 type: perf compatibility: compatible scope: MATLAB documentation renderer Context Two project documentation tests each rebuild the complete tracked site. Profiling showed that most render time was not file generation or tree comparison: every inline Markdown fragment created an anonymous function that captured the complete documentation model before scanning links. Decision and rationale Pass the shared model and current page directly into token protection and dispatch code, image, and link rendering by token type. This preserves the renderer contract and generated bytes while avoiding thousands of large closure allocations. Changes Replaced per-fragment renderer closures with direct token dispatch. Retained the same code, image, link, emphasis, and nested inline-code behavior. Added a focused regression guard against restoring the measured closure pattern. User and data impact Generated HTML is byte-for-byte unchanged. Documentation rebuilds, consistency checks, and the launcher documentation action complete substantially sooner. Compatibility and migration `renderLabKitDocs`, `checkLabKitDocs`, `buildtool docs`, and `buildtool docsCheck` retain their existing syntax, outputs, and failure behavior. No migration is required. Validation On the same MATLAB R2025a host and complete `checkLabKitDocs` scenario, profiler total time decreased from 46.52 seconds to 9.66 seconds, approximately 79 percent. The after-profile run also completed the byte comparison against the tracked site. Evidence The focused renderer regression protects inline link behavior and the allocation fix. `docsCheck` independently rebuilds all narrative and API pages and compares every generated file. Known limitations and follow-up Absolute times vary by machine and filesystem. API help rendering is now the largest measured renderer-owned cost and should be changed only after a separate profile identifies a safe optimization."},{"title":"Documentation structure becomes the navigation contract","url":"history/records/2026/07/LK-20260719-path-derived-documentation.html","kind":"history","text":"LK-20260719-path-derived-documentation 2026-07-19 136 refactor behavior-preserving labkit_launcher Documentation structure becomes the navigation contract schema: 2 id: LK-20260719-path-derived-documentation date: 2026-07-19 sequence: 136 type: refactor compatibility: behavior-preserving component: `labkit_launcher` | `1.5.1 -> 1.5.2` scope: Documentation renderer scope: Documentation maintenance Context The local HTML site duplicated documentation ownership across Markdown files, `site.json`, two catalog JSON files, and generated output. Moving or adding a page therefore required several coordinated edits. Markdown links also embedded many relative paths without a repository-owned way to repair links after a move. Decision and rationale Treat the `docs/` directory structure as the narrative navigation contract. Discover public Apps from `labkit_launcher(\"list\")`, associate each App with its unique path-conventional manual, and discover public API pages from complete MATLAB help contracts. Keep ordinary relative Markdown links so source pages remain useful in GitHub and add a checker that can repair a broken link only when its destination is unambiguous. The generated `site/` remains the offline and local HTML product. It is still produced exclusively by the repository renderer. Changes Removed `docs/site.json` and the App and API catalog JSON files. Reorganized development and framework guides into path-owned navigation groups and moved the API landing page to `docs/reference/README.md`. Made the renderer discover Markdown pages, public Apps, and complete public MATLAB help without duplicated registries. Added `maintainLabKitDocLinks` for checking and uniquely repairing relative Markdown links after file moves. Changed launcher documentation lookup to derive the selected App manual and generated HTML path from App discovery and documentation structure. Removed obsolete compatibility redirect pages instead of preserving legacy documentation routes. User and data impact Local HTML documentation and launcher documentation actions remain available. Contributors add or move a page by placing Markdown in the intended directory and running the link maintainer and renderer; no navigation catalog needs a matching edit. Scientific behavior and project data are unchanged. Compatibility and migration Existing current documentation links are rewritten to their new relative paths. Old generated documentation routes are intentionally retired and are not maintained as redirect pages. Rebuild `site/` after updating a checkout. Validation Focused documentation contract and renderer regression tests cover discovery, navigation, link repair, public API help, and launcher documentation lookup. The documentation checker validates the generated site after a clean local render. Evidence [Documentation Build Tools](../../../../development/tools/documentation.md) [Maintaining LabKit Documentation](../../../../development/maintain-and-release/documentation.md) [LabKit Launcher](../../../../apps/labkit-core/launcher/README.md) [API Reference](../../../../reference/README.md) Known limitations and follow-up Automatic link repair deliberately refuses ambiguous destinations. Authors must resolve those cases explicitly so the checker never guesses between pages with the same filename or title."},{"title":"Durable Video Marker source metadata","url":"history/records/2026/07/LK-20260716-video-marker-durable-metadata.html","kind":"history","text":"LK-20260716-video-marker-durable-metadata 2026-07-16 70 feat compatible labkit_VideoMarker_app Durable Video Marker source metadata schema: 2 id: LK-20260716-video-marker-durable-metadata date: 2026-07-16 sequence: 70 type: feat compatibility: compatible component: `labkit_VideoMarker_app` | `1.4.1 -> 1.5.0` scope: project payload schema scope: downstream gait inputs Context Video Marker previously kept frame rate, frame count, duration, and image size only in transient decoded-video state. Its MAT project preserved coordinates but a GUI-independent downstream analysis could not recover time semantics without reopening the original video or inventing a frame rate. Decision and rationale Make immutable decoded-video facts part of the durable Video Marker project. The portable source record continues to own the file path; the metadata record contains only finite numeric facts required to interpret saved annotations. Changes Advanced the Video Marker project payload from version 1 to version 2. Added durable frame count, frame rate, duration, height, and width. Populate metadata when a video or marker CSV is opened and seed rebuilt sessions from saved metadata before optional source decoding. Refresh durable metadata from the reopened source before an explicit **Save autosave**, so a loaded version 1 autosave upgrades in place. Validate metadata values and annotation/frame-count consistency. Added an ordered v1-to-v2 migration that recovers frame count from saved annotation coordinates and leaves unknowable source facts at zero. User and data impact New named projects and source-adjacent autosaves contain enough timing and geometry information for Gait Analysis to treat the MAT document as its authoritative input. Paths are not duplicated into the metadata record. Compatibility and migration Version 1 LabKit project envelopes remain readable. Opening one marks it for an upgrade; saving writes payload version 2. A frame rate absent from version 1 cannot be inferred from coordinates alone and remains zero until the source is opened by the current Video Marker app. Validation Video Marker unit tests verify metadata selection, absence of transient paths, frame-count recovery, payload validation, and mismatched annotation rejection. The existing GUI suite covers video opening, marker CSV import, project saves, and recovery loading. Evidence [Video Marker](../../../../apps/image-measurement/video-marker/README.md) documents the persisted fields and downstream use. Gait Analysis input tests exercise the current payload contract separately. Known limitations and follow-up Projects whose old payload never recorded a frame rate must reopen their source video before producing a fully self-describing current project. Opening that source through the saved reference and pressing **Save autosave** performs the upgrade without another location prompt."},{"title":"ECG Print uses one project contract","url":"history/records/2026/07/LK-20260716-ecg-print-structure.html","kind":"history","text":"LK-20260716-ecg-print-structure 2026-07-16 106 refactor compatible labkit_ECGPrint_app ECG Print uses one project contract schema: 2 id: LK-20260716-ecg-print-structure date: 2026-07-16 sequence: 106 type: refactor compatibility: compatible component: `labkit_ECGPrint_app` | `1.4.2 -> 1.4.3` scope: Wearable scope: App structure Context ECG Print kept product metadata in separate requirement and version functions, and split one durable schema across project creation, validation, migration, and session reconstruction files under a generic lifecycle package. Session and presentation code also read the Runtime-owned portable-reference structure directly. Decision and rationale Use the compact Runtime V2 App structure. The definition owns product metadata and optional capabilities, one project contract owns durable schema evolution, and one root session factory rebuilds decoded signal state. ECG-specific parsing, analysis, presentation, and result packages already express real workflow capabilities and remain App-owned. Changes Consolidated command identity, display metadata, version, requirements, layout, actions, presenter, renderer, and debug capability in `definition`. Concentrated project creation, validation, and the version-1 source upgrade in `projectSpec` behind one `Migrate` callback. Moved transient recording and analysis reconstruction to root `createSession` and reused that factory when clearing a failed decode. Replaced nested portable-reference reads with semantic `sourcePaths` lookup. Removed the generic lifecycle package and separate requirement/version files. Updated the App package structure guardrail so actions, presentation, project persistence, and transient sessions are optional capabilities; Apps that adopt root `projectSpec` cannot regress to split lifecycle metadata. User and data impact Recording import, parsing controls, channel selection, filtering, peak detection, segmentation, template construction, SNR measurements, plots, exports, manifests, project reopening, and user wording are unchanged. The App version advances to 1.4.3; durable payload version remains 2. Compatibility and migration Version-1 projects still move their singular source record into the canonical source collection before validation. Runtime V2 now invokes the single migration entry and owns iteration to the current payload version. Current version-2 project files require no data transformation. Validation Focused GUI-free tests cover the migration callback, definition contract, import parsing, analysis products, presentation models, and result tables. The hidden GUI workflow covers launch, recording import, analysis, plots, both exports, project save, and session reconstruction after load. Project guardrails cover the compact optional-capability structure, embedded metadata, version/history ownership, and generated documentation consistency. Evidence [ECG Print](../../../../apps/wearable/ecg-print/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) [App Development](../../../../development/build-apps/app-development.md) Known limitations and follow-up Automated tests do not judge ECG morphology, peak quality, or the visual suitability of exported waveforms for a particular experiment. Remaining Runtime V2 App migrations and the stale agent guidance require separate review."},{"title":"EIS consolidates its product and project contracts","url":"history/records/2026/07/LK-20260716-eis-project-spec.html","kind":"history","text":"LK-20260716-eis-project-spec 2026-07-16 89 refactor compatible labkit_EIS_app EIS consolidates its product and project contracts schema: 2 id: LK-20260716-eis-project-spec date: 2026-07-16 sequence: 89 type: refactor compatibility: compatible component: `labkit_EIS_app` | `1.4.1 -> 1.4.2` scope: Electrochemistry scope: Project lifecycle Context EIS split static product metadata across three files and one first-version project schema across a generic lifecycle package. Those files did not own separate impedance or plotting contracts. Decision and rationale Make `definition.m` the complete product declaration and `projectSpec.m` the sole durable-schema entry. Keep `createSession.m` explicit because restoring decoded ZCURVE items and source selection is genuine transient work. Changes Consolidated command metadata, version, update date, and requirements in the definition. Consolidated project defaults and validation behind one project spec. Moved decoded curve and selection restoration to a package-root session factory. Removed separate metadata files and the generic lifecycle package. Delayed log-scale assignment until filtered positive data establishes valid automatic limits, and corrected the GUI regression fixture to use a legal positive manual range while testing stale-zoom replacement. Kept impedance values, Nyquist/Bode axis semantics, log scaling, zoom behavior, and export schemas unchanged. User and developer impact Launch, multi-file selection, plotting, zoom, save/load, and export behavior remain unchanged. Project and transient state ownership are now adjacent and explicit. Compatibility and migration The project remains version 1 with identical fields, defaults, validation, and source records. Existing EIS projects require no migration. Validation Unit tests cover ZCURVE parsing, axis values, Nyquist mode, log scaling, project/session contracts, presentation, and export columns. The hidden GUI workflow covers real launch, file loading, plot interaction, save/load, and export. Evidence [EIS](../../../../apps/electrochemistry/eis/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Source restoration still consumes portable-reference internals. The planned shared source-path service will remove that leak across all Apps together."},{"title":"EIS delegates source identity reconciliation to Runtime V2","url":"history/records/2026/07/LK-20260716-eis-runtime-source-reconciliation.html","kind":"history","text":"LK-20260716-eis-runtime-source-reconciliation 2026-07-16 110 refactor compatible labkit_EIS_app EIS delegates source identity reconciliation to Runtime V2 schema: 2 id: LK-20260716-eis-runtime-source-reconciliation date: 2026-07-16 sequence: 110 type: refactor compatibility: compatible component: `labkit_EIS_app` | `1.4.3 -> 1.4.4` scope: Electrochemistry scope: App structure scope: Runtime adoption Context EIS already stored only portable source records in its durable project, but its action file still generated source IDs, appended records, and removed records with App-local helpers. That duplicated Runtime V2's source reconciliation service and made the App responsible for an identity invariant owned by project persistence. The EIS session factory also repeated empty workflow and view buckets even though Runtime V2 canonicalizes missing transient buckets. Decision and rationale Use `services.project.reconcileSources` as the single source-record owner after successful file additions, removals, and clearing. The EIS workflow continues to decide which decoded files are accepted and their order; Runtime preserves matching source identities and allocates collision-free identities for new files. Return only EIS-specific selection and decoded-cache state from `createSession`. Runtime supplies the empty workflow and view buckets before the state becomes visible to actions or presenters. Changes Removed EIS-local source-ID generation, append, and removal helpers. Reconciled durable source records from the successfully decoded EIS items. Removed empty workflow and view boilerplate from the session factory. Added GUI assertions for canonical Runtime buckets, unique source IDs, and identity preservation across additions and project reopen. Advanced the EIS App version to 1.4.4. User and data impact File selection, decoding, plotting, export, save, and reopen behavior are unchanged. Existing saved source IDs remain stable when their paths match. Newly added records receive Runtime-managed IDs. IDs are persistence identities and are not displayed as scientific results. Compatibility and migration The project payload remains version 1 and needs no migration. Existing project files continue to load through their stored portable references. Validation The EIS hidden GUI workflow covers two-file loading from separate folders, selection, plotting, export, project save, clear, and reopen. Focused unit and contract tests cover EIS calculations, App structure, version ownership, documentation, and history ordering. Evidence [EIS](../../../../apps/electrochemistry/eis/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) [App Development](../../../../development/build-apps/app-development.md) Known limitations and follow-up Other multi-file Apps still contain equivalent local source-list helpers. They should adopt the same Runtime service only after their role and ordering semantics are verified by their own focused tests."},{"title":"Electrochem Apps stop reading portable references","url":"history/records/2026/07/LK-20260716-electrochem-source-boundary.html","kind":"history","text":"LK-20260716-electrochem-source-boundary 2026-07-16 92 refactor compatible labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app Electrochem Apps stop reading portable references schema: 2 id: LK-20260716-electrochem-source-boundary date: 2026-07-16 sequence: 92 type: refactor compatibility: compatible component: `labkit_ChronoOverlay_app` | `1.4.2 -> 1.4.3` component: `labkit_CIC_app` | `1.4.2 -> 1.4.3` component: `labkit_CSC_app` | `1.4.2 -> 1.4.3` component: `labkit_EIS_app` | `1.4.2 -> 1.4.3` component: `labkit_VTResistance_app` | `1.4.2 -> 1.4.3` scope: Electrochemistry scope: Runtime source boundary Context The Electrochem Apps already delegated portable source creation, save-time rebasing, and load-time relinking to Runtime V2. Their session factories, source loaders, actions, and presenters nevertheless read the runtime's nested path field directly. CIC and VT Resistance also carried duplicate local path extraction functions. Decision and rationale Use the public Runtime source-path accessor at every App boundary. This keeps lazy-loading and batch-selection policies App-owned while removing knowledge of the portable-reference storage schema. Changes Replaced direct nested-reference reads in all five Electrochem Apps. Removed duplicate CIC and VT Resistance path extraction functions. Preserved source order and lazy first-item decoding. Kept DTA parsing, formulas, thresholds, plots, exports, and error wording unchanged. User and developer impact File selection, project reopen/relink, calculation, preview, and export behavior are unchanged. App code now expresses only its workflow use of paths; Runtime V2 owns how those paths are stored and resolved. Compatibility and migration No project schema or saved source record changed. Existing projects require no migration and remain compatible with the same UI 7 contract range. Validation The Electrochem unit and hidden-GUI suites cover project reconstruction, DTA loading, selection, plotting, computation, export, and saved-state workflows. Package and version guardrails verify the shared API and component metadata. Evidence [Electrochemistry Apps](../../../../apps/electrochemistry/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Other App families still contain direct reads and will migrate in their own behavior-tested commits before a repository-wide no-leak guard is enabled."},{"title":"Electrochemistry source-field validation boundary","url":"history/records/2026/07/LK-20260716-electrochem-source-field-validation.html","kind":"history","text":"LK-20260716-electrochem-source-field-validation 2026-07-16 118 fix compatible labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app Electrochemistry source-field validation boundary schema: 2 id: LK-20260716-electrochem-source-field-validation date: 2026-07-16 sequence: 118 type: fix compatibility: compatible component: `labkit_ChronoOverlay_app` | `1.4.5 -> 1.4.6` component: `labkit_CIC_app` | `1.4.5 -> 1.4.6` component: `labkit_CSC_app` | `1.4.5 -> 1.4.6` component: `labkit_EIS_app` | `1.4.5 -> 1.4.6` component: `labkit_VTResistance_app` | `1.4.5 -> 1.4.6` scope: electrochemistry project schemas scope: malformed project rejection Context Runtime owns the format of a portable source record but intentionally allows a project whose `inputs` bucket has no `sources` field. Static and embedded-data Apps may not use external sources. The first electrochemistry validator reduction correctly removed record-format duplication but also stopped declaring that these five file-backed Apps require a source collection. Decision and rationale Keep field presence with the App schema and record shape with Runtime. This is the narrow ownership split: the App decides whether sources are required; Runtime decides what every supplied source record means. Changes Restored the `inputs.sources` presence requirement in all five electrochemistry project validators. Added a GUI-free unit contract that accepts each default project and rejects the same project after its App-required source collection is removed. Kept canonical bucket and source-record field validation out of the Apps. User and data impact Valid projects are unchanged. A malformed electrochemistry project missing its entire source collection is rejected by the App validator before session reconstruction instead of failing later while decoded data are rebuilt. Compatibility and migration No saved format changed and no migration is required. Every project created or saved by these Apps already contains `inputs.sources`. Validation The focused project-spec test covers all five accepted defaults and all five missing-source rejection cases. The Runtime project and electrochemistry GUI tests continue to cover canonical record validation and working file flows. Evidence [Runtime and Lifecycle](../../../../framework/guides/runtime.md) separates framework-owned record shape from App-owned fields and roles. The [Electrochemistry App manuals](../../../../apps/electrochemistry/README.md) identify the required App source collection. Known limitations and follow-up The same distinction must be applied explicitly while reducing validators in other App families; a generic source format does not imply that every App has the same required source fields or roles."},{"title":"Explicit application autosave","url":"history/records/2026/07/LK-20260716-explicit-autosave.html","kind":"history","text":"LK-20260716-explicit-autosave 2026-07-16 68 feat compatible labkit.ui labkit_VideoMarker_app Explicit application autosave schema: 2 id: LK-20260716-explicit-autosave date: 2026-07-16 sequence: 68 type: feat compatibility: compatible component: `labkit.ui` | `6.0.2 -> 6.0.3` component: `labkit_VideoMarker_app` | `1.3.0 -> 1.4.0` scope: Runtime V2 project recovery scope: Video Marker session workflow Context Runtime V2 already wrote debounced recovery generations after durable edits, but an app could only expose the named-project save operation. Video Marker users who wanted to force the current recovery point had no direct control and using **Save State** introduced a destination prompt for an unnamed project. Decision and rationale Expose the existing framework-owned recovery writer as an injected action service. The service accepts the current action state, writes the same bounded recovery generations as the timer, and deliberately leaves the named project path and dirty status unchanged. Video Marker exposes that operation as **Save autosave** in its Session panel. Changes Factored the atomic recovery-generation writer out of the timer scheduler. Added `services.project.saveAutosave(state)` for an immediate, pathless recovery write. Added the Video Marker **Save autosave** button and visible workflow log acknowledgement. Kept ordinary **Save State** as the separate operation for choosing or updating a named project file. User and data impact Clicking **Save autosave** does not open a native file dialog. It updates the current app/document recovery file and retains one previous generation. It does not make the recovery file the active named project and does not suppress later unsaved-close protection. Compatibility and migration Existing projects, recovery files, and automatic autosave behavior remain compatible. Apps that do not expose the new service behave exactly as before. Validation The focused Video Marker GUI workflow verifies that the button immediately creates a recovery file while the runtime document path remains empty. The existing Runtime V2 project test continues to cover debouncing, bounded generations, and recovery loading. Evidence [Runtime and lifecycle](../../../../framework/guides/runtime.md) documents the injected recovery operation and its distinction from named saves. [Video Marker](../../../../apps/image-measurement/video-marker/README.md) documents the Session button and no-dialog behavior. Known limitations and follow-up An autosave is a recovery aid, not a user-named archival project. Users still use **Save State** when they need a deliberate project filename or location. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Explicit layout action contract","url":"history/records/2026/07/LK-20260717-explicit-layout-action-contract.html","kind":"history","text":"LK-20260717-explicit-layout-action-contract 2026-07-17 123 refactor compatible labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_GaitAnalysis_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_VideoMarker_app labkit_FigureStudio_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app Explicit layout action contract schema: 2 id: LK-20260717-explicit-layout-action-contract date: 2026-07-17 sequence: 123 type: refactor compatibility: compatible component: `labkit_DICPostprocess_app` | `1.4.5 -> 1.4.6` component: `labkit_DICPreprocess_app` | `1.5.6 -> 1.5.7` component: `labkit_ChronoOverlay_app` | `1.4.6 -> 1.4.7` component: `labkit_CIC_app` | `1.4.6 -> 1.4.7` component: `labkit_CSC_app` | `1.4.6 -> 1.4.7` component: `labkit_EIS_app` | `1.4.6 -> 1.4.7` component: `labkit_VTResistance_app` | `1.4.6 -> 1.4.7` component: `labkit_GaitAnalysis_app` | `2.0.6 -> 2.0.7` component: `labkit_BatchImageCrop_app` | `1.7.5 -> 1.7.6` component: `labkit_CurvatureMeasurement_app` | `1.4.5 -> 1.4.6` component: `labkit_FLIRThermal_app` | `1.4.5 -> 1.4.6` component: `labkit_FocusStack_app` | `1.5.4 -> 1.5.5` component: `labkit_ImageEnhance_app` | `1.6.5 -> 1.6.6` component: `labkit_ImageMatch_app` | `1.6.5 -> 1.6.6` component: `labkit_VideoMarker_app` | `1.5.5 -> 1.5.6` component: `labkit_FigureStudio_app` | `0.2.7 -> 0.2.8` component: `labkit_NerveResponseAnalysis_app` | `1.4.5 -> 1.4.6` component: `labkit_ResponseReviewStats_app` | `1.4.5 -> 1.4.6` component: `labkit_RHSPreview_app` | `1.4.5 -> 1.4.6` component: `labkit_ECGPrint_app` | `1.4.5 -> 1.4.6` scope: Runtime V2 layout callback resolution scope: public App maintenance cost Context Every public App layout carried the same local `callbackValue` helper. The helper returned an empty value when a callback field was absent, so a misspelled or unregistered action could survive startup as a control that silently did nothing. Twenty copies also obscured that Runtime already creates the complete callback table from each App definition. Decision and rationale Layouts reference Runtime-generated callbacks directly. MATLAB field access therefore validates the action ID while the data-only layout is built. Runtime remains the single callback-adapter owner; Apps own only their semantic action registries and layout references. Changes Removed the duplicated callback fallback from all 20 public App layouts. Replaced static lookups with direct `callbacks.actionId` references and kept dynamic field access only in three small controls whose action names are supplied by the surrounding layout. Added a fleet-wide contract that creates every App definition and layout with the Runtime callback inventory. Updated direct neurophysiology layout tests to supply explicit callbacks derived from their action registries. User and data impact Correctly registered controls behave as before. A broken App definition now fails during layout construction instead of presenting an inert control. No scientific calculation, project payload, saved file, or interaction semantics changed. Compatibility and migration No project migration is required. This is an App-development contract tightening: custom layouts must register every referenced semantic action. Validation The focused fleet contract builds every public App layout and catches missing or misspelled callback fields. The App package guardrail rejects reintroduced silent callback fallbacks, and focused workflow-layout tests cover direct construction outside a running figure. Evidence [Runtime layout and action rules](../../../../framework/guides/runtime.md#layout-and-action-rules) [Apps](../../../../apps/README.md) [App development](../../../../development/build-apps/app-development.md) Known limitations and follow-up This change deliberately does not merge short semantic actions or dynamic control helpers merely to reduce line count. Those remain candidates only when a repeated behavior has one stable, domain-neutral owner."},{"title":"FLIR Thermal separates durable annotations from decoded sources","url":"history/records/2026/07/LK-20260716-flir-thermal-structure.html","kind":"history","text":"LK-20260716-flir-thermal-structure 2026-07-16 98 refactor compatible labkit_FLIRThermal_app FLIR Thermal separates durable annotations from decoded sources schema: 2 id: LK-20260716-flir-thermal-structure date: 2026-07-16 sequence: 98 type: refactor compatibility: compatible component: `labkit_FLIRThermal_app` | `1.4.2 -> 1.4.3` scope: Image Measurement scope: App structure Context FLIR Thermal split static metadata across files, spread a version-1 project over a generic lifecycle package, grouped decoded records, durable annotations, and numerical reading updates under `+appState`, and read nested portable reference fields in session, actions, and presentation. Its startup callback also selected an output directory before the user loaded a source. Decision and rationale Use the compact Runtime V2 contract while preserving the boundary between decoded radiometric data and the lightweight annotations safe to persist. Assign each remaining helper to the capability it describes instead of moving thermal semantics into the framework. Changes Consolidated product metadata, version, requirements, and optional capabilities in `definition.m`. Consolidated durable project creation and validation in `projectSpec.m` and moved selected-image reconstruction to root `createSession.m`. Moved decoded item shape to `+sourceFiles`, point/ROI updates to `+analysisRun`, and persistent per-image readings to `+thermalAnnotations`. Removed separate metadata files, generic lifecycle/state packages, and the redundant App startup callback. Replaced all direct portable-reference field access with the Runtime source path accessor. User and developer impact Radiometric decoding, Celsius conversion, range controls, point and ROI readings, viewport-preserving overlays, project reopen, and exports keep their existing behavior. Empty launch no longer chooses an output folder; adding sources still establishes the same source-adjacent default. Developers can find transient decoded matrices, scientific readings, and durable annotations under their actual owners without learning a generic state layer. Compatibility and migration The durable payload remains version 1 with identical fields and defaults. Existing projects require no migration. The removed App-specific debug line is replaced by the Runtime's standard debug-startup message. Validation Five unit methods cover radiometric import, thermal values, calibration diagnostics, range controls, independent ROIs, rendering mappings, manifests, and exported matrices. Two hidden GUI workflows cover multi-file selection, session caching, managed region interaction, project save/load reconstruction, shared ranges, and batch export. Evidence [FLIR Thermal](../../../../apps/image-measurement/flir-thermal/README.md) [Thermal Library](../../../../libraries/thermal/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Other Image Measurement Apps still use generic lifecycle/state packages and will be reviewed individually. Automated GUI tests do not replace manual judgment of pointer feel, visual calibration, or camera-specific accuracy."},{"title":"FLIR display tuning","url":"history/records/2026/07/LK-20260703-flir-display-tuning.html","kind":"history","text":"LK-20260703-flir-display-tuning 2026-07-03 36 feat compatible labkit_CSC_app labkit_FLIRThermal_app labkit_FLIRThermal_app labkit_FLIRThermal_app FLIR display tuning schema: 2 id: LK-20260703-flir-display-tuning date: 2026-07-03 sequence: 36 type: feat compatibility: compatible component: `labkit_CSC_app` | `1.3.6 -> 1.3.7` component: `labkit_FLIRThermal_app` | `1.2.4 -> 1.2.5` component: `labkit_FLIRThermal_app` | `1.2.5 -> 1.2.6` component: `labkit_FLIRThermal_app` | `1.2.6 -> 1.2.7` Context The initial FLIR renderer used a fixed color transfer. Images with a narrow or skewed temperature distribution could therefore hide useful contrast even when the numeric range was correct. CSC voltage/current CSV output also needed clearer cycle organization for direct downstream use. Decision and rationale Separate the displayed color mapping from the temperature values. Apply gamma only while rendering the normalized image and expose it as a user control; keep the temperature matrix, point readings, ROI means, and exports numerically unchanged. Refine CSC CSV structure at its result writer rather than in the UI. Changes `labkit_FLIRThermal_app` `1.2.4 -> 1.2.7` `labkit_CSC_app` `1.3.6 -> 1.3.7` Refined CSC CV export. Added FLIR gamma color mapping and made gamma adjustable. User and data impact FLIR users could reveal contrast in hotter or cooler parts of an image without changing the underlying temperatures. The same gamma was used in displayed and exported color renderings. CSC users received a more directly usable voltage/current table. Compatibility and migration Existing FLIR inputs remained valid, and gamma affected rendered color only. Temperature matrices and measurement values did not require conversion. Validation The commits extended CSC export assertions, FLIR rendering and output tests, and the FLIR layout test for the gamma control. Exact historical commands were not recorded. Evidence Main commits `ee5b8f79`, `65dbf5ae`, and `f076561e`. Known limitations and follow-up Gamma changes visual contrast only. Quantitative interpretation must use the temperature values and scale, not colors sampled from the rendered image."},{"title":"Facade contract baseline and release validation hardening","url":"history/records/2026/06/LK-20260623-facade-contract-baseline-and-release-validation-hardening.html","kind":"history","text":"LK-20260623-facade-contract-baseline-and-release-validation-hardening 2026-06-23 11 ci compatible labkit.biosignal labkit.dta labkit.rhs labkit.ui labkit.ui labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_CurvatureMeasurement_app Facade contract baseline and release validation hardening schema: 2 id: LK-20260623-facade-contract-baseline-and-release-validation-hardening date: 2026-06-23 sequence: 11 type: ci compatibility: compatible introduced: `labkit.biosignal` | `1.0.0` introduced: `labkit.dta` | `1.0.0` introduced: `labkit.rhs` | `1.0.0` introduced: `labkit.ui` | `2.0.0` component: `labkit.ui` | `2.0.0 -> 2.1.0` component: `labkit.ui` | `2.2.0 -> 2.2.1` component: `labkit_DICPostprocess_app` | `1.0.0 -> 1.0.1` component: `labkit_DICPreprocess_app` | `1.0.0 -> 1.0.1` component: `labkit_CurvatureMeasurement_app` | `1.0.0 -> 1.0.1` Context Reusable packages exposed public MATLAB functions, but apps had no machine-checkable way to state which API versions they required. Release and CI checks also needed one consistent build-task entry path. Decision and rationale Give each reusable package version metadata and let apps declare compatible ranges that are checked at launch and in tests. Route MATLAB CI shards through the same build tasks used by maintainers so release validation exercises the documented commands. Changes `labkit.biosignal` `1.0.0` `labkit.dta` `1.0.0` `labkit.rhs` `1.0.0` `labkit.ui` `2.0.0 -> 2.2.1` DIC Pre/Post and Curvature `1.0.0 -> 1.0.1` Release tags `v2.4.1` and `v2.4.2` Added facade contract metadata and requirement checks. Hardened app lifecycle and release validation contracts. Routed MATLAB CI shards through build tasks. User and data impact An incompatible app/package combination could fail with a direct requirement message instead of producing a later missing-function error. Existing project and result data were unaffected. Compatibility and migration The contract metadata described the existing public facades and was additive for app callers. Apps with an incompatible declared requirement now failed at launch with a version diagnostic instead of continuing unpredictably. Validation The listed commits introduced contract, lifecycle, and CI build-task tests. Release tags `v2.4.1` and `v2.4.2` identify the shipped checkpoints; exact local commands were not recorded. Evidence Main commits `a25b79f9`, `3673e548`, `49d9f41b`, and `7e39b558`. Known limitations and follow-up App and launcher display versions were added in the separate version-metadata change later the same day. labkit.biosignal.buildTemplate Build a representative segment template. labkit.biosignal.compareGroups Summarize groups and compute pairwise Welch comparisons. labkit.biosignal.cropSignal Return a signal clipped to a time range in seconds. labkit.biosignal.defaultEcgPeakOptions Return documented defaults for ECG/QRS peak detection. labkit.biosignal.detectEcgPeaks Detect ECG/QRS peaks as event anchors. labkit.biosignal.filterSignal Apply a zero-phase FFT-domain filter to a biosignal. labkit.biosignal.getChannel Return one signal from a recording by display name or index. labkit.biosignal.listChannels Return display names for all channels in a recording. labkit.biosignal.measureSegments Measure generic template-residual segment quality. labkit.biosignal.readRecording Read a biosignal file into a standard recording struct. labkit.biosignal.segmentByEvents Extract fixed windows around event anchors. labkit.biosignal.version Return the LabKit biosignal facade contract version. labkit.dta.detectPulses Locate cathodic and anodic pulse windows in chrono data. labkit.dta.detectType Identify the supported Gamry DTA data family in a file. labkit.dta.findFiles Find DTA files in a folder and its subfolders. labkit.dta.getColumn Extract a named column from a parsed DTA table. labkit.dta.getCurveXY Extract paired X and Y data from a parsed CV/CT curve. labkit.dta.getMainCurve Select the transient table containing T, Vf, and Im data. labkit.dta.getZCurve Select the impedance table from parsed EIS data. labkit.dta.loadFile Read one supported Gamry DTA file. labkit.dta.loadFiles Read a list of Gamry DTA files. labkit.dta.loadFolder Read every DTA file below a folder. labkit.dta.version Return version information for the DTA API. labkit.rhs.findFiles Find Intan RHS files in a folder and its subfolders. labkit.rhs.indexFile Build a block index for reading selected RHS time windows. labkit.rhs.inspectFile Read metadata and data-layout information from an RHS file. labkit.rhs.readWindow Read selected channels and times from an Intan RHS file. labkit.rhs.version Return version information for the RHS API. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Figure Studio adopts one product definition","url":"history/records/2026/07/LK-20260716-figure-studio-single-definition.html","kind":"history","text":"LK-20260716-figure-studio-single-definition 2026-07-16 79 refactor compatible labkit_FigureStudio_app labkit.ui Figure Studio adopts one product definition schema: 2 id: LK-20260716-figure-studio-single-definition date: 2026-07-16 sequence: 79 type: refactor compatibility: compatible component: `labkit_FigureStudio_app` | `0.2.1 -> 0.2.2` component: `labkit.ui` | `7.2.0 -> 7.2.1` scope: LabKit Core scope: App structure Context Figure Studio declared its command, names, family, App version, and LabKit requirement in two files separate from the runtime definition. Its entrypoint joined all three factories even though they described one product. Decision and rationale Use Figure Studio as the first reviewed App migration to the single-definition contract. This validates the reduced structure on an App that also accepts a typed axes handoff, without changing its plotting or export behavior. Changes Moved exact product metadata and the UI requirement into `definition.m`. Reduced the public entrypoint to one definition plus its existing typed request adapter. Removed the redundant `requirements.m` and `version.m` files. Taught the version guard to use `AppVersion` in a migrated definition while comparing the first migration against the previous `version.m` baseline. Fixed the shared source reconciler so it creates the canonical empty source array without first constructing an invalid empty-ID placeholder record. Added `services.results.emptyOutputs()` and used it for Figure Studio's variable-length export manifest instead of constructing an invalid empty-ID placeholder output. User and developer impact Launch, axes handoff, style controls, project payload, FIG reading, plot snapshot extraction, and all export formats retain their existing behavior. Maintainers now update one product contract instead of three files. Compatibility and migration The App command and project ID are unchanged. App version metadata requests still return the same fields through the public entrypoint. Existing project payloads remain at version 1 and require no data migration. Validation Focused tests cover ordinary launch, axes handoff, style changes, project round-trip, exports, launcher discovery, requirements/version requests, and version/history governance. Evidence [Figure Studio](../../../../apps/labkit-core/figure-studio/README.md) documents the unchanged workflow and new definition ownership. [Runtime and Lifecycle](../../../../framework/guides/runtime.md) defines the shared product contract. Known limitations and follow-up Figure Studio still has a meaningful version-1 project schema, rebuildable plot session cache, and startup resource installation. These will move to the projectSpec/action capability model after the framework supports that model; they were not deleted merely to reduce file count. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Figure Studio consolidates its project schema","url":"history/records/2026/07/LK-20260716-figure-studio-project-spec.html","kind":"history","text":"LK-20260716-figure-studio-project-spec 2026-07-16 82 refactor compatible labkit_FigureStudio_app Figure Studio consolidates its project schema schema: 2 id: LK-20260716-figure-studio-project-spec date: 2026-07-16 sequence: 82 type: refactor compatibility: compatible component: `labkit_FigureStudio_app` | `0.2.2 -> 0.2.3` scope: LabKit Core scope: Project lifecycle Context Figure Studio's version-1 project creation and validation lived in separate files under a generic `+appLifecycle` package. The package name described a framework phase rather than the durable capability those functions owned. Decision and rationale Consolidate the complete durable schema behind `projectSpec.m`. Keep `createSession.m` separate at the App package root because it performs a different job: rebuilding transient selection and decoded plot cache from a validated project. Changes Added one project declaration with local create and validate functions. Moved session reconstruction to one explicitly named package-root entry. Removed the three-file generic `+appLifecycle` package. Left meaningful startup, action, presentation, source, style, and export capabilities unchanged. User and developer impact Figure loading, axes handoff, styling, project save/load, session restoration, and exports behave unchanged. A maintainer can now understand the entire durable schema in one file. Compatibility and migration The App command, project ID, payload version, fields, validation, and source record format are unchanged. Existing Figure Studio projects need no migration. Validation The existing unit and hidden GUI suites cover FIG reading, source style, project round-trip, axes handoff, canvas resizing, quick export, and data package export through the consolidated project declaration. Evidence [Figure Studio](../../../../apps/labkit-core/figure-studio/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up The startup hook still owns axes handoff and resize-resource installation. It will be evaluated with other App startup hooks before any shared mount/action capability is introduced."},{"title":"Figure Studio names its post-layout initialization capability","url":"history/records/2026/07/LK-20260716-figure-studio-initializer.html","kind":"history","text":"LK-20260716-figure-studio-initializer 2026-07-16 109 refactor compatible labkit_FigureStudio_app Figure Studio names its post-layout initialization capability schema: 2 id: LK-20260716-figure-studio-initializer date: 2026-07-16 sequence: 109 type: refactor compatibility: compatible component: `labkit_FigureStudio_app` | `0.2.4 -> 0.2.5` scope: LabKit Core scope: App structure Context Figure Studio was the only public App with a generic `startup.m` file. Its definition already declared the function through Runtime V2's optional `Start` capability, but the filename obscured who invoked it and made the function look like an independent lifecycle entrypoint. Decision and rationale Keep the Runtime-owned `Start` hook because Figure Studio genuinely needs a post-layout phase: it consumes an optional axes handoff, resolves the managed preview axes, and registers a cleanup-owned resize resource. Rename the App callback to `initializeWorkbench` so its capability and invocation boundary are explicit. Do not move GUI/service work into the GUI-free session factory or reintroduce App-owned timers and readiness state. Changes Renamed the Start callback from generic `startup` to `initializeWorkbench` and declared the new handle in `definition`. Documented the callback timing and the request, preview, resource, dialog, workflow, and debug services supplied by Runtime V2. Tightened the App structure guardrail so compact Apps cannot add a generic `startup.m` file outside their declared capability vocabulary. Advanced the Figure Studio App version to 0.2.5. User and data impact Normal launch, default output folder selection, popout axes handoff, source style adoption, preview canvas resize behavior, debug logging, project data, and exports are unchanged. No project migration is required. Compatibility and migration The public command and Runtime definition contract are unchanged. `startup` was an App-internal callback referenced only by the definition; callers should launch the App rather than invoke either callback directly. Validation The Figure Studio hidden GUI suite covers ordinary launch, style controls, FIG loading, preview resize, quick/package export, axes handoff, project save/load, stable canvas sizing, and popout-to-Studio transfer. It also asserts that the definition exposes the semantic initializer handle. Evidence [Figure Studio](../../../../apps/labkit-core/figure-studio/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) [App Development](../../../../development/build-apps/app-development.md) Known limitations and follow-up The Start capability remains necessary for post-layout resources; removing it would require a different framework service phase rather than a file move. App and agent guidance still need a repository-wide terminology audit."},{"title":"Figure Studio removes source-reference knowledge","url":"history/records/2026/07/LK-20260716-figure-studio-source-boundary.html","kind":"history","text":"LK-20260716-figure-studio-source-boundary 2026-07-16 94 refactor compatible labkit_FigureStudio_app Figure Studio removes source-reference knowledge schema: 2 id: LK-20260716-figure-studio-source-boundary date: 2026-07-16 sequence: 94 type: refactor compatibility: compatible component: `labkit_FigureStudio_app` | `0.2.3 -> 0.2.4` scope: LabKit Core Apps scope: Runtime source boundary Context Figure Studio read both the current path and stored filename from the nested portable source reference. It repeated those reads in session reconstruction, actions, file-list presentation, and selection lookup. Decision and rationale Resolve source paths once through Runtime V2 at each workflow boundary. Derive the displayed filename from the resolved path so the App needs no knowledge of any portable-reference field. Changes Migrated FIG session reconstruction, action loading, file presentation, and selection lookup to `labkit.ui.runtime.sourcePaths`. Derived the opened-file status name through `fileparts`. Removed every direct portable-reference field read from Figure Studio. Preserved imported axes data, default style adoption, source order, current selection, editing, and export behavior. User and developer impact FIG selection and display behave unchanged. Figure Studio now owns only how it uses a resolved FIG file; Runtime V2 owns how that file remains portable. Compatibility and migration No project or source-record field changed. Existing Figure Studio projects require no migration. Validation Source-axes and result-file unit tests cover import and export semantics. The hidden GUI suite covers empty startup, FIG addition and selection, style presentation, save/load, and output workflows. Evidence [Figure Studio](../../../../apps/labkit-core/figure-studio/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up The repository-wide portable-reference guard remains deferred until all remaining App families have migrated."},{"title":"File-panel layout stabilization","url":"history/records/2026/06/LK-20260630-file-panel-layout-stabilization.html","kind":"history","text":"LK-20260630-file-panel-layout-stabilization 2026-06-30 19 fix compatible labkit.ui labkit.ui File-panel layout stabilization schema: 2 id: LK-20260630-file-panel-layout-stabilization date: 2026-06-30 sequence: 19 type: fix compatibility: compatible component: `labkit.ui` | `3.2.3 -> 3.2.4` component: `labkit.ui` | `3.2.4 -> 3.2.5` Context The single-file panel allocated too much height to its rows and could render inconsistently as the containing app resized. This was especially noticeable in file-heavy apps, where the panel competed with analysis controls and plots. Decision and rationale Give the panel explicit, compact row sizing and protect that geometry with GUI layout tests. The change stayed in the shared file-panel builder because the same visual defect appeared wherever the component was used. Changes `labkit.ui` `3.2.3 -> 3.2.5` Stabilized and compacted single file-panel layout. User and data impact File names and actions occupied less vertical space, leaving more room for the workflow below them. The change affected layout only; selections and loaded file data were unchanged. Compatibility and migration App definitions and selected files required no conversion. The shared panel kept the same controls and callbacks with revised row geometry. Validation Both commits extended `GuiLayoutUiDeclarativeAppTest` with the expected panel geometry. Exact historical test commands were not recorded. Evidence Main commits `7f8df1cd` and `02b2f1b6`. Known limitations and follow-up Later runtime generations replaced this private UI 3.x builder, but retained the principle that shared components own and test their responsive geometry. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"File-panel migration","url":"history/records/2026/06/LK-20260624-file-panel-migration.html","kind":"history","text":"LK-20260624-file-panel-migration 2026-06-24 12 refactor breaking labkit.dta labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app File-panel migration schema: 2 id: LK-20260624-file-panel-migration date: 2026-06-24 sequence: 12 type: refactor compatibility: breaking component: `labkit.dta` | `1.0.0 -> 2.0.0` component: `labkit.ui` | `2.2.1 -> 3.0.0` component: `labkit_DICPostprocess_app` | `1.0.1 -> 1.2.0` component: `labkit_DICPreprocess_app` | `1.0.1 -> 1.2.0` component: `labkit_ChronoOverlay_app` | `1.0.0 -> 1.2.0` component: `labkit_CIC_app` | `1.0.0 -> 1.2.0` component: `labkit_CSC_app` | `1.0.0 -> 1.2.0` component: `labkit_EIS_app` | `1.0.0 -> 1.2.0` component: `labkit_VTResistance_app` | `1.0.0 -> 1.2.0` component: `labkit_BatchImageCrop_app` | `1.0.0 -> 1.2.0` component: `labkit_CurvatureMeasurement_app` | `1.0.1 -> 1.2.0` component: `labkit_FocusStack_app` | `1.0.0 -> 1.2.0` component: `labkit_ImageEnhance_app` | `1.0.0 -> 1.2.0` component: `labkit_ImageMatch_app` | `1.0.0 -> 1.2.0` component: `labkit_NerveResponseAnalysis_app` | `1.0.0 -> 1.2.0` component: `labkit_ResponseReviewStats_app` | `1.0.0 -> 1.2.0` component: `labkit_RHSPreview_app` | `1.0.0 -> 1.2.0` component: `labkit_ECGPrint_app` | `1.0.0 -> 1.2.0` Context Apps implemented file selection through separate task-input adapters, which produced inconsistent lists, selection events, and status feedback. The DTA session helper also coupled parsing to that older UI model. Decision and rationale Move supported apps to one reusable file-panel model and make DTA loading a GUI-free file/curve API. Accept the breaking package versions because retaining both task-input and file-panel paths would preserve ambiguous callback behavior. Changes `labkit.dta` `1.0.0 -> 2.0.0` `labkit.ui` `2.2.1 -> 3.0.0` All supported apps moved from `1.0.x` into the `1.2.0` workflow line. Replaced task inputs with file panels. Removed the old DTA session helper surface. User and data impact File lists, current selection, add/remove actions, and status display became consistent across the migrated apps. App code written against the removed task inputs or DTA session helpers required an update. Compatibility and migration This was a breaking workflow migration. Older app code expecting task inputs or the removed DTA session helpers needed migration. Validation Commit `b145c904` migrated app GUI workflows and package compatibility tests to the new file-panel and DTA APIs. Evidence Main commit `b145c904`. Known limitations and follow-up This first file-panel generation was later refined for append behavior, native dialog edge cases, and Runtime V2 events. labkit.dta.detectPulses Locate cathodic and anodic pulse windows in chrono data. labkit.dta.detectType Identify the supported Gamry DTA data family in a file. labkit.dta.findFiles Find DTA files in a folder and its subfolders. labkit.dta.getColumn Extract a named column from a parsed DTA table. labkit.dta.getCurveXY Extract paired X and Y data from a parsed CV/CT curve. labkit.dta.getMainCurve Select the transient table containing T, Vf, and Im data. labkit.dta.getZCurve Select the impedance table from parsed EIS data. labkit.dta.loadFile Read one supported Gamry DTA file. labkit.dta.loadFiles Read a list of Gamry DTA files. labkit.dta.loadFolder Read every DTA file below a folder. labkit.dta.version Return version information for the DTA API. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Focus Stack adopts the compact App contract","url":"history/records/2026/07/LK-20260716-focus-stack-structure.html","kind":"history","text":"LK-20260716-focus-stack-structure 2026-07-16 95 refactor compatible labkit_FocusStack_app Focus Stack adopts the compact App contract schema: 2 id: LK-20260716-focus-stack-structure date: 2026-07-16 sequence: 95 type: refactor compatibility: compatible component: `labkit_FocusStack_app` | `1.5.1 -> 1.5.2` scope: Image Measurement scope: App structure Context Focus Stack split static metadata across `requirements.m` and `version.m`, spread a version-1 project over a generic lifecycle package, grouped three calculation concepts under `+appState`, and ran a startup callback only to fill an output path and emit an App-specific debug line. It also duplicated the portable-reference path loop in session, actions, and presentation. Decision and rationale Use the compact Runtime V2 contract: one product definition, one durable project specification, and one explicit session factory. Put calculation result shape, presets, and run fingerprinting beside the computation. Resolve output folders only when file registration or export actually needs them. Changes Consolidated command metadata, version, requirements, and optional capabilities in `definition.m`. Consolidated project creation and validation in `projectSpec.m`. Moved transient image reconstruction to root `createSession.m`. Moved three `+appState` helpers into their owning `+analysisRun` capability. Removed the metadata files, generic lifecycle/state packages, and redundant App startup callback. Replaced all direct portable-reference reads with the Runtime path accessor. User and developer impact Focus loading, registration, fusion, previews, duplicate-run detection, project reopen, and exports keep the same scientific behavior. A new App no longer mutates its durable output folder during startup. After inputs are selected, output defaults remain source-adjacent as before. The App package loses six structural files and one generic package while its real workflow capabilities become easier to locate. Compatibility and migration The durable payload remains version 1 with identical fields, defaults, and validation. Existing projects require no migration. The removed startup debug line is replaced by the Runtime's standard debug-startup message. Validation Unit tests cover the definition/project/session contract, numerical fusion, registration, deterministic run fingerprints, summaries, readers, and invalid inputs. The hidden GUI test covers debug startup, file and folder import, fusion, presentation, export manifest, save/load reconstruction, and append. Evidence [Focus Stack](../../../../apps/image-measurement/focus-stack/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up The remaining Image Measurement Apps still use the older structural packages and will be reviewed individually rather than copied from this App blindly."},{"title":"Framework guides and function reference keep distinct routes","url":"history/records/2026/07/LK-20260717-framework-guide-and-function-routes.html","kind":"history","text":"LK-20260717-framework-guide-and-function-routes 2026-07-17 134 fix compatible Framework guides and function reference keep distinct routes schema: 2 id: LK-20260717-framework-guide-and-function-routes date: 2026-07-17 sequence: 134 type: fix compatibility: compatible scope: Documentation information architecture Context An initial Framework documentation alignment also classified individual `labkit.ui.*` and `labkit.contract.*` API pages as Framework pages. Entering those pages from the global Functions index then changed the active top-level area, which mixed conceptual ownership with page type and made return paths hard to predict. Decision and rationale Keep conceptual and workflow guides under Framework. Keep every exact MATLAB function contract under Functions. Visible API group labels still explain that `labkit.ui` is the Framework implementation, but the top navigation remains stable while browsing function reference pages. Changes Moved the canonical compatibility guide to `framework/contracts.html`. Kept a tracked legacy page at `libraries/contracts/index.html` that links to the new guide and the Functions reference. Restored Functions ownership for all individual UI and contract API pages. Updated framework, library, and regression-test links to the canonical guide. User and data impact Readers now follow one predictable route for Framework guides and another for exact function syntax. Existing contract-guide bookmarks still reach a compatibility page. MATLAB code and scientific data are unchanged. Compatibility and migration The old guide URL remains as a compatibility landing page. Public MATLAB symbols remain unchanged. Validation Renderer regression verifies the Framework guide route, legacy landing page, and Functions-active runtime API page. `docsCheck` verifies the complete site. Evidence The generated Framework landing page links directly to `framework/contracts.html`, while `labkit.ui.runtime.launch` highlights Functions and labels its sibling group Framework Runtime. Known limitations and follow-up The legacy page is a visible handoff rather than an HTTP redirect because the tracked static-site renderer does not currently emit redirect responses."},{"title":"Framework owns UI and compatibility documentation","url":"history/records/2026/07/LK-20260717-framework-documentation-ownership.html","kind":"history","text":"LK-20260717-framework-documentation-ownership 2026-07-17 133 docs compatible Framework owns UI and compatibility documentation schema: 2 id: LK-20260717-framework-documentation-ownership date: 2026-07-17 sequence: 133 type: docs compatibility: compatible scope: App Framework documentation Context The documentation described `labkit.ui` as the App Framework but presented its public API and the compatibility contracts through the generic Functions area. The visible information architecture therefore disagreed with the framework terminology used by app development guides. Decision and rationale Make Framework the documentation owner for `labkit.ui.*` and `labkit.contract.*`. Compatibility requirements belong in the Framework because App definitions and startup consume them. Keep the stable `labkit.contract` MATLAB namespace because it checks UI and domain facades alike; moving it below `labkit.ui` would create a misleading dependency and break existing App definitions. Changes Added compatibility contracts as a Framework sidebar branch. Classified UI and contract API pages under the Framework top navigation. Replaced raw UI package headings with visible Framework API labels while retaining fully qualified MATLAB symbols. Removed the separate Contracts entry from the Functions guide table. User and data impact Readers now enter framework runtime, layout, plotting, interaction, debugging, and compatibility material through one consistent Framework route. MATLAB calls, saved projects, scientific results, and URLs remain compatible. Compatibility and migration No code migration is required. Existing `labkit.ui.*` and `labkit.contract.*` symbols are unchanged. Validation Renderer regression tests verify Framework ownership for the contracts guide and a runtime API page. `docsCheck` rebuilds and compares the complete site. Evidence Generated UI and contract pages highlight Framework in the product navigation, and the Framework landing sidebar links the compatibility guide. Known limitations and follow-up The global API reference still indexes all public symbols together so exact MATLAB functions remain searchable from one place."},{"title":"Gait Analysis active-swing workflow","url":"history/records/2026/07/LK-20260716-gait-analysis-active-swing-workflow.html","kind":"history","text":"LK-20260716-gait-analysis-active-swing-workflow 2026-07-16 71 feat breaking labkit_GaitAnalysis_app Gait Analysis active-swing workflow schema: 2 id: LK-20260716-gait-analysis-active-swing-workflow date: 2026-07-16 sequence: 71 type: feat compatibility: breaking component: `labkit_GaitAnalysis_app` | `1.1.1 -> 2.0.0` scope: Video Marker input contract scope: treadmill swing segmentation scope: gait visualization and exports Context The migrated Gait app accepted loosely shaped coordinate files but did not recover the legacy workflow's step segmentation, per-step skeleton report, or complete translation and angle measurements. A coordinate file alone also could not prove its frame rate, skeleton order, or calibration. Decision and rationale Treat a current Video Marker payload-version-2 project or autosave as the sole file input. It is the first durable artifact that owns coordinates, timing, skeleton, calibration, and annotation provenance together. Segment the legacy treadmill active swing from a prominent foot-X maximum (lift-off) to the following minimum (landing), including a final completed swing that has no later lift-off. Changes Load only the named `labkitProject` variable and reject generic tables, arbitrary MAT variables, legacy marker payloads, and missing frame rate. Show all overlaid skeleton trajectories immediately after import. Detect active swings with app-owned prominence, peak-height, and temporal separation logic; compute cycle/stance measures when a following lift-off exists. Present one selected swing with connected landmarks, joint/segment traces, five endpoint translations, and joint minimum, maximum, and range of motion. Export per-frame, coordinate, step, summary, and provenance tables with lift-off/landing and `swing_time_s` terminology. Migrate version-1 Gait option names and invalidate its scientifically incompatible cached result. User and data impact Users first inspect all trajectories, then run analysis and move between segmented swings. Existing Gait project settings are migrated, but old cached results are recalculated. Older or generic pose files must be converted by opening and saving the source in current Video Marker. Compatibility and migration This is an intentional input and output-schema break. The durable project migration preserves equivalent thresholds while renaming stride/contact-era fields to active-swing terms. Video Marker project payload version 2 is required. Validation Focused unit tests cover source metadata recovery, rejection of incomplete and legacy MAT files, lift-off/landing pairs, retention of the final completed swing, migration of old option names, scientific table fields, and exports. The focused GUI test covers trajectory orientation, analysis, step preview, export, and project reopen. Evidence [Gait Analysis](../../../../apps/gait/gait-analysis/README.md) documents the source, calculation, visualization, and export contracts. [Video Marker](../../../../apps/image-measurement/video-marker/README.md) documents the durable payload used as input. Known limitations and follow-up The detector represents image-kinematic treadmill events, not force-plate contact. Frame annotation status is preserved but is not yet an automatic step exclusion rule."},{"title":"Gait Analysis app","url":"history/records/2026/07/LK-20260714-gait-analysis-app.html","kind":"history","text":"LK-20260714-gait-analysis-app 2026-07-14 55 feat additive labkit_GaitAnalysis_app Gait Analysis app schema: 2 id: LK-20260714-gait-analysis-app date: 2026-07-14 sequence: 55 type: feat compatibility: additive introduced: `labkit_GaitAnalysis_app` | `1.0.0` Context Existing gait work used a script chain after pose tracking: import coordinates, smooth marker traces, make per-step figures, compute step and joint metrics, select useful steps, and export tables for downstream statistics. That work belongs in its own app family because the downstream task is not image annotation, signal import, or electrochemistry; it is gait-specific pose analysis from already tracked coordinates. Decision and rationale Add an independent Gait Analysis app instead of extending Video Marker or recreating a model-training workflow. The app accepts several coordinate-table shapes, keeps gait event detection and metric definitions app-local, and exports simple CSV tables that can be consumed by plotting or statistical programs. Changes Added `labkit_GaitAnalysis_app` under the new Gait family. Added CSV/TSV/TXT and MAT pose-coordinate import, including generic `point_x`/`point_y` and LabKit `point__x`/`point__y` column shapes. Added smoothing, foot-relative step-event detection, hip/knee/ankle angle calculation, segment lengths, per-step translations, stride length, step time, ROM, summary metrics, and trajectory/angle/step-event previews. Added CSV set export for frame metrics, step metrics, summary metrics, and per-frame coordinates that keep raw pixel columns alongside optional scale-calibrated and first-frame-origin-shifted columns. User and data impact Users can analyze gait from multiple pose-coordinate sources without tying the workflow to a specific tracking model. The exported tables are plain CSV and separate frame-level, coordinate, step-level, and summary data for downstream overlay, plotting, or statistics. Compatibility and migration The app is additive and does not change Video Marker, image measurement apps, public `+labkit` facades, or existing exports. Existing script outputs can be imported when they provide wide coordinate columns or MAT `coords` and `pointNames`. Validation `GaitAnalysisTest` covers coordinate import, synthetic step detection, metric tables, MAT import, coordinate export calibration/origin semantics, coordinate CSV readback, and CSV set export. `GuiLayoutGaitAnalysisTest` covers the hidden GUI launch and semantic control contract. These tests were added with the app in commit `49863964`. Evidence Initial Gait Analysis app `49863964`. Known limitations and follow-up The first version analyzes one tracked subject at a time and does not perform tracking, model training, group-level statistics, EMG/CAP synchronization, multi-limb phase analysis, or automatic step quality classification."},{"title":"Gait Analysis keeps schema history in one project contract","url":"history/records/2026/07/LK-20260716-gait-analysis-structure.html","kind":"history","text":"LK-20260716-gait-analysis-structure 2026-07-16 104 refactor compatible labkit_GaitAnalysis_app Gait Analysis keeps schema history in one project contract schema: 2 id: LK-20260716-gait-analysis-structure date: 2026-07-16 sequence: 104 type: refactor compatibility: compatible component: `labkit_GaitAnalysis_app` | `2.0.3 -> 2.0.4` scope: Gait scope: App structure Context Gait Analysis spread creation, validation, session reconstruction, and two ordered schema upgrades through a generic lifecycle package. Its generic state package actually contained four analysis concepts: defaults, source-derived options, result construction, and duplicate-run fingerprints. Session and presentation code also inspected the Runtime's nested source-reference fields. Decision and rationale Concentrate project history behind one `projectSpec.m`, keep transient pose reconstruction in root `createSession.m`, and assign all former state helpers to `+analysisRun`. This preserves the real complexity—Video Marker parsing, step segmentation, kinematics, QC, and exports—while removing structural categories that did not explain the workflow. Changes Consolidated product metadata, version, requirements, layout, actions, presentation, renderer, and debug capability in `definition.m`. Replaced two public migration files with one migration callback that selects the version-1 or version-2 transformation from `fromVersion`. Moved default options, pose-derived option resolution, empty results, and deterministic task fingerprints from `+appState` to `+analysisRun`. Replaced direct portable-reference access with semantic `sourcePaths` lookup in session reconstruction and presentation. Removed generic lifecycle/state packages and separate requirement/version files, and updated GUI-free documentation calls to the owning package. User and developer impact Current Video Marker MAT loading, full-trajectory inspection, active-swing segmentation, one-step review, joint angles, translations, timing, QC, duplicate-run detection, CSV export, project save, and project reopen retain their behavior. The image-coordinate Y direction and time-series coordinate direction remain deliberately distinct. Developers can now follow project history through one entry and find all analysis policy under the package that owns the scientific workflow. Compatibility and migration Durable schema version 3 is unchanged. Version-1 option renames and result invalidation still run before the version-2 source collection upgrade. Runtime V2 now owns the loop and validates the resulting current payload. Validation Focused tests cover current Video Marker input, timing/scale/role extraction, active-swing segmentation, final-swing retention, gait parameters, CSV round-trip, and both historical migrations. The hidden GUI workflow covers MAT import, trajectory preview, analysis, export, project save, and decoded pose reconstruction after reopen. Evidence [Gait Analysis](../../../../apps/gait/gait-analysis/README.md) [Video Marker](../../../../apps/image-measurement/video-marker/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Automated tests do not establish scientific validity for recordings outside the documented treadmill model or replace manual inspection of tracked points and segmented events. Neurophysiology and wearable Apps with generic lifecycle packages remain scheduled for the same review."},{"title":"Gait consumes the Video Marker file contract without a sibling App dependency","url":"history/records/2026/07/LK-20260717-gait-app-path-isolation.html","kind":"history","text":"LK-20260717-gait-app-path-isolation 2026-07-17 124 fix compatible labkit_GaitAnalysis_app Gait consumes the Video Marker file contract without a sibling App dependency schema: 2 id: LK-20260717-gait-app-path-isolation date: 2026-07-17 sequence: 124 type: fix compatibility: compatible component: `labkit_GaitAnalysis_app` | `2.0.7 -> 2.0.8` scope: Gait scope: App packaging scope: Video Marker file contract Context Gait Analysis correctly parsed saved Video Marker MAT documents in production, but its synthetic debug writer created those documents by calling `video_marker.projectSpec`, `video_marker.skeletonDefinition`, and `video_marker.frameAnnotations`. Tests added every App root to the MATLAB path, while the launcher adds only the selected App root. Debug startup could therefore pass tests and fail in a real isolated launch. Decision and rationale Treat the saved `labkitProject` payload as the boundary between the producer and consumer. Gait owns its parser, diagnostics, and synthetic consumer fixture. Video Marker owns serialization and schema validation. One explicit integration test may load both Apps to prove their current file contracts agree, but neither Gait production nor debug code may call the Video Marker package. Changes Rebuilt the Gait debug sample from the documented payload fields without loading Video Marker source code. Split consumer parser tests from a producer-consumer integration test that constructs a current Video Marker project, validates it, saves it, and reads it through Gait. Added an isolated-path debug test using only the repository root and Gait App root. Added a repository guardrail that rejects executable calls from one App package into a sibling App package while allowing documentation references and integration tests. Documented the saved-data boundary in the App development guide and Gait manual. User and developer impact Gait debug startup and its packaged App behavior no longer depend on every public App being present on the MATLAB path. Users continue to open the same current Video Marker project and autosave MAT files; parsing, timing, calibration, gait calculations, and exports are unchanged. Developers receive an immediate guardrail failure if a shared test path hides a new sibling-App runtime dependency. Compatibility and migration No project or output migration is required. Video Marker payload version 2 remains the accepted input contract. Validation Focused validation covers isolated debug sample generation, consumer parsing, current Video Marker producer-to-Gait import, Gait calculations and hidden GUI workflow, and the App package boundary guardrail. Evidence [Gait Analysis](../../../../apps/gait/gait-analysis/README.md) [Video Marker](../../../../apps/image-measurement/video-marker/README.md) [App Development](../../../../development/build-apps/app-development.md#cross-app-data-contracts) Known limitations and follow-up The integration test protects the current saved schema, not scientific validity of manually marked trajectories. Those data and workflow checks remain App-owned."},{"title":"Gait trajectory image-coordinate preview","url":"history/records/2026/07/LK-20260716-gait-image-coordinates.html","kind":"history","text":"LK-20260716-gait-image-coordinates 2026-07-16 67 fix compatible labkit_GaitAnalysis_app Gait trajectory image-coordinate preview schema: 2 id: LK-20260716-gait-image-coordinates date: 2026-07-16 sequence: 67 type: fix compatibility: compatible component: `labkit_GaitAnalysis_app` | `1.1.0 -> 1.1.1` scope: trajectory preview Context Marker coordinates use the image convention with an upper-left origin and Y increasing downward. Gait Analysis plotted those values on MATLAB's default Cartesian axes, so the trajectory preview appeared vertically flipped relative to the source video. Decision and rationale Preserve the imported coordinate values and scientific calculations. Reverse only the trajectory preview's Y axis so its visual orientation matches Video Marker and Image Marker. Time-series angle and step plots explicitly retain the conventional upward Y direction. Changes Render trajectory previews with a reversed Y axis. Restore the normal Y direction when switching to Angles or Steps. Document the preview coordinate convention. User and data impact Tracked motion now appears in the same orientation as the source image. Stored coordinates, calculations, tables, and exports are unchanged. Compatibility and migration The change is display-only and requires no project or data migration. Validation The hidden Gait Analysis workflow test checks both trajectory and time-series axis directions while exercising source load and analysis. Evidence [Gait Analysis](../../../../apps/gait/gait-analysis/README.md) documents the image-coordinate preview convention. Known limitations and follow-up The preview assumes imported marker coordinates follow the documented image coordinate convention. Generic coordinate files with a Cartesian convention must be converted by their producer before import."},{"title":"Headless validation follows current documentation and App contracts","url":"history/records/2026/07/LK-20260719-headless-validation-repair.html","kind":"history","text":"LK-20260719-headless-validation-repair 2026-07-19 137 fix behavior-preserving labkit_TTestWizard_app Headless validation follows current documentation and App contracts schema: 2 id: LK-20260719-headless-validation-repair date: 2026-07-19 sequence: 137 type: fix compatibility: behavior-preserving component: `labkit_TTestWizard_app` | `1.0.0 -> 1.0.1` scope: Continuous integration scope: Documentation validation scope: Statistics Context The first main-branch validation after the path-derived documentation change still asserted several retired documentation paths. The same run exposed T-Test Wizard code-quality findings and a framework GUI test file that had grown beyond its review budget. Decision and rationale Align validation with the current path-owned documentation structure and keep Runtime-owned empty session buckets out of App factories. Resolve the static analysis findings without changing calculations, export values, or visible workflow behavior. Split the workspace-specific GUI coverage by capability so each test file remains focused. Changes Updated documentation guardrails to use the current testing, Runtime, and complete-App guide paths. Removed an empty `view` placeholder that Runtime already supplies. Replaced dynamic array growth in group-label and group-reassignment helpers with bounded collection. Centralized the box-plot label and documented the plot-jitter and significance-display constants. Split workspace-page and dynamic-table GUI coverage into its own focused test file. User and data impact T-Test Wizard calculations, project data, CSV values, plots, and workflows are unchanged. The App version advances to 1.0.1 to record the internal validation repair. Compatibility and migration Existing version-1 projects remain compatible and require no data migration. Runtime continues to provide `selection`, `workflow`, `view`, and `cache` in the final session state. Validation Focused contract and T-Test core tests cover the original headless failures. Hidden GUI tests cover the T-Test workflow and the extracted Runtime workspace coverage. Documentation rendering and changed-file gates validate the final main-branch handoff. Evidence [T-Test Wizard](../../../../apps/statistics/ttest-wizard/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) [Testing LabKit](../../../../development/maintain-and-release/testing.md) Known limitations and follow-up Interactive native-dialog behavior remains a developer-led manual validation responsibility; hidden GUI automation does not replace it."},{"title":"Image App project validation ownership","url":"history/records/2026/07/LK-20260716-image-project-validation-ownership.html","kind":"history","text":"LK-20260716-image-project-validation-ownership 2026-07-16 119 refactor compatible labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_VideoMarker_app Image App project validation ownership schema: 2 id: LK-20260716-image-project-validation-ownership date: 2026-07-16 sequence: 119 type: refactor compatibility: compatible component: `labkit_BatchImageCrop_app` | `1.7.4 -> 1.7.5` component: `labkit_CurvatureMeasurement_app` | `1.4.4 -> 1.4.5` component: `labkit_FLIRThermal_app` | `1.4.4 -> 1.4.5` component: `labkit_FocusStack_app` | `1.5.3 -> 1.5.4` component: `labkit_ImageEnhance_app` | `1.6.4 -> 1.6.5` component: `labkit_ImageMatch_app` | `1.6.4 -> 1.6.5` component: `labkit_VideoMarker_app` | `1.5.3 -> 1.5.4` scope: image App project schemas scope: App maintenance cost Context The seven image workflow validators repeated Runtime's canonical project-bucket checks and, in six cases, the standard portable source-record field list. Those checks obscured the App-specific rules that actually matter: crop task relationships, parameter ranges, annotation shapes, video metadata, skeleton dimensions, and result schemas. Decision and rationale Keep the existence of each App's required source collection and every domain-specific relationship in `projectSpec.m`. Rely on Runtime for the five canonical bucket structs and the internal shape of every supplied source record. This preserves strict malformed-project rejection while making each validator read as its App schema. Changes Removed 71 net lines of repeated Runtime structure checks from the seven image App project specifications. Preserved required `inputs.sources` fields and all item/source, annotation/source, skeleton/frame, parameter, and result constraints. Added a GUI-free contract that accepts all seven default projects and rejects each project when its App-required source collection is removed. User and data impact Valid projects, migrations, image calculations, annotations, and exports are unchanged. Malformed source records continue to fail in Runtime; malformed App fields continue to fail in the owning App validator. Compatibility and migration No payload format changed and no migration is required. The refactor changes which layer reports framework-owned structure failures, not which valid data the Apps accept. Validation The focused project-spec contract covers default acceptance and missing-source rejection for every affected App. Existing focused unit and hidden-GUI tests cover the domain relationships and representative load, edit, analyze, and export workflows. Evidence [Runtime and Lifecycle](../../../../framework/guides/runtime.md) defines canonical project and source validation. Each affected [Image Measurement App](../../../../apps/image-measurement/README.md) manual lists its remaining project and session responsibilities. Known limitations and follow-up This change does not shorten action handlers or scientific workflow code. Those remain App-owned unless a separately measured repeated behavior proves to be stable and domain-neutral."},{"title":"Image Apps stop creating invalid placeholder outputs","url":"history/records/2026/07/LK-20260716-image-manifest-output-arrays.html","kind":"history","text":"LK-20260716-image-manifest-output-arrays 2026-07-16 80 fix compatible labkit_BatchImageCrop_app labkit_FLIRThermal_app labkit_ImageEnhance_app labkit_ImageMatch_app Image Apps stop creating invalid placeholder outputs schema: 2 id: LK-20260716-image-manifest-output-arrays date: 2026-07-16 sequence: 80 type: fix compatibility: compatible component: `labkit_BatchImageCrop_app` | `1.7.1 -> 1.7.2` component: `labkit_FLIRThermal_app` | `1.4.1 -> 1.4.2` component: `labkit_ImageEnhance_app` | `1.6.1 -> 1.6.2` component: `labkit_ImageMatch_app` | `1.6.1 -> 1.6.2` scope: Image Measurement scope: Result manifests Context Four image Apps preallocated variable-length result arrays by first asking the Runtime output factory to create a record with an empty ID. Runtime V2 rejects empty result IDs, so otherwise valid export workflows could stop before their standard manifest was written. Decision and rationale Use the canonical empty result array introduced by `labkit.ui` and append only validated real outputs. This preserves strict IDs without requiring each App to duplicate the manifest struct shape. Changes Updated Batch Crop crop outputs. Updated FLIR image, colorbar, and temperature-CSV outputs. Updated Image Enhance batch outputs. Updated Image Match batch outputs. User and developer impact The exported images, numeric CSV data, filenames, roles, statuses, and manifest schemas are unchanged. Exports no longer fail during internal output-array initialization. Compatibility and migration No saved project or export schema migration is required. Existing result files remain readable. Validation Each affected App's hidden synthetic GUI export workflow verifies its domain output plus the standard LabKit manifest. Version and component-history guardrails cover all four products. Evidence [Batch Crop](../../../../apps/image-measurement/batch-crop/README.md) [FLIR Thermal](../../../../apps/image-measurement/flir-thermal/README.md) [Image Enhance](../../../../apps/image-measurement/image-enhance/README.md) [Image Match](../../../../apps/image-measurement/image-match/README.md) Known limitations and follow-up The Apps still need their separate single-definition and projectSpec reviews; this record only fixes the shared manifest construction defect."},{"title":"Image Enhance assigns state helpers to workflow capabilities","url":"history/records/2026/07/LK-20260716-image-enhance-structure.html","kind":"history","text":"LK-20260716-image-enhance-structure 2026-07-16 99 refactor compatible labkit_ImageEnhance_app Image Enhance assigns state helpers to workflow capabilities schema: 2 id: LK-20260716-image-enhance-structure date: 2026-07-16 sequence: 99 type: refactor compatibility: compatible component: `labkit_ImageEnhance_app` | `1.6.2 -> 1.6.3` scope: Image Measurement scope: App structure Context Image Enhance split static product metadata across files, spread a version-1 project over a generic lifecycle package, and placed decoded items, durable annotations, numerical histories, preview scaling, UI normalization, and export fingerprints together under `+appState`. Source reconstruction and presentation also read nested portable-reference fields directly. Decision and rationale Use the compact Runtime V2 definition and project contracts, then assign each remaining concept to the workflow capability that owns its behavior. Preserve the important preview/export boundary: pixel-radius parameters scale for the downsampled preview while full-size export continues to use source coordinates. Changes Consolidated product metadata, version, requirements, and optional capabilities in `definition.m`. Consolidated durable creation and validation in `projectSpec.m` and moved transient reconstruction to root `createSession.m`. Assigned decoded items and lazy selected-image loading to `+sourceFiles`, histories and preview replay to `+analysisRun`, durable per-image data to `+enhancementAnnotations`, UI value clamping to `+userInterface`, and export fingerprints to `+resultFiles`. Removed separate metadata files, generic lifecycle/state packages, and the redundant App startup callback. Replaced direct portable-reference access with the Runtime path accessor. Corrected the GUI-free manual example to write the first returned image from the documented cell-array result. User and developer impact Shared and per-image histories, white ROI calibration, pending previews, preview-coordinate radius scaling, original-resolution processing, duplicate export detection, project reopen, and outputs keep their existing behavior. Empty launch no longer chooses an output folder; importing sources retains the source-adjacent default. Developers can locate source decoding, calculations, durable annotations, and exports without learning one generic state package. Compatibility and migration The durable payload remains version 1 with identical fields and defaults, so existing projects require no migration. The Runtime's standard debug-startup message replaces the removed App-specific line. Validation The focused unit suite covers numerical tools, white ROI behavior, preview radius scaling, source reading, full-size exports, manifests, per-image steps, task fingerprints, and project/presenter contracts. The hidden GUI workflow covers file append and selection, independent histories, tool application, export, save/load, and transient cache reconstruction. Evidence [Image Enhance](../../../../apps/image-measurement/image-enhance/README.md) [Image Library](../../../../libraries/image/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Other Apps still using generic lifecycle/state packages will be reviewed individually. Automated GUI tests do not replace manual judgment of visual quality, ROI pointer feel, or enhancement suitability for quantitative images."},{"title":"Image Match adopts capability-owned structure","url":"history/records/2026/07/LK-20260716-image-match-structure.html","kind":"history","text":"LK-20260716-image-match-structure 2026-07-16 96 refactor compatible labkit_ImageMatch_app Image Match adopts capability-owned structure schema: 2 id: LK-20260716-image-match-structure date: 2026-07-16 sequence: 96 type: refactor compatibility: compatible component: `labkit_ImageMatch_app` | `1.6.2 -> 1.6.3` scope: Image Measurement scope: App structure Context Image Match split metadata and project lifecycle across eight structural files, used an App startup callback only for an output-folder default and debug line, and grouped unrelated source, analysis, and export structures under `+appState`. Session, actions, and presentation also read portable-reference path fields directly. Decision and rationale Adopt one definition, one project spec, and one session factory. Place each data constructor beside the capability that owns its meaning: loaded image items with source reading, match steps with analysis, and idempotent export tasks with result writing. Changes Consolidated command metadata, version, requirements, and optional capabilities in `definition.m`. Consolidated version-1 project creation and validation in `projectSpec.m`. Moved selected-image reconstruction to root `createSession.m`. Replaced `+appState` with capability-owned source, analysis, and result functions. Removed the metadata files, generic lifecycle package, and redundant startup callback. Replaced all portable-reference field reads with the Runtime path accessor. Corrected the GUI-free manual example to use the real `applyPipeline` argument order and cell output. User and developer impact Reference/source selection, lazy preview loading, match history, calculation, duplicate-export detection, project reopen, and export formats behave unchanged. A new project no longer writes an environment-specific output path during startup; source selection still establishes the same adjacent default. The directory now answers where an item, step, or export task belongs without requiring knowledge of a generic state layer. Compatibility and migration The durable payload remains version 1 with identical fields and validation. Existing Image Match projects require no migration. Validation Unit tests cover the definition/project/session contract, matching methods, pipeline replay, preview scaling, result tables, export formats, manifests, and task fingerprints. The hidden GUI suite covers reference/source loading, selection, history, preview, export, and save/load workflows. Evidence [Image Match](../../../../apps/image-measurement/image-match/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Other Image Measurement Apps retain older lifecycle or state packages and will be reviewed against their own capabilities."},{"title":"Image app workflow improvements","url":"history/records/2026/07/LK-20260701-image-app-workflow-improvements.html","kind":"history","text":"LK-20260701-image-app-workflow-improvements 2026-07-01 27 feat compatible labkit_launcher labkit.image labkit.ui labkit.ui labkit_BatchImageCrop_app labkit_BatchImageCrop_app labkit_FLIRThermal_app labkit_FLIRThermal_app labkit_ImageEnhance_app labkit_ImageMatch_app Image app workflow improvements schema: 2 id: LK-20260701-image-app-workflow-improvements date: 2026-07-01 sequence: 27 type: feat compatibility: compatible component: `labkit_launcher` | `1.1.5 -> 1.1.6` component: `labkit.image` | `1.0.0 -> 1.1.0` component: `labkit.ui` | `3.2.10 -> 3.3.0` component: `labkit.ui` | `3.3.0 -> 3.3.1` component: `labkit_BatchImageCrop_app` | `1.4.0 -> 1.5.0` component: `labkit_BatchImageCrop_app` | `1.5.0 -> 1.5.1` component: `labkit_FLIRThermal_app` | `1.0.0 -> 1.1.0` component: `labkit_FLIRThermal_app` | `1.1.0 -> 1.1.2` component: `labkit_ImageEnhance_app` | `1.4.0 -> 1.4.1` component: `labkit_ImageMatch_app` | `1.4.0 -> 1.4.1` Context Image apps used full-resolution data for several previews even when the screen could not display that detail. Controls also accepted ranges that were awkward for the current image, and FLIR point and ROI measurements were not yet a complete direct-manipulation workflow. Decision and rationale Set an explicit preview budget, derive sensible control bounds from the loaded image, and preserve full-resolution data for calculations and exports. Expand the FLIR app around point and ROI temperature tools rather than adding more separate analysis dialogs. Changes `labkit.image` `1.0.0 -> 1.1.0` `labkit.ui` `3.2.10 -> 3.3.1` Batch Crop `1.4.0 -> 1.5.1` FLIR Thermal `1.0.0 -> 1.1.2` `labkit_launcher` `1.1.5 -> 1.1.6` Added preview-budget helpers. Improved image app range and preview controls. Improved image measurement workflows. User and data impact Large images produced bounded previews while exports continued to use source resolution. Batch Crop controls reflected the crop and scale being edited, and FLIR users could place temperature points or regions directly on the image and read their values in the detail panel. Compatibility and migration Existing image inputs and app exports remained valid. Preview budgeting changed display resolution only; calculations and exports continued to use the source data required by each workflow. Validation The two commits expanded Batch Crop export tests, FLIR operation and layout tests, image-facade preview tests, busy-state tests, scale-bar gesture checks, and launcher coverage. Exact historical commands were not recorded. Evidence Main commits `15a798ba` and `70bfcfd4`. Known limitations and follow-up Preview budgeting improved responsiveness but did not by itself remove every eager image read. Batch Crop file selection was made lazy in the following profiling work. labkit.image.adjustBrightnessContrast Apply simple brightness and contrast adjustment. labkit.image.adjustHueSaturation Apply HSV hue and saturation adjustment. labkit.image.assertSupportedPaths Throw when any path has an unsupported image extension. labkit.image.displayName Return a short image-file display name. labkit.image.ensureRgb Return image data with exactly three color channels. labkit.image.fileDialogFilter Return a file-chooser-compatible image filter. labkit.image.grayWorldWhiteBalance Apply generic gray-world white balance. labkit.image.im2double Convert image data to double using MATLAB's im2double contract. labkit.image.isSupportedPath Return true when a path has a supported image extension. labkit.image.localContrast Enhance local value-channel contrast with a mean-filter mask. labkit.image.meanFilter2 Apply normalized 2-D mean filtering with edge correction. labkit.image.normalizePaths Normalize image file path inputs to a string column. labkit.image.previewBudget Downsample image data to fit a display pixel budget. labkit.image.readFiles Read image files into path/name/image records. labkit.image.resizeToFit Resize an image to fit within maximum row/column limits. labkit.image.rgb2gray Convert RGB data using MATLAB's rgb2gray call contract. labkit.image.sharpen Apply generic unsharp-mask sharpening. labkit.image.supportedExtensions Return extensions supported by LabKit image file inputs. labkit.image.version Return the LabKit image facade contract version. labkit.image.writeFile Write one image file, creating the parent folder when needed. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Interactive recovery of missing project sources","url":"history/records/2026/07/LK-20260716-runtime-source-relink.html","kind":"history","text":"LK-20260716-runtime-source-relink 2026-07-16 66 fix compatible labkit.ui Interactive recovery of missing project sources schema: 2 id: LK-20260716-runtime-source-relink date: 2026-07-16 sequence: 66 type: fix compatibility: compatible component: `labkit.ui` | `6.0.1 -> 6.0.2` scope: project loading scope: portable source references Context Saved projects could describe required external files with portable source records, but an unresolved path produced a framework error unless the owning app implemented the advanced `Project.RelinkSources` hook. Production apps using standard source records therefore had no common recovery interaction when a project moved between folders, drives, or operating systems. Decision and rationale Make missing-file recovery the default Runtime V2 behavior for canonical source records. The runtime already owns project loading, injected dialogs, and transactional state replacement, so it can provide one consistent prompt without duplicating native chooser code in every app. The advanced app hook remains available for nonstandard source schemas. Changes Report the saved filename and source role when automatic resolution fails. Let the user locate each missing required file with one native file chooser. Rebuild the selected source reference relative to the loaded project before constructing the app session. Mark a successfully repaired document as unsaved so the replacement path can be retained on the next save. Mark migrated payloads, snapshots, and declared legacy variables as unsaved; **Save State** then upgrades the opened MAT path to the current project format through the existing atomic replacement. Treat cancellation as an atomic load cancellation without changing the live project or view. User and data impact Projects whose external files remain in their recorded locations open exactly as before. When a required file moved, the user can locate it instead of seeing only an unresolved-source error. The runtime never searches arbitrary folders or silently substitutes a different file. Merely opening an old document does not rewrite it; saving the migrated document upgrades that same path. Compatibility and migration The change is compatible with current project envelopes, declared legacy imports, and existing custom `Project.RelinkSources` callbacks. Selecting a replacement updates only the candidate project; saving writes the current project format. Validation The hidden Runtime V2 project test covers successful default relinking, relative-reference rebuilding, dirty-document state, cancellation rollback, and the existing custom relink hook. Evidence [Runtime and lifecycle](../../../../framework/guides/runtime.md) documents the default missing-source interaction. Runtime source resolution reruns before fresh-session construction and state commit. Known limitations and follow-up The default flow asks once for each unresolved source. Apps with a specialized multi-file schema may continue to provide a custom relinking callback. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"LabKit name and the first multi-domain app families","url":"history/records/2026/05/LK-20260530-app-family-expansion.html","kind":"history","text":"LK-20260530-app-family-expansion 2026-05-30 3 feat compatible LabKit name and the first multi-domain app families schema: 2 id: LK-20260530-app-family-expansion date: 2026-05-30 sequence: 3 type: feat compatibility: compatible scope: historical project evolution Context By the end of the electrochem extraction, the repository had reusable DTA operations and several app entry points, but its naming and package layout still reflected the original Gamry workbench. It was not yet clear whether the design could serve workflows with different data, interaction, and visualization needs. Decision and rationale Rename the project LabKit and treat apps, rather than one scientific domain, as the main deliverables. Keep a limited set of reusable packages underneath them, then exercise that structure with image registration, region editing, curvature measurement, wearable data import, and ECG viewing. The purpose was not to turn LabKit into one analysis program. It was to make a common MATLAB foundation useful to several independent laboratory tools without erasing the workflow decisions that made each tool understandable. Changes Renamed the workbench namespace to `labkit` and reduced the public package surface to app-facing UI and domain operations. Standardized the basic workbench shell and DTA file panels across the electrochem apps. Added DIC preprocessing and postprocessing workflows with iterative crop, mask, registration, zoom, and preview interactions. Added the Curvature Measurement app and reused its anchor-curve editor across image workflows where the interaction was genuinely the same. Added a biosignal facade and the ECG Print app, including wearable CSV import and signal-column diagnostics. Added MATLAB CI and reorganized tests by source responsibility. User and data impact LabKit now served electrochem, image, and biosignal work from distinct app entry points. Image users gained direct manipulation of regions and anchors; biosignal users gained a viewer for imported wearable recordings. Existing DTA files remained external inputs and were not converted into a central project format. Compatibility and migration The namespace rename was an implementation migration for repository code. The new image and biosignal apps were additive. Scripts that referenced the former workbench namespace needed to adopt `labkit` or an app entry point. Validation Focused DIC interaction tests, biosignal import tests, layout tests, and the first MATLAB CI workflow were added during this stage. The exact historical command used for each commit was not recorded. Evidence Public-surface cleanup and LabKit rename `9bd8ec8f` through `179885e1`. Unified workbench and file panels `2de7dd0f` through `1be52b9d`. DIC family `aa96ae88` through `fd808a56`. Curvature Measurement `9c1f3798`, `8bcde252`. Biosignal and ECG work `d7c31369` through `b7cabd52`. Initial MATLAB CI `5102640c`. Known limitations and follow-up The new apps exposed interaction problems that ordinary callback tests did not capture well, including popout axes, anchor editing, busy-state reentry, and image zoom ownership. Those issues drove the managed interaction work that followed before v1.0."},{"title":"Launcher and maintainer tool manuals","url":"history/records/2026/07/LK-20260716-launcher-and-maintainer-manuals.html","kind":"history","text":"LK-20260716-launcher-and-maintainer-manuals 2026-07-16 63 docs additive labkit_launcher Launcher and maintainer tool manuals schema: 2 id: LK-20260716-launcher-and-maintainer-manuals date: 2026-07-16 sequence: 63 type: docs compatibility: additive component: `labkit_launcher` | documentation scope: `docs/apps/labkit-core/launcher/` scope: `docs/development/tools/` scope: `docs/development/maintain-and-release/testing.md` Context Launcher behavior was split between Getting Started, architecture notes, source comments, and historical records. Maintainer tools under `tools/` had callable MATLAB entry points but no coherent manual set; profiling details were embedded in the testing command matrix while packaging and codecheck behavior were mostly discoverable only from source. Decision and rationale Document the user-operated launcher beside LabKit Core apps, even though its self-repair implementation remains a root-level standalone file. Document source-checkout tools under Development because they support maintenance rather than app workflows. Keep the existing Testing page as the test-system owner, add a conceptual execution map there, and move profiler call details into the maintainer tool reference. This structure lets readers start from the product they operate or the maintenance task they intend, without mirroring every source directory or mixing tool APIs into reusable `labkit.*` function reference. Changes Added a LabKit Launcher manual covering window actions, programmatic calls, discovery, installation, recovery, tools, limitations, and development milestones. Added a maintainer-tool landing page and detailed references for Code Analyzer reports, app deployment packages, documentation builds, and performance profiling. Added a test-system execution map explaining build tasks, runner discovery, test layers, selectors, plugins, artifacts, and manual-validation boundaries. Connected Getting Started, Apps, LabKit Core, Development, Architecture, Documentation, Testing, Release, the root README, and generated navigation to the new manuals. User and data impact Users can now understand launcher controls and history from the Apps section. Maintainers can copy direct MATLAB calls and inspect every supported option and output without reading tool implementation files. No app workflow, scientific calculation, saved project, input file, or exported result changes. Compatibility and migration This is an additive documentation change. Existing page URLs remain valid. The new launcher and tool pages are generated from Markdown and included in search; no handwritten HTML or third-party documentation package is added. Validation Documentation contracts verify source ownership, generated launcher history, tool entry-point coverage, local links, search indexing, and byte-for-byte agreement between structured sources and the tracked site. Evidence `docs/apps/labkit-core/launcher/README.md` owns the launcher manual. `docs/development/tools/` owns direct maintainer-tool references. `ProjectSurfaceDocumentationTest` protects placement and generated content. Known limitations and follow-up Private app documentation remains in each private repository. Internal private helpers beneath tool folders are intentionally described through their public entry point rather than receiving standalone reference pages."},{"title":"Launcher app version history","url":"history/records/2026/07/LK-20260713-launcher-app-version-history.html","kind":"history","text":"LK-20260713-launcher-app-version-history 2026-07-13 53 feat additive labkit_launcher Launcher app version history schema: 2 id: LK-20260713-launcher-app-version-history date: 2026-07-13 sequence: 53 type: feat compatibility: additive component: `labkit_launcher` | `1.3.0 -> 1.4.0` Context The structured changelog could be parsed by maintainers, but launcher users could not inspect the history of the selected app. Earlier normalization also attached broad release descriptions without recording every app version event, which made histories such as CIC appear to begin years of versions too late. Decision and rationale Expose component-filtered history in the launcher and make exact component events the durable lookup key. A user should be able to move from the current app catalog directly to its evolution record without reading Git history or understanding changelog internals. Changes Added `labkit_launcher(\"history\", appCommand)` for programmatic history lookup and a Version History viewer for the selected launcher app. Added explicit component introduction events and reconstructed every tracked launcher, facade, and app version transition from `origin/main` history. Added continuous-history validation so missing introductions, gaps, and current-version mismatches fail the release guardrail. User and data impact Launcher users can inspect dated version transitions, compatibility, rationale, impact, and evidence for the selected app. App calculations and user data are unchanged. Compatibility and migration The launcher command and button are additive. Existing list, version, package, maintenance, and app-launch workflows remain available. Validation `LauncherGuiTest` covers programmatic filtering and the hidden GUI viewer; `ChangelogGuardrailTest` validates complete version chains against current metadata. These checks were included in the combined mainline change `4a6c41dd`. Evidence Component history, launcher viewer, and changelog validation were included in `4a6c41dd`. Historical component events were reconstructed from version-file changes on the then-current `origin/main` history. Known limitations and follow-up History begins when version metadata was first tracked for each component. Behavior before that point can only be inferred from older source commits and is not assigned invented version numbers."},{"title":"Launcher code-analysis export","url":"history/records/2026/07/LK-20260701-launcher-code-analysis-export.html","kind":"history","text":"LK-20260701-launcher-code-analysis-export 2026-07-01 29 feat compatible labkit_launcher Launcher code-analysis export schema: 2 id: LK-20260701-launcher-code-analysis-export date: 2026-07-01 sequence: 29 type: feat compatibility: compatible component: `labkit_launcher` | `1.1.6 -> 1.2.0` Context The launcher offered a Code Analyzer action, but its earlier implementation carried substantial custom export code. That made a maintenance tool harder to maintain than the MATLAB analysis it was exposing. Decision and rationale Use MATLAB's native issue representation and export path, and keep the launcher responsible only for choosing the target and destination. This reduced custom conversion logic while preserving a launcher-level entry point for the report. Changes `labkit_launcher` `1.1.6 -> 1.2.0` Exported launcher Code Analyzer issues natively. User and data impact The launcher could export Code Analyzer findings in a MATLAB-supported form without requiring a separate script. This action inspected source code only and did not start or modify an app project. Compatibility and migration Existing launcher actions remained available. Newly exported issue files used MATLAB's native representation rather than the removed custom conversion. Validation `LauncherGuiTest` gained coverage for the native issue-export path and the implementation in `8fd3ddff` removed more launcher code than it added. Evidence Main commit `8fd3ddff`. Known limitations and follow-up Code analysis remained a maintainer tool. It did not become a prerequisite for launching an app or a replacement for the repository's automated checks."},{"title":"Launcher manager and stale callback fix","url":"history/records/2026/06/LK-20260626-launcher-manager-and-stale-callback-fix.html","kind":"history","text":"LK-20260626-launcher-manager-and-stale-callback-fix 2026-06-26 13 fix compatible labkit_launcher labkit_launcher labkit.ui Launcher manager and stale callback fix schema: 2 id: LK-20260626-launcher-manager-and-stale-callback-fix date: 2026-06-26 sequence: 13 type: fix compatibility: compatible component: `labkit_launcher` | `1.0.0 -> 1.1.0` component: `labkit_launcher` | `1.1.0 -> 1.1.1` component: `labkit.ui` | `3.0.0 -> 3.0.1` Context The launcher could update LabKit but did not offer an explicit choice among a recent release, a tag, and the main branch. Separately, image drag tools could leave old callbacks attached after an interaction ended. Decision and rationale Add a managed version selector backed by an install manifest, and make image interaction cleanup restore or release every temporary callback. Both changes reduce hidden state that otherwise survives beyond the user's selected action. Changes `labkit_launcher` `1.0.0 -> 1.1.1` `labkit.ui` `3.0.0 -> 3.0.1` Added the launcher version manager and managed-manifest requirement. Released stale image drag callbacks. User and data impact Users could deliberately install a supported release or development revision. Ending an image drag no longer left the figure responding to an obsolete tool. Saved app data did not change. Compatibility and migration Existing launcher installations remained usable. Managed installations needed the launcher manifest for version replacement; image editors required no saved- data conversion after their stale callbacks were released. Validation The listed commits added launcher-manager/manifest coverage and regression checks for image callback cleanup. Exact historical commands were not recorded. Evidence Main commits `fe8654c9`, `ef89cf77`, and `3d23b7f1`. Known limitations and follow-up Version selection still depended on network access for remote revisions; already installed local versions remained usable offline. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Launcher reads the single App definition","url":"history/records/2026/07/LK-20260716-launcher-definition-metadata.html","kind":"history","text":"LK-20260716-launcher-definition-metadata 2026-07-16 78 refactor compatible labkit_launcher Launcher reads the single App definition schema: 2 id: LK-20260716-launcher-definition-metadata date: 2026-07-16 sequence: 78 type: refactor compatibility: compatible component: `labkit_launcher` | `1.5.0 -> 1.5.1` scope: App discovery scope: Product metadata ownership Context The launcher discovered entrypoints independently from App runtime creation, but obtained versions by parsing a separate package `version.m`. That kept the old metadata file alive even after Runtime V2 gained a single definition contract. Decision and rationale Keep discovery lightweight and self-contained while changing its source of truth. The launcher now reads `AppVersion` and `Updated` literals from the App's `definition.m`; it does not execute the definition or start the App. Changes Preferred `definition.m` product metadata during public and private App discovery. Added a synthetic private-App catalog test with no `version.m` file. Retained a documented internal fallback only for Apps awaiting migration. User and developer impact Launcher catalogs continue to show version and update dates. A migrated App does not need a second metadata file solely for discovery. Compatibility and migration The change is compatible. Existing Apps remain discoverable through the temporary fallback; migrated Apps use only their definition. The fallback is removed after the last App migration. Validation The launcher catalog test creates an isolated App definition, points private discovery at it, and verifies the returned version and date without a `version.m` file. Evidence [LabKit Launcher](../../../../apps/labkit-core/launcher/README.md) explains definition-backed discovery. [Runtime and Lifecycle](../../../../framework/guides/runtime.md) defines the App product metadata fields. Known limitations and follow-up P-code packaging metadata will be audited with the deployment workflow before the transitional parser fallback is removed."},{"title":"Launcher update reliability","url":"history/records/2026/07/LK-20260701-launcher-update-reliability.html","kind":"history","text":"LK-20260701-launcher-update-reliability 2026-07-01 25 fix compatible labkit_launcher labkit_launcher Launcher update reliability schema: 2 id: LK-20260701-launcher-update-reliability date: 2026-07-01 sequence: 25 type: fix compatibility: compatible component: `labkit_launcher` | `1.1.3 -> 1.1.4` component: `labkit_launcher` | `1.1.4 -> 1.1.5` Context The self-contained launcher could install a ZIP release, but the replacement path performed more filesystem work than necessary and duplicated logic for locating, unpacking, and replacing the workbench. Slow replacement made a successful update look stalled; duplicated recovery paths made failures harder to reason about. Decision and rationale Minimize the files touched during an update and give ZIP replacement one straight-line implementation. Preserve the existing installation until the download and unpack steps have produced a usable replacement. Changes `labkit_launcher` `1.1.3 -> 1.1.5` Sped up launcher zip updates. Simplified launcher zip replacement. User and data impact ZIP updates completed with less waiting and fewer intermediate operations. A failure before replacement left the existing checkout available, while a successful update continued to present the same launcher entry point. Compatibility and migration Existing managed installations remained updateable. The replacement algorithm changed internally and did not require users to reinstall a working checkout. Validation Both commits extended `LauncherGuiTest` around download, replacement, and recovery behavior. The exact historical commands were not recorded. Evidence Main commits `ebf86cf2` and `becf9391`. Known limitations and follow-up The updater still depended on the release artifact being trustworthy. Later release work added stronger tag-to-asset verification and update diagnostics."},{"title":"Legacy import and first app workbench","url":"history/records/2026/05/LK-20260528-initial-app-workbench-foundation.html","kind":"history","text":"LK-20260528-initial-app-workbench-foundation 2026-05-28 1 feat compatible Legacy import and first app workbench schema: 2 id: LK-20260528-initial-app-workbench-foundation date: 2026-05-28 sequence: 1 type: feat compatibility: compatible scope: historical project evolution Context LabKit began with a direct import of older MATLAB analysis and GUI code. The electrochem workflows mixed file parsing, calculations, session state, plots, and exports inside large entry scripts, and several root commands exposed the same legacy implementation through different paths. Decision and rationale Separate the imported code by responsibility before adding new features. Move Gamry parsing and pulse detection into reusable DTA operations; keep CIC, CSC, VT resistance, EIS, and chrono calculations with their workflows; then expose each workflow through one package-backed app command. Extract shared UI pieces only after the app behavior is visible in more than one workflow. Changes Imported the legacy MATLAB tree without rewriting its scientific formulas. Extracted chrono, EIS, and CV/CT DTA parsers plus pulse detection and named synthetic fixtures. Separated chrono alignment/export, CIC, CSC, VT resistance, and EIS result operations from their GUI assembly. Added named app entry points and package-backed runners, then removed the duplicate root legacy entry points and legacy GUI directory. Introduced the first shared two-pane/tabbed shells, file panels, plot controls, log panels, and axes helpers used by the electrochem apps. User and data impact Users gained named electrochem app commands instead of opening the old GUI scripts directly. Calculations and exported values were intended to remain unchanged while code ownership moved; laboratory files remained external inputs rather than being copied into a central LabKit database. Compatibility and migration Component/app version files did not exist yet. Old root GUI entry points were removed as package-backed app commands became the supported launch path. Validation The first regression evidence included named DTA fixtures, calculation tests, an optional GUI smoke runner, and GUI compatibility checks. A single standard test command had not yet been established. Evidence Initial import `5973bde0`. Parser and calculation extraction from `fc70f9b2` through `9f7c6e4e`. App entrypoint and package migration from `40f46561` through `4d58066c`. First shared app-shell work from `f05f4a35` through `61a8cb87`. Known limitations and follow-up This stage improved structure but still contained several broad shared UI helpers and session abstractions that were narrowed during the following DTA facade and app-boundary work."},{"title":"Legacy import preserves portable references without exposing their schema","url":"history/records/2026/07/LK-20260716-opaque-source-import.html","kind":"history","text":"LK-20260716-opaque-source-import 2026-07-16 102 feat compatible labkit.ui Legacy import preserves portable references without exposing their schema schema: 2 id: LK-20260716-opaque-source-import date: 2026-07-16 sequence: 102 type: feat compatibility: compatible component: `labkit.ui` | `7.4.2 -> 7.4.3` scope: App Framework scope: Project portability Context The GUI-free source-record factory accepted filepaths, but an App importing an older MAT variable could already receive a portable reference containing the only usable relative location. Recreating the record from its original path would discard that portability; preserving it required the importer to copy the Runtime's private nested reference schema. Decision and rationale Keep one public source factory and allow its source value to be either a normal filepath or an existing portable reference passed as one opaque struct. The Runtime validates and canonicalizes the latter. Apps still never construct or read nested reference fields. Changes Extended `labkit.ui.runtime.sourceRecord` with an opaque-reference overload. Added strict validation for the supported portable-reference schema, scalar text fields, filename, and usable relative or original path. Canonicalized imported references so unrelated legacy fields do not enter a current project. Documented the distinction between normal filepath creation and legacy reference preservation. User and developer impact Moved legacy projects retain their relative source fallback during upgrade. App importers no longer need to duplicate Runtime serialization fields or choose between architectural ownership and data portability. Compatibility and migration Existing filepath calls and injected source-record services are unchanged. The overload accepts only the current supported reference schema; malformed or newer references fail before they can enter a project. Validation Focused Runtime tests cover normal path creation, opaque-reference canonicalization, relative-path preservation, missing fields, unsupported schema versions, source path access, and the public package surface. Evidence [Runtime and Lifecycle](../../../../framework/guides/runtime.md) [App Framework](../../../../framework/README.md) [Architecture](../../../../development/build-apps/architecture.md) Known limitations and follow-up Opaque-reference import is intended for trusted project importers, not as a general App-defined serialization extension. Video Marker is the first legacy importer scheduled to consume the overload. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"MATLAB-compatible image conversion API","url":"history/records/2026/07/LK-20260713-matlab-compatible-image-conversion.html","kind":"history","text":"LK-20260713-matlab-compatible-image-conversion 2026-07-13 47 refactor breaking labkit.image labkit_DICPostprocess_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app MATLAB-compatible image conversion API schema: 2 id: LK-20260713-matlab-compatible-image-conversion date: 2026-07-13 sequence: 47 type: refactor compatibility: breaking component: `labkit.image` | `1.2.0 -> 2.0.0` component: `labkit_DICPostprocess_app` | `1.3.5 -> 1.3.6` component: `labkit_BatchImageCrop_app` | `1.6.7 -> 1.6.8` component: `labkit_CurvatureMeasurement_app` | `1.3.4 -> 1.3.5` component: `labkit_FocusStack_app` | `1.4.8 -> 1.4.9` component: `labkit_ImageEnhance_app` | `1.5.7 -> 1.5.8` component: `labkit_ImageMatch_app` | `1.5.7 -> 1.5.8` Context The `toDouble`, `toLuma`, and `toRgbDouble` names combined class conversion, channel shaping, and clipping in ways that differed from familiar MATLAB APIs. Decision and rationale Use MATLAB-compatible names and call contracts for replacement functions, and keep orthogonal RGB shaping explicit so users do not need to learn a composite LabKit normalization rule. Changes Added base-MATLAB `labkit.image.im2double` and `labkit.image.rgb2gray`. Kept channel shaping in `ensureRgb` and made clipping explicit at call sites. Removed the ambiguous conversion helpers and centralized Rec.601 ownership. User and data impact Base-MATLAB users receive familiar image conversion behavior without hidden toolbox requirements. Existing app image results retain their intended ranges and channel shapes through explicit pipelines. Compatibility and migration This is a breaking facade rename. External callers replace removed helper names with `im2double`, `rgb2gray`, and `ensureRgb` as separately needed. Validation Image facade, downstream app, toolbox-shadow, and base-MATLAB ownership tests cover class conversion, luma values, and representative workflows. Evidence The current API contracts are documented in the [Image Library](../../../../libraries/image/README.md). Commit `e3f71c2d` aligned the conversion functions with MATLAB call behavior. Known limitations and follow-up The compatibility layer intentionally covers the LabKit-used MATLAB contracts, not every Image Processing Toolbox function. labkit.image.adjustBrightnessContrast Apply simple brightness and contrast adjustment. labkit.image.adjustHueSaturation Apply HSV hue and saturation adjustment. labkit.image.assertSupportedPaths Throw when any path has an unsupported image extension. labkit.image.displayName Return a short image-file display name. labkit.image.ensureRgb Return image data with exactly three color channels. labkit.image.fileDialogFilter Return a file-chooser-compatible image filter. labkit.image.grayWorldWhiteBalance Apply generic gray-world white balance. labkit.image.im2double Convert image data to double using MATLAB's im2double contract. labkit.image.isSupportedPath Return true when a path has a supported image extension. labkit.image.localContrast Enhance local value-channel contrast with a mean-filter mask. labkit.image.meanFilter2 Apply normalized 2-D mean filtering with edge correction. labkit.image.normalizePaths Normalize image file path inputs to a string column. labkit.image.previewBudget Downsample image data to fit a display pixel budget. labkit.image.readFiles Read image files into path/name/image records. labkit.image.resizeToFit Resize an image to fit within maximum row/column limits. labkit.image.rgb2gray Convert RGB data using MATLAB's rgb2gray call contract. labkit.image.sharpen Apply generic unsharp-mask sharpening. labkit.image.supportedExtensions Return extensions supported by LabKit image file inputs. labkit.image.version Return the LabKit image facade contract version. labkit.image.writeFile Write one image file, creating the parent folder when needed."},{"title":"Managed image interactions and diagnostic tracing","url":"history/records/2026/06/LK-20260604-managed-image-interactions.html","kind":"history","text":"LK-20260604-managed-image-interactions 2026-06-04 4 feat compatible Managed image interactions and diagnostic tracing schema: 2 id: LK-20260604-managed-image-interactions date: 2026-06-04 sequence: 4 type: feat compatibility: compatible scope: historical project evolution Context Image and ECG apps had made the workbench broader, but they also revealed a class of GUI failures that did not appear in parser or calculation tests. An anchor editor could re-enter a callback, a scale bar could lose ownership, an axes popout could stop reflecting the source, and a long operation could leave controls in an ambiguous state. Decision and rationale Move repeatable interaction mechanics into the UI foundation while leaving image-specific geometry and workflow state in each app. Give long-running actions an explicit busy-state guard, make image axes and tools managed resources, and record callback progress in diagnostic traces that could be inspected after a failure. Changes Added axes popout support and corrected image aspect and menu refresh behavior. Added shared UI control helpers and a reusable ECG peak detector facade. Added the Focus Stack app with automatic and manual image selection. Improved anchor insertion, curvature controls, and scale-bar ownership. Added a busy-state guard to prevent overlapping actions. Introduced a managed image-axes runtime, layered UI facades, and app debug trace logging. Split large app entry points into app-owned private workflow helpers rather than widening the public framework API. User and data impact Users gained Focus Stack and more predictable image editing. Popout views, anchors, scale bars, and long operations provided clearer feedback and were less likely to leave an app in a broken intermediate state. Debug traces made it possible to distinguish a slow or failed callback from an unresponsive window. The changes affected UI state and presentation; they did not intentionally alter existing scientific results. Compatibility and migration Apps gradually moved from direct axes and interaction setup to the managed UI facades. App-private geometry helpers remained private, so the framework did not promise a public API for every interaction detail. Validation The period added public API option documentation, app-private helper tests, interaction regression coverage, and focused checks for UI layout ownership. No single historical validation command covers the complete stage. Evidence Axes popout and control helpers `1e9022c4` through `2136009f`. ECG detector and filtering work `e8450652` through `adb7dfbf`. Focus Stack `08518e91`, `cc4cdf92`. Busy guard `ac36bd54`. Image interaction and scale-bar work `f58b8559` through `5304fa20`. Managed axes, traces, and layered UI facades `27bdbdd8`, `e86e4ea3`, `da0663f1`, `c4cae633`. Known limitations and follow-up The interaction layer was still evolving and the test infrastructure mixed custom runners with newer MATLAB tests. The v1.0 stabilization pass addressed test entry points, package ownership, and remaining compatibility aliases."},{"title":"Managed scientific and conversion constants","url":"history/records/2026/07/LK-20260713-managed-calculation-constants.html","kind":"history","text":"LK-20260713-managed-calculation-constants 2026-07-13 48 refactor compatible labkit.dta labkit.rhs labkit.biosignal labkit_ChronoOverlay_app labkit_CSC_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app Managed scientific and conversion constants schema: 2 id: LK-20260713-managed-calculation-constants date: 2026-07-13 sequence: 48 type: refactor compatibility: compatible component: `labkit.dta` | `2.0.0 -> 2.0.1` component: `labkit.rhs` | `1.0.0 -> 1.0.1` component: `labkit.biosignal` | `1.0.0 -> 1.0.1` component: `labkit_ChronoOverlay_app` | `1.3.5 -> 1.3.6` component: `labkit_CSC_app` | `1.3.9 -> 1.3.10` component: `labkit_NerveResponseAnalysis_app` | `1.3.4 -> 1.3.5` component: `labkit_ResponseReviewStats_app` | `1.3.4 -> 1.3.5` Context Scientific coefficients, device-format gains, unit conversions, and numeric tolerances were sometimes left as unexplained literals or repeated across callers, making origin and change impact difficult to audit. Decision and rationale Give each semantic calculation constant one named owner and a nearby source or purpose comment, while exempting ordinary indices and explicit UI geometry that do not encode scientific meaning. Changes Named and documented Rec.601, sRGB/CIE, DTA/RHS gains, SI conversions, tolerances, and empirical policies across facades and apps. Centralized repeated CSC charge-density, CIC display-unit, and curvature tolerance contracts. Added repository-wide magic-number and rectangle-interaction guardrails. User and data impact Calculation results are preserved, but future changes now expose the constant's meaning and provenance instead of silently editing an unexplained literal. Compatibility and migration No user migration is required. Public function call contracts and output schemas are unchanged by this record. Validation `MagicNumberGovernanceTest`, rectangle governance, module compatibility tests, and affected app tests cover centralized ownership and preserved numeric behavior. Evidence Source comments use the `Constant:` marker and guardrail diagnostics name the unmanaged file and line. Commit `125338c0` introduced the calculation-constant checks and the corresponding source annotations. Known limitations and follow-up The scanner targets semantically suspicious precision and notation; review is still required for simple integers or short decimals whose meaning is hidden. labkit.biosignal.buildTemplate Build a representative segment template. labkit.biosignal.compareGroups Summarize groups and compute pairwise Welch comparisons. labkit.biosignal.cropSignal Return a signal clipped to a time range in seconds. labkit.biosignal.defaultEcgPeakOptions Return documented defaults for ECG/QRS peak detection. labkit.biosignal.detectEcgPeaks Detect ECG/QRS peaks as event anchors. labkit.biosignal.filterSignal Apply a zero-phase FFT-domain filter to a biosignal. labkit.biosignal.getChannel Return one signal from a recording by display name or index. labkit.biosignal.listChannels Return display names for all channels in a recording. labkit.biosignal.measureSegments Measure generic template-residual segment quality. labkit.biosignal.readRecording Read a biosignal file into a standard recording struct. labkit.biosignal.segmentByEvents Extract fixed windows around event anchors. labkit.biosignal.version Return the LabKit biosignal facade contract version. labkit.dta.detectPulses Locate cathodic and anodic pulse windows in chrono data. labkit.dta.detectType Identify the supported Gamry DTA data family in a file. labkit.dta.findFiles Find DTA files in a folder and its subfolders. labkit.dta.getColumn Extract a named column from a parsed DTA table. labkit.dta.getCurveXY Extract paired X and Y data from a parsed CV/CT curve. labkit.dta.getMainCurve Select the transient table containing T, Vf, and Im data. labkit.dta.getZCurve Select the impedance table from parsed EIS data. labkit.dta.loadFile Read one supported Gamry DTA file. labkit.dta.loadFiles Read a list of Gamry DTA files. labkit.dta.loadFolder Read every DTA file below a folder. labkit.dta.version Return version information for the DTA API. labkit.rhs.findFiles Find Intan RHS files in a folder and its subfolders. labkit.rhs.indexFile Build a block index for reading selected RHS time windows. labkit.rhs.inspectFile Read metadata and data-layout information from an RHS file. labkit.rhs.readWindow Read selected channels and times from an Intan RHS file. labkit.rhs.version Return version information for the RHS API."},{"title":"Migration helper cleanup","url":"history/records/2026/06/LK-20260630-migration-helper-cleanup.html","kind":"history","text":"LK-20260630-migration-helper-cleanup 2026-06-30 23 refactor compatible labkit.ui labkit_DICPostprocess_app labkit_BatchImageCrop_app labkit_BatchImageCrop_app labkit_ImageEnhance_app labkit_RHSPreview_app labkit_RHSPreview_app Migration helper cleanup schema: 2 id: LK-20260630-migration-helper-cleanup date: 2026-06-30 sequence: 23 type: refactor compatibility: compatible component: `labkit.ui` | `3.2.8 -> 3.2.9` component: `labkit_DICPostprocess_app` | `1.2.3 -> 1.2.4` component: `labkit_BatchImageCrop_app` | `1.3.7 -> 1.3.8` component: `labkit_BatchImageCrop_app` | `1.3.8 -> 1.3.9` component: `labkit_ImageEnhance_app` | `1.3.4 -> 1.3.5` component: `labkit_RHSPreview_app` | `1.2.2 -> 1.2.3` component: `labkit_RHSPreview_app` | `1.2.3 -> 1.2.4` scope: historical project evolution Context Several apps still split one small decision across multiple temporary helper files created during earlier package migrations. That made simple state and preview behavior harder to follow without creating a reusable API. Decision and rationale Consolidate related values behind one clearly named operation and delete pass-through helpers whose only purpose was reducing file length. Preserve the visible app behavior while making each calculation or state summary traceable from its caller. Changes DIC Post, Batch Crop, and RHS Preview patch bumped. Retired migration helper debt. Consolidated RHS preview window bounds, Batch Crop scale state, and Image Enhance export helpers. User and data impact No workflow or result format changed. The cleanup reduced internal indirection in Image Enhance export, Batch Crop scale summaries, RHS Preview window bounds, and a small set of DIC/electrochem helpers. Compatibility and migration The consolidated helpers preserved the app-facing state and result structures. No project or export format changed as the duplicate implementations were removed. Validation Commits `7f73b71b`, `e3349af6`, `733fb951`, `98a2b02c`, and `391540a7` added or updated focused tests for each consolidated operation. Evidence Main commits `7f73b71b`, `e3349af6`, `733fb951`, `98a2b02c`, and `391540a7`. Known limitations and follow-up This was a behavior-preserving cleanup. Later workflow-first packages replaced some of the package names shown in the historical commits. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Minimal App contract and current authoring manuals","url":"history/records/2026/07/LK-20260716-minimal-app-contract.html","kind":"history","text":"LK-20260716-minimal-app-contract 2026-07-16 115 docs clarification LabKit project Minimal App contract and current authoring manuals schema: 2 id: LK-20260716-minimal-app-contract date: 2026-07-16 sequence: 115 type: docs compatibility: clarification component: `LabKit project` | documentation scope: App authoring scope: Runtime V2 contract guardrails Context Runtime V2 already supplied default project, session, action, presenter, and startup capabilities, but the complete-App tutorial and several current manuals still taught the retired split metadata and generic lifecycle package shape. The App structure guardrail also prohibited those files only when an App already owned `projectSpec.m`, leaving static Apps unprotected. Decision and rationale Make progressive capability the documented and tested authoring contract. A static App consists of a thin entrypoint, one definition, and one data-only layout. Actions, presentation, durable project state, transient reconstruction, and startup are added independently only when the product needs them. Changes Rewrote the complete-App tutorial around the three-file starting point and one `projectSpec.m` create/validate/migrate entry. Updated architecture, framework, private-App, library, testing, release, and history manuals plus scoped App rules and the App-builder skill to use definition-owned metadata and progressive optional capabilities. Added a hidden-GUI launch test whose definition omits every optional Runtime V2 component. Corrected the tutorial's numeric field declaration after the real launch test exposed its implicit text-control mismatch. Made the App package guardrail reject retired metadata, lifecycle, state, startup, and per-version migration files for every App shape. User and developer impact App behavior and scientific results do not change. A developer can begin with three source files and add each larger architectural concept only when a real workflow requires it. Public and private App documentation now teach the same contract. Compatibility and migration This change documents and protects the already migrated Runtime V2 structure. It does not alter saved project payloads or remove the temporary low-level legacy project-migration reader used by framework tests. Validation Focused contract and GUI tests cover both the forbidden retired structures and an actual launch from the minimal definition. A documentation contract checks the tutorial, scoped App rules, and App-builder skill together. Documentation consistency checks and a regenerated site validate navigation and history metadata. Evidence [Build a Complete App](../../../../development/build-apps/complete-app.md) gives the progressive file-by-file tutorial. [Architecture](../../../../development/build-apps/architecture.md) defines the current package shape and ownership boundaries. [Runtime and Lifecycle](../../../../framework/guides/runtime.md) specifies every optional component and callback. Known limitations and follow-up The minimal contract proves framework defaults, not that every complex App has already reached its lowest maintainable complexity. App-by-App workflow and framework-boundary audits continue separately."},{"title":"Minimal App definitions","url":"history/records/2026/07/LK-20260716-minimal-app-definition.html","kind":"history","text":"LK-20260716-minimal-app-definition 2026-07-16 76 feat additive labkit.ui Minimal App definitions schema: 2 id: LK-20260716-minimal-app-definition date: 2026-07-16 sequence: 76 type: feat compatibility: additive component: `labkit.ui` | `7.0.0 -> 7.1.0` scope: App authoring scope: Runtime V2 definition defaults Context Runtime V2 already normalized missing project and session buckets, but every definition still had to provide project create/validate callbacks, at least one action, and a presenter. A static App therefore needed lifecycle files and placeholder functions that owned no behavior. Decision and rationale Make lifecycle components proportional to product behavior. Every App still declares a stable ID, title, and semantic layout. The framework supplies empty canonical state and no-op dynamic behavior until the App opts into durable data, transient cache, actions, or presentation. Changes Made `Project`, `Actions`, and `Present` optional in `labkit.ui.runtime.define`; `CreateSession` was already optional. Added a framework-owned version-1 empty project specification and empty presenter model. Allowed an empty action registry when no layout event or startup phase references an action. Added a GUI contract test that launches a definition containing only `Id`, `Title`, and `Layout` and verifies both canonical state roots. Rewrote App-development entry guidance around capability tiers instead of a fixed list of placeholder files. User and developer impact Existing Apps behave unchanged. A new static App does not need `createProject.m`, `createSession.m`, `validateProject.m`, `definitionActions.m`, or a presenter. Those components are introduced only when the App gains corresponding behavior. Compatibility and migration The change is additive within UI 7. Existing complete definitions remain valid. Saved project and source schemas do not change. Validation The minimal-definition GUI test exercises real launch, canonical project and session creation, layout construction, empty presentation, and readiness. Definition validation and public documentation guardrails cover both minimal and full definitions. Evidence [App Framework](../../../../framework/README.md) introduces the minimal definition. [Runtime and Lifecycle](../../../../framework/guides/runtime.md) lists required and optional definition components. [App Development](../../../../development/build-apps/app-development.md) maps files to capabilities instead of treating them as universal boilerplate. Known limitations and follow-up Apps that own a project schema still use explicit project specification. The next lifecycle cleanup consolidates each App's project factory, validator, and ordered migration steps behind one `projectSpec.m` entry point rather than scattered version-step files. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Multi-app launcher packages","url":"history/records/2026/07/LK-20260709-multi-app-launcher-packages.html","kind":"history","text":"LK-20260709-multi-app-launcher-packages 2026-07-09 42 feat compatible labkit_launcher Multi-app launcher packages schema: 2 id: LK-20260709-multi-app-launcher-packages date: 2026-07-09 sequence: 42 type: feat compatibility: compatible component: `labkit_launcher` | `1.2.7 -> 1.3.0` Context The deployment tool could package one app, but laboratory workflows often used a small related set. Creating one ZIP per app duplicated the shared LabKit runtime, while manually merging ZIP files risked inconsistent entry files and manifests. The launcher's selected row also could not double as a multi-select without making Open and Debug ambiguous. Decision and rationale Add a separate Package checkbox to each launcher row. Keep the ordinary row selection for Open and Debug, and let checked rows produce one bundle with one entry file per app and a manifest describing the complete package. Changes `labkit_launcher` `1.2.7 -> 1.3.0` Project deployment tooling, multi-app bundle support. Added an independent `Package` checkbox column to the launcher app table so users can choose multiple apps without changing the row selected for Open or Debug. `Package Checked` and `Checked P-code` now create one zip containing every checked app, one direct entry file per app, and a multi-app manifest. Kept single-app package names, result fields, and manifest schema compatible when only one app is supplied to `packageLabKitApp`. User and data impact Users could distribute a related group of apps in one source or P-code ZIP without including the rest of the workbench. Opening and debugging still acted on the highlighted app, so preparing a package did not change normal launcher navigation. Compatibility and migration Existing direct calls that package one app continue to produce the original single-app package contract. Validation The commit expanded deployment unit tests, launcher packaging GUI coverage, selection helpers, changed-file routing, and build-task efficiency checks. The exact historical command was not recorded. Evidence Mainline commit `8a23a52`. Known limitations and follow-up Bundles intentionally included selected apps and their LabKit dependencies, not tests, full documentation, or unrelated private workspaces. P-code packages still required a compatible MATLAB runtime."},{"title":"Nerve Response Analysis uses fixed source identities","url":"history/records/2026/07/LK-20260716-nerve-response-structure.html","kind":"history","text":"LK-20260716-nerve-response-structure 2026-07-16 107 refactor compatible labkit_NerveResponseAnalysis_app Nerve Response Analysis uses fixed source identities schema: 2 id: LK-20260716-nerve-response-structure date: 2026-07-16 sequence: 107 type: refactor compatibility: compatible component: `labkit_NerveResponseAnalysis_app` | `1.4.2 -> 1.4.3` scope: Neurophysiology scope: App structure Context Nerve Response Analysis split one project schema across generic lifecycle files and kept two additional lifecycle helpers to filter and replace source records by role. Product metadata remained in separate requirement/version functions, while session and presentation code read nested portable-reference fields directly. Decision and rationale Use `filterRecord` and `protocol` as the two stable App-owned source IDs and matching roles. Runtime V2 already supports semantic path lookup by ID and an injected upsert service, so role-filter wrappers duplicate framework behavior without adding scientific meaning. Keep event detection, recording analysis, CAP measurement, presentation, and result writing in their existing concrete workflow packages. Changes Consolidated command identity, display metadata, version, requirements, project, session, layout, actions, presenter, renderer, and debug capability through the single definition. Concentrated project creation, validation, and the version-1 source upgrade in `projectSpec` behind one `Migrate` callback. Moved parsed JSON and transient workflow reconstruction to root `createSession`. Replaced App-owned role filtering/replacement helpers with `sourcePaths` and the injected `upsertSource` service. Strengthened validation so the two source IDs are unique, supported, and match their declared roles. Removed the generic lifecycle package and separate requirement/version files. User and data impact Filter/protocol selection, run limits, event and train detection, CAP metrics, issue handling, previews, output-folder defaults, JSON export, manifest output, reset, and project reopening are unchanged. The App version advances to 1.4.3; durable payload version remains 2. Compatibility and migration Version-1 projects still combine their separate filter and protocol records into the canonical source collection. Runtime V2 invokes the single migration entry and owns iteration. Current version-2 files already written with the documented fixed IDs load without data transformation. Validation Focused unit tests cover migration, source identity validation, event trains, differential/common-mode calculations, CAP metrics, filter labels, and the legacy keep-column input. The hidden GUI workflow covers filter selection, analysis, counts/issues presentation, JSON and manifest export, project save, reset, reopen, and reanalysis. Evidence [Nerve Response Analysis](../../../../apps/neurophysiology/nerve-response-analysis/README.md) [RHS Preview](../../../../apps/neurophysiology/rhs-preview/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Automated tests do not establish that a selected protocol, stimulation source, or timing window is physiologically correct. RHS Preview still uses the older split lifecycle structure and requires its own independent convergence review."},{"title":"One project migration entry per App","url":"history/records/2026/07/LK-20260716-single-project-migration-entry.html","kind":"history","text":"LK-20260716-single-project-migration-entry 2026-07-16 81 feat additive labkit.ui One project migration entry per App schema: 2 id: LK-20260716-single-project-migration-entry date: 2026-07-16 sequence: 81 type: feat compatibility: additive component: `labkit.ui` | `7.2.1 -> 7.3.0` scope: Runtime V2 projects scope: App authoring Context Runtime V2 already applied project migrations sequentially, but each App had to expose a cell array containing every historical version-step function. Schema growth therefore increased files and wiring even though the framework owned the actual migration loop. Decision and rationale Give each persistent App one migration entry. `Migrate(project,fromVersion)` upgrades exactly one step; Runtime V2 supplies each missing version in order, validates every intermediate payload, and continues to the current version. Changes Added the scalar `Project.Migrate` callback. Kept per-step serialization validation, newer-version rejection, atomic load behavior, final project validation, session rebuild, and dirty upgrade state unchanged. Converted the framework's version-1-to-3 round-trip fixture to one callback with two version cases. Retained `Project.Migrations` only as a temporary bridge for existing Apps. User and developer impact Saved projects behave as before. App maintainers can keep create, validate, and every schema transition as local functions in one `projectSpec.m` instead of maintaining a growing migration-file registry. Compatibility and migration The contract is additive within UI 7. Existing Apps continue to load during family-sized migration. An App must not declare both migration forms. Validation The Runtime V2 project test opens a version-1 payload in a version-3 App, executes both steps through one callback, verifies final data and source relinking, saves the current payload version, and checks failure atomicity. Evidence [Runtime and Lifecycle](../../../../framework/guides/runtime.md) documents the project declaration and loop. [App Development](../../../../development/build-apps/app-development.md) describes the single project file. Known limitations and follow-up Existing Apps still need their lifecycle functions consolidated into `projectSpec.m`. The legacy migration-array bridge will be removed after the last family is migrated. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Output folder prompts","url":"history/records/2026/06/LK-20260630-output-folder-prompts.html","kind":"history","text":"LK-20260630-output-folder-prompts 2026-06-30 20 feat compatible labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_BatchImageCrop_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app Output folder prompts schema: 2 id: LK-20260630-output-folder-prompts date: 2026-06-30 sequence: 20 type: feat compatibility: compatible component: `labkit.ui` | `3.2.5 -> 3.2.6` component: `labkit_DICPostprocess_app` | `1.2.0 -> 1.2.1` component: `labkit_DICPreprocess_app` | `1.2.0 -> 1.2.1` component: `labkit_BatchImageCrop_app` | `1.3.3 -> 1.3.4` component: `labkit_FocusStack_app` | `1.2.1 -> 1.2.2` component: `labkit_ImageEnhance_app` | `1.3.1 -> 1.3.2` component: `labkit_ImageMatch_app` | `1.3.1 -> 1.3.2` component: `labkit_NerveResponseAnalysis_app` | `1.2.1 -> 1.2.3` component: `labkit_ResponseReviewStats_app` | `1.2.1 -> 1.2.2` Context Exporting from different apps opened `uigetdir` directly, chose starting folders differently, and could not substitute a noninteractive chooser in a test. The repeated dialog code also made cancellation handling inconsistent. Decision and rationale Provide one output-folder prompt that selects a safe default, returns an explicit cancellation flag, remembers a successful folder, and accepts an injected chooser for tests. Apps would still decide when an output folder was needed and what they wrote there. Changes `labkit.ui` `3.2.5 -> 3.2.6` DIC apps, Batch Crop, Focus Stack, Image Enhance/Match, Nerve Response, and Response Review patch bumped. Added `promptOutputFolder`. Migrated output-folder prompts with chooser injection and safe defaults. User and data impact Output dialogs began in a useful folder and cancellation returned cleanly to the app. The selected folder was remembered as a preference; no output was created until the owning app performed its export. Compatibility and migration Existing output locations and exported file formats remained valid. Apps moved from direct folder dialogs to the shared prompt without changing what they wrote after selection. Validation Commit `c5055b98` added `AppHookHelpersTest` coverage for defaults, cancellation, successful selection, and chooser injection, then updated the affected app compatibility checks. Evidence Main commit `c5055b98`. Known limitations and follow-up Runtime V2 later moved dialog access into injected services, preserving the same separation between app decisions and platform dialog mechanics. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Preview-area per-axis wheel zoom","url":"history/records/2026/07/LK-20260709-preview-area-per-axis-wheel-zoom.html","kind":"history","text":"LK-20260709-preview-area-per-axis-wheel-zoom 2026-07-09 44 feat compatible labkit.ui Preview-area per-axis wheel zoom schema: 2 id: LK-20260709-preview-area-per-axis-wheel-zoom date: 2026-07-09 sequence: 44 type: feat compatibility: compatible component: `labkit.ui` | `5.0.3 -> 5.0.4` Context The preview area applied the same two-dimensional wheel zoom to every registered axes. That was appropriate for an image or main plot, but awkward for a narrow histogram or color-scale axes: horizontal zoom changed a dimension whose layout was meant to remain fixed. Decision and rationale Let a preview-area definition choose `xy`, `x`, or `y` wheel zoom for each axes. Preserve `xy` as the default so existing apps keep their behavior, while side axes can opt into only the dimension that carries data. Changes `labkit.ui` `5.0.3 -> 5.0.4` Added a `scrollZoomAxes` preview-area layout option so apps can declare whether each preview axis should mouse-wheel zoom in `xy`, `x`, or `y`. Preview-area side axes can now remain horizontally stable while still allowing app-selected vertical wheel zoom. User and data impact A histogram or temperature scale could remain horizontally stable while still supporting vertical exploration. Main images and plots continued to zoom in both dimensions unless their app explicitly chose another mode. Compatibility and migration Existing preview areas keep default `xy` wheel zoom unless they opt into another per-axis setting. Validation The commit extended UI layout unit tests and axes-workbench GUI coverage for the default and per-axis modes. The exact historical command was not recorded. Evidence Mainline commit `3c143eb`. Known limitations and follow-up The option controlled wheel navigation only. Toolbar zoom, pan, linked axes, and app-specific limit policies remained separate concerns. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Profiling and validation speedups","url":"history/records/2026/07/LK-20260702-profiling-and-validation-speedups.html","kind":"history","text":"LK-20260702-profiling-and-validation-speedups 2026-07-02 30 ci compatible labkit_launcher labkit_launcher labkit.ui labkit.ui labkit_BatchImageCrop_app labkit_ECGPrint_app Profiling and validation speedups schema: 2 id: LK-20260702-profiling-and-validation-speedups date: 2026-07-02 sequence: 30 type: ci compatibility: compatible component: `labkit_launcher` | `1.2.0 -> 1.2.1` component: `labkit_launcher` | `1.2.1 -> 1.2.2` component: `labkit.ui` | `3.4.0 -> 3.4.1` component: `labkit.ui` | `3.4.1 -> 3.4.2` component: `labkit_BatchImageCrop_app` | `1.6.0 -> 1.6.1` component: `labkit_ECGPrint_app` | `1.3.0 -> 1.3.1` Context Startup and file-selection complaints could not be resolved reliably from wall clock impressions alone. At the same time, focused changes paid avoidable test discovery cost, GUI tests waited longer than the behavior required, and Batch Crop read every selected image before the user requested a preview or export. Decision and rationale Add a profiler that records launcher, startup, callback, and close costs with source attribution. Use its evidence to remove repeated UI updates and eager image reads. Route changed-file validation to the smallest owning suites and bound GUI waits without weakening their behavioral assertions. Changes `labkit_launcher` `1.2.0 -> 1.2.2` `labkit.ui` `3.4.0 -> 3.4.2` `labkit_BatchImageCrop_app` `1.6.0 -> 1.6.1` `labkit_ECGPrint_app` `1.3.0 -> 1.3.1` Added LabKit profiling and build-managed test routing to the launcher. Reduced GUI profiling overhead and deferred Batch Crop image reads until preview/export. Compressed validation runtime with bounded GUI waits. User and data impact Maintainers could launch a profiled target and receive a summarized report instead of interpreting MATLAB's raw profile table. Selecting many Batch Crop files returned sooner because images were decoded only for the current preview or final export. Routine validation also completed with less discovery and wait overhead. Compatibility and migration Profiling and test routing were maintainer tools. Batch Crop kept its task and export schemas; selected images were simply decoded later in the workflow. Validation The profiling tool received a dedicated unit suite. Later commits added launcher profiler coverage, deferred-read tests, debug-trace mirroring checks, routing guardrails, and bounded GUI-wait tests. Commit `25912c54` records successful `buildtool changed`, integration validation, and remote MATLAB tests. Evidence Main commits `c07dfc0a`, `74025fee`, `eadcca82`, `25912c54`, and `fcfc36d8`. Known limitations and follow-up These changes reduced measured work but did not eliminate the white window shown before all startup setup completed. The following startup pass changed when windows were painted and when scroll navigation was installed. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Project restore distinguishes missing sources from damaged sources","url":"history/records/2026/07/LK-20260717-strict-project-session-restore.html","kind":"history","text":"LK-20260717-strict-project-session-restore 2026-07-17 125 fix compatible labkit.ui labkit_FigureStudio_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageMatch_app labkit_ImageEnhance_app labkit_NerveResponseAnalysis_app Project restore distinguishes missing sources from damaged sources schema: 2 id: LK-20260717-strict-project-session-restore date: 2026-07-17 sequence: 125 type: fix compatibility: compatible component: `labkit.ui` | `7.4.6 -> 7.4.7` component: `labkit_FigureStudio_app` | `0.2.8 -> 0.2.9` component: `labkit_FLIRThermal_app` | `1.4.6 -> 1.4.7` component: `labkit_FocusStack_app` | `1.5.5 -> 1.5.6` component: `labkit_ImageMatch_app` | `1.6.6 -> 1.6.7` component: `labkit_ImageEnhance_app` | `1.6.6 -> 1.6.7` component: `labkit_NerveResponseAnalysis_app` | `1.4.6 -> 1.4.7` scope: Runtime project restore scope: App session reconstruction Context Runtime already resolved missing required source paths and offered interactive relinking before it rebuilt an App session. Several session factories then caught every decoder exception and returned an empty cache. A file that still existed but was damaged, unsupported, or exposed a programming error could therefore look like an unresolved source or an empty project. Decision and rationale Keep path resolution and cancellation in Runtime, and make reconstruction of an existing source strict. A session factory may accept an explicitly absent optional source, but it must not reinterpret an exception from an existing file. Runtime wraps reconstruction failures with `inputs.sources` IDs, roles, and filenames, records the exception in diagnostics, and preserves the prior state and presentation. Changes Removed broad exception recovery from Figure Studio, Focus Stack, Image Match, Image Enhance, FLIR Thermal, and Nerve Response Analysis session reconstruction. Made FLIR restore use strict thermal decoding while retaining skip-and-report behavior for interactive batch import. Added Runtime diagnostic context and a field-specific `ProjectSessionRestoreFailed` error without replacing the decoder cause. Added atomic corrupt-source, diagnostic-delivery, cross-App factory, and no-broad-catch regression coverage. User and developer impact Missing required files still open the relink workflow, and cancelling it leaves the current project untouched. Existing corrupt or unsupported files now stop the load with the relevant project source identities and filenames instead of showing an empty preview. The prior document remains usable. App developers can leave ordinary decoder failures uncaught in `createSession.m`; Runtime owns the user-visible load failure and diagnostic record. Compatibility and migration No saved-project migration is required. Project envelopes and source-reference formats are unchanged. This change only corrects failure handling while reconstructing transient state. Validation Focused validation covers successful project round trips, missing-source relink, cancellation rollback, existing corrupt source rollback, diagnostic delivery, all affected session factories, and the repository guardrail that forbids broad catches in `createSession.m`. Evidence [Runtime and lifecycle](../../../../framework/guides/runtime.md#session-actions-presentation-and-renderers) [Figure Studio](../../../../apps/labkit-core/figure-studio/README.md) [FLIR Thermal](../../../../apps/image-measurement/flir-thermal/README.md) [Focus Stack](../../../../apps/image-measurement/focus-stack/README.md) [Image Match](../../../../apps/image-measurement/image-match/README.md) [Image Enhance](../../../../apps/image-measurement/image-enhance/README.md) [Nerve Response Analysis](../../../../apps/neurophysiology/nerve-response-analysis/README.md) Known limitations and follow-up Automated hidden GUI tests verify state rollback and semantic error delivery; they do not replace manual review of native dialog wording or damaged third-party file variants. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Protected image enhancement workflows","url":"history/records/2026/06/LK-20260629-protected-image-enhancement-workflows.html","kind":"history","text":"LK-20260629-protected-image-enhancement-workflows 2026-06-29 16 feat compatible labkit_ImageEnhance_app labkit_ImageMatch_app Protected image enhancement workflows schema: 2 id: LK-20260629-protected-image-enhancement-workflows date: 2026-06-29 sequence: 16 type: feat compatibility: compatible component: `labkit_ImageEnhance_app` | `1.2.2 -> 1.3.0` component: `labkit_ImageMatch_app` | `1.2.1 -> 1.3.0` scope: historical project evolution Context The existing appearance adjustments could change a subject together with its background. Image Enhance and Image Match needed modes that protected subject detail while correcting bright backgrounds and tone. Decision and rationale Add subject-preserving enhancement and protected tone matching as explicit methods with their own parameters, descriptions, and tests. Keep these methods inside the image apps because they encode workflow-specific decisions rather than generic image primitives. Changes Image Enhance `1.2.2 -> 1.3.0` Image Match `1.2.1 -> 1.3.0` Added Subject-preserving enhance to Image Enhance. Added Protected tone to Image Match. Added calculation and GUI coverage for both methods. User and data impact Users could brighten or normalize a background with less change to the main subject. The new methods were optional; existing histories and other matching methods retained their previous behavior. Compatibility and migration Existing enhancement steps and Image Match inputs remained valid. The new protected methods were additional choices and did not rewrite saved source images. Validation Commit `1768dd57` added focused Image Enhance and Image Match unit tests and updated both GUI workflow tests. Evidence Main commit `1768dd57`. Known limitations and follow-up The protected methods reduce unwanted subject changes but cannot infer semantic foreground perfectly. Their result still requires visual review."},{"title":"Public API help is complete, discoverable, and behavior checked","url":"history/records/2026/07/LK-20260717-complete-public-api-help-contract.html","kind":"history","text":"LK-20260717-complete-public-api-help-contract 2026-07-17 127 docs compatible labkit.biosignal labkit.dta labkit.rhs labkit.thermal labkit.image labkit.ui labkit_DICPreprocess_app labkit_DICPostprocess_app labkit_CSC_app labkit_BatchImageCrop_app labkit_FLIRThermal_app labkit_ImageMatch_app labkit_VideoMarker_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app Public API help is complete, discoverable, and behavior checked schema: 2 id: LK-20260717-complete-public-api-help-contract date: 2026-07-17 sequence: 127 type: docs compatibility: compatible component: `labkit.biosignal` | `1.0.2 -> 1.0.3` component: `labkit.dta` | `2.0.2 -> 2.0.3` component: `labkit.rhs` | `1.0.2 -> 1.0.3` component: `labkit.thermal` | `1.1.1 -> 1.1.2` component: `labkit.image` | `2.0.1 -> 2.0.2` component: `labkit.ui` | `7.5.0 -> 7.5.1` component: `labkit_DICPreprocess_app` | `1.5.7 -> 1.5.8` component: `labkit_DICPostprocess_app` | `1.4.6 -> 1.4.7` component: `labkit_CSC_app` | `1.4.7 -> 1.4.8` component: `labkit_BatchImageCrop_app` | `1.7.6 -> 1.7.7` component: `labkit_FLIRThermal_app` | `1.4.7 -> 1.4.8` component: `labkit_ImageMatch_app` | `1.6.7 -> 1.6.8` component: `labkit_VideoMarker_app` | `1.5.6 -> 1.5.7` component: `labkit_NerveResponseAnalysis_app` | `1.4.7 -> 1.4.8` component: `labkit_ResponseReviewStats_app` | `1.4.6 -> 1.4.7` scope: Public MATLAB help scope: Generated API reference scope: Documentation contract tests Context LabKit's generated function pages came from MATLAB help, but the contract test covered a hand-maintained subset of packages and only checked broad section presence. A public function, implemented option, default, legal value, failure mode, or related API could therefore be omitted while the documentation suite still passed. Several App calculation pages exposed exactly those gaps. Decision and rationale Treat the callable public surface as discoverable source truth. The contract now scans every non-private function below `+labkit` plus each App API declared in the API catalog. It derives standard option reads from implementation code and requires those fields to have documented values and defaults, while required options remain explicitly distinguishable from optional ones. Every public function also states failure behavior and a `See also` relationship. This keeps a generated page useful for standalone MATLAB callers instead of merely repeating a short summary. Changes Completed help for the Contract, Biosignal, DTA, RHS, Thermal, Image, UI, and cataloged App calculation surfaces. Added exact option/default/legal-value checks, package-wide discovery, failure-behavior checks, and related-API checks to the documentation contract. Added a synthetic regression proving the guard detects an undocumented option, an omitted default, a vague value domain, missing failure behavior, and a missing related-API link. Made `labkit.image.resizeToFit` reject unsupported interpolation method names instead of silently treating them as bilinear. Linked owning library and App manuals to the detailed generated reference. User and developer impact Readers can now determine valid calls, defaults, option domains, outputs, empty-result behavior, errors, and adjacent functions from the generated page for a public symbol. Developers adding a public function or a standard option read receive a focused contract failure when the corresponding help is incomplete. Compatibility and migration No saved project or data migration is required. Existing valid calls are unchanged. A call to `labkit.image.resizeToFit` with a method other than `\"bilinear\"` or `\"nearest\"` now raises `labkit:image:InvalidResizeMethod`; such values were never part of the documented contract. Validation The exact `PublicApiDocumentationContractTest` suite passes all eight tests, including execution of cataloged examples and the new synthetic defect regression. Image facade unit tests cover both supported resize methods and the unsupported-method error. Generated-site consistency and changed-scope validation are run after this record and its component manuals are rendered. Evidence [Public API index](../../../../reference/README.md) [LabKit App Framework](../../../../framework/README.md) [App catalog](../../../../apps/README.md) [Documentation maintenance](../../../../development/maintain-and-release/documentation.md) Known limitations and follow-up Implementation-derived option discovery intentionally recognizes the repository's standard option access patterns; unusual dynamic field access still requires review. Private helpers remain outside the public-page contract, and interactive `Typical Call:` sketches are not executed as file-independent examples. labkit.biosignal.buildTemplate Build a representative segment template. labkit.biosignal.compareGroups Summarize groups and compute pairwise Welch comparisons. labkit.biosignal.cropSignal Return a signal clipped to a time range in seconds. labkit.biosignal.defaultEcgPeakOptions Return documented defaults for ECG/QRS peak detection. labkit.biosignal.detectEcgPeaks Detect ECG/QRS peaks as event anchors. labkit.biosignal.filterSignal Apply a zero-phase FFT-domain filter to a biosignal. labkit.biosignal.getChannel Return one signal from a recording by display name or index. labkit.biosignal.listChannels Return display names for all channels in a recording. labkit.biosignal.measureSegments Measure generic template-residual segment quality. labkit.biosignal.readRecording Read a biosignal file into a standard recording struct. labkit.biosignal.segmentByEvents Extract fixed windows around event anchors. labkit.biosignal.version Return the LabKit biosignal facade contract version. labkit.dta.detectPulses Locate cathodic and anodic pulse windows in chrono data. labkit.dta.detectType Identify the supported Gamry DTA data family in a file. labkit.dta.findFiles Find DTA files in a folder and its subfolders. labkit.dta.getColumn Extract a named column from a parsed DTA table. labkit.dta.getCurveXY Extract paired X and Y data from a parsed CV/CT curve. labkit.dta.getMainCurve Select the transient table containing T, Vf, and Im data. labkit.dta.getZCurve Select the impedance table from parsed EIS data. labkit.dta.loadFile Read one supported Gamry DTA file. labkit.dta.loadFiles Read a list of Gamry DTA files. labkit.dta.loadFolder Read every DTA file below a folder. labkit.dta.version Return version information for the DTA API. labkit.image.adjustBrightnessContrast Apply simple brightness and contrast adjustment. labkit.image.adjustHueSaturation Apply HSV hue and saturation adjustment. labkit.image.assertSupportedPaths Throw when any path has an unsupported image extension. labkit.image.displayName Return a short image-file display name. labkit.image.ensureRgb Return image data with exactly three color channels. labkit.image.fileDialogFilter Return a file-chooser-compatible image filter. labkit.image.grayWorldWhiteBalance Apply generic gray-world white balance. labkit.image.im2double Convert image data to double using MATLAB's im2double contract. labkit.image.isSupportedPath Return true when a path has a supported image extension. labkit.image.localContrast Enhance local value-channel contrast with a mean-filter mask. labkit.image.meanFilter2 Apply normalized 2-D mean filtering with edge correction. labkit.image.normalizePaths Normalize image file path inputs to a string column. labkit.image.previewBudget Downsample image data to fit a display pixel budget. labkit.image.readFiles Read image files into path/name/image records. labkit.image.resizeToFit Resize an image to fit within maximum row/column limits. labkit.image.rgb2gray Convert RGB data using MATLAB's rgb2gray call contract. labkit.image.sharpen Apply generic unsharp-mask sharpening. labkit.image.supportedExtensions Return extensions supported by LabKit image file inputs. labkit.image.version Return the LabKit image facade contract version. labkit.image.writeFile Write one image file, creating the parent folder when needed. labkit.rhs.findFiles Find Intan RHS files in a folder and its subfolders. labkit.rhs.indexFile Build a block index for reading selected RHS time windows. labkit.rhs.inspectFile Read metadata and data-layout information from an RHS file. labkit.rhs.readWindow Read selected channels and times from an Intan RHS file. labkit.rhs.version Return version information for the RHS API. labkit.thermal.fileDialogFilter Create a file-selection filter for thermal images. labkit.thermal.inspectFile Check whether a file contains supported radiometric data. labkit.thermal.isSupportedPath Test whether a path has a supported thermal extension. labkit.thermal.rawToTemperatureC Convert FLIR raw sensor values to degrees Celsius. labkit.thermal.readFile Read radiometric image data and convert it to degrees Celsius. labkit.thermal.readFiles Read several radiometric image files. labkit.thermal.renderImage Map a thermal matrix to an RGB image. labkit.thermal.supportedExtensions List the supported thermal filename extensions. labkit.thermal.version Return version information for the thermal API. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Public library help contracts and reference validation","url":"history/records/2026/07/LK-20260716-public-api-help-contracts.html","kind":"history","text":"LK-20260716-public-api-help-contracts 2026-07-16 62 docs compatible labkit.biosignal labkit.dta labkit.image labkit.rhs labkit.thermal Public library help contracts and reference validation schema: 2 id: LK-20260716-public-api-help-contracts date: 2026-07-16 sequence: 62 type: docs compatibility: compatible component: `labkit.biosignal` | `1.0.1 -> 1.0.2` component: `labkit.dta` | `2.0.1 -> 2.0.2` component: `labkit.image` | `2.0.0 -> 2.0.1` component: `labkit.rhs` | `1.0.1 -> 1.0.2` component: `labkit.thermal` | `1.1.0 -> 1.1.1` scope: public MATLAB help scope: generated API reference scope: documentation contract tests Context The public library reference identified functions and basic syntax but did not consistently explain option values, units, returned structures, failure behavior, or the distinction between executable examples and calls that need a user-supplied file. Readers still had to inspect implementation files to use several GUI-free APIs correctly. Decision and rationale Treat each public MATLAB help block as the source-level call contract and render that contract into one API page per function. Detailed library validation is kept separate from project-navigation validation so both remain focused and within the repository's effective-code-line budget. The patch versions record a compatible documentation-contract improvement. Function signatures, calculations, returned data, and error identifiers are unchanged. Changes Expanded public help with syntax, inputs, outputs, option names and legal values, defaults, units, errors, examples or typical calls, and related APIs. Distinguished self-contained `Example:` sections from interactive or file-dependent `Typical Call:` sections. Added generated-reference checks for thermal conversion and rendering, DTA schemas and pulse modes, and RHS lazy reads and waveform units. Corrected stale biosignal documentation links in package guidance and an implementation contract. Made label-ownership and package-boundary scans ignore prose-only substring collisions while continuing to reject literals and forbidden MATLAB tokens in executable code. User and data impact Users can discover the supported call contract through both `help` and the generated HTML reference without launching an app. No file format, saved project, calculation, or exported value changes. Compatibility and migration This is a patch-level compatible change. Existing calls require no migration. Validation Documentation consistency checks validate generated sections and executable examples. Project guardrails validate version/history ownership, literal ownership, package boundaries, and effective code-line budgets. Evidence Public help blocks and generated API pages are the maintained contract. Known limitations and follow-up Interactive calls and calls requiring laboratory files remain `Typical Call:` sketches until the repository provides synthetic, non-sensitive fixtures that make them executable examples. labkit.biosignal.buildTemplate Build a representative segment template. labkit.biosignal.compareGroups Summarize groups and compute pairwise Welch comparisons. labkit.biosignal.cropSignal Return a signal clipped to a time range in seconds. labkit.biosignal.defaultEcgPeakOptions Return documented defaults for ECG/QRS peak detection. labkit.biosignal.detectEcgPeaks Detect ECG/QRS peaks as event anchors. labkit.biosignal.filterSignal Apply a zero-phase FFT-domain filter to a biosignal. labkit.biosignal.getChannel Return one signal from a recording by display name or index. labkit.biosignal.listChannels Return display names for all channels in a recording. labkit.biosignal.measureSegments Measure generic template-residual segment quality. labkit.biosignal.readRecording Read a biosignal file into a standard recording struct. labkit.biosignal.segmentByEvents Extract fixed windows around event anchors. labkit.biosignal.version Return the LabKit biosignal facade contract version. labkit.dta.detectPulses Locate cathodic and anodic pulse windows in chrono data. labkit.dta.detectType Identify the supported Gamry DTA data family in a file. labkit.dta.findFiles Find DTA files in a folder and its subfolders. labkit.dta.getColumn Extract a named column from a parsed DTA table. labkit.dta.getCurveXY Extract paired X and Y data from a parsed CV/CT curve. labkit.dta.getMainCurve Select the transient table containing T, Vf, and Im data. labkit.dta.getZCurve Select the impedance table from parsed EIS data. labkit.dta.loadFile Read one supported Gamry DTA file. labkit.dta.loadFiles Read a list of Gamry DTA files. labkit.dta.loadFolder Read every DTA file below a folder. labkit.dta.version Return version information for the DTA API. labkit.image.adjustBrightnessContrast Apply simple brightness and contrast adjustment. labkit.image.adjustHueSaturation Apply HSV hue and saturation adjustment. labkit.image.assertSupportedPaths Throw when any path has an unsupported image extension. labkit.image.displayName Return a short image-file display name. labkit.image.ensureRgb Return image data with exactly three color channels. labkit.image.fileDialogFilter Return a file-chooser-compatible image filter. labkit.image.grayWorldWhiteBalance Apply generic gray-world white balance. labkit.image.im2double Convert image data to double using MATLAB's im2double contract. labkit.image.isSupportedPath Return true when a path has a supported image extension. labkit.image.localContrast Enhance local value-channel contrast with a mean-filter mask. labkit.image.meanFilter2 Apply normalized 2-D mean filtering with edge correction. labkit.image.normalizePaths Normalize image file path inputs to a string column. labkit.image.previewBudget Downsample image data to fit a display pixel budget. labkit.image.readFiles Read image files into path/name/image records. labkit.image.resizeToFit Resize an image to fit within maximum row/column limits. labkit.image.rgb2gray Convert RGB data using MATLAB's rgb2gray call contract. labkit.image.sharpen Apply generic unsharp-mask sharpening. labkit.image.supportedExtensions Return extensions supported by LabKit image file inputs. labkit.image.version Return the LabKit image facade contract version. labkit.image.writeFile Write one image file, creating the parent folder when needed. labkit.rhs.findFiles Find Intan RHS files in a folder and its subfolders. labkit.rhs.indexFile Build a block index for reading selected RHS time windows. labkit.rhs.inspectFile Read metadata and data-layout information from an RHS file. labkit.rhs.readWindow Read selected channels and times from an Intan RHS file. labkit.rhs.version Return version information for the RHS API. labkit.thermal.fileDialogFilter Create a file-selection filter for thermal images. labkit.thermal.inspectFile Check whether a file contains supported radiometric data. labkit.thermal.isSupportedPath Test whether a path has a supported thermal extension. labkit.thermal.rawToTemperatureC Convert FLIR raw sensor values to degrees Celsius. labkit.thermal.readFile Read radiometric image data and convert it to degrees Celsius. labkit.thermal.readFiles Read several radiometric image files. labkit.thermal.renderImage Map a thermal matrix to an RGB image. labkit.thermal.supportedExtensions List the supported thermal filename extensions. labkit.thermal.version Return version information for the thermal API."},{"title":"RHS Preview separates source roles from reference mechanics","url":"history/records/2026/07/LK-20260716-rhs-preview-structure.html","kind":"history","text":"LK-20260716-rhs-preview-structure 2026-07-16 108 refactor compatible labkit_RHSPreview_app RHS Preview separates source roles from reference mechanics schema: 2 id: LK-20260716-rhs-preview-structure date: 2026-07-16 sequence: 108 type: refactor compatibility: compatible component: `labkit_RHSPreview_app` | `1.4.2 -> 1.4.3` scope: Neurophysiology scope: App structure Context RHS Preview split one durable project across generic lifecycle files and used two additional lifecycle helpers to select or replace records by role. It also constructed variable filter-source IDs itself and read Runtime-owned reference fields in session, actions, and presentation code. Product metadata remained in separate requirement/version functions. Decision and rationale Keep the three App-owned roles because they express real workflow meaning: one preview recording, one optional protocol, and an ordered collection of filter recordings. Use Runtime V2 for portable path access, fixed-source upserts, and stable reconciliation of the variable collection. Keep only the small App-owned role-to-path selection policy under `sourceFiles`; do not introduce a new framework public role API for one App-specific collection shape. Changes Consolidated command identity, display metadata, version, requirements, project, session, layout, actions, presenter, renderer, and debug capability through the single definition. Concentrated project creation, validation, and the version-1 source upgrade in `projectSpec` behind one `Migrate` callback. Moved indexing, initial preview reads, filter discovery, and transient view reconstruction to root `createSession`. Added `sourceFiles.pathsForRole` to select App-owned source roles while delegating reference decoding to `labkit.ui.runtime.sourcePaths`. Replaced fixed-source construction with `upsertSource` and variable filter ID construction with `reconcileSources`, preserving filter order and stable identities across rediscovery. Removed the generic lifecycle package and separate requirement/version files. User and data impact RHS indexing, lazy waveform windows, channel selection, managed ROI/scroll interaction, protocol editing, multi-file filter labels, refresh/removal, exports, manifests, reset, and project reopening retain their behavior. New filter selections use Runtime-generated stable IDs; those IDs are internal project identities and do not change exported recording paths or labels. The App version advances to 1.4.3; durable payload version remains 2. Compatibility and migration Version-1 projects still combine their separate recording, protocol, and filter source fields before validation. Existing version-2 source IDs and ordering are preserved when their resolved files remain selected. Runtime V2 owns migration iteration and later saves remain version 2. Validation Focused unit tests cover source migration, role-path ordering, summary/detail models, channel-role drafts, filter labels, and preview bounds. The hidden GUI workflow covers indexing and drawing a synthetic RHS file, channel tables, two-file filter discovery, protocol/filter export, manifests, project save, reset, reopen, and reconstruction of both preview and filter sources. Evidence [RHS Preview](../../../../apps/neurophysiology/rhs-preview/README.md) [Nerve Response Analysis](../../../../apps/neurophysiology/nerve-response-analysis/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) Known limitations and follow-up Automated tests do not assess native file-dialog feel, physical channel-role correctness, or waveform interpretation. The remaining special startup path and repository guidance still require separate convergence review."},{"title":"Release validation gate and GUI CI hardening","url":"history/records/2026/07/LK-20260708-release-validation-gate-and-gui-ci-hardening.html","kind":"history","text":"LK-20260708-release-validation-gate-and-gui-ci-hardening 2026-07-08 40 ci compatible Release validation gate and GUI CI hardening schema: 2 id: LK-20260708-release-validation-gate-and-gui-ci-hardening date: 2026-07-08 sequence: 40 type: ci compatibility: compatible scope: historical project evolution Context Release-candidate tags did not yet require one explicit summary gate over the headless, coverage, and GUI jobs. Some GUI assertions also depended on exact pixel ordering or timing that varied across CI display backends. Decision and rationale Require every release test job before publication, while testing semantic grid structure instead of platform-specific pixel rounding. Increase the shared GUI idle allowance so slower hosted displays can finish registered UI work. Changes Project validation workflow, no component version change. Release candidate tags now run the full MATLAB test workflow gate before publication: headless tests, coverage, GUI tests, and a release summary gate. GUI layout tests now assert structural grid contracts instead of platform-dependent flattened pixel ordering or width comparisons. Shared GUI test idle waiting allows slower CI display backends more time to finish registered UI work. User and data impact Published release candidates gained a single pass/fail validation signal. Application behavior and data formats did not change; the GUI suite became less sensitive to harmless platform layout differences. Compatibility and migration No known manual migration. Validation Commit `f359518` updated CI policy tests, declarative UI tests, launcher GUI tests, and shared wait behavior. Evidence Mainline commit `f359518`. Known limitations and follow-up The gate covers automated checks only. Interactive workflow feel and scientific visual review remain outside hosted CI."},{"title":"Release validation includes Base MATLAB compatibility","url":"history/records/2026/07/LK-20260717-release-validation-hardening.html","kind":"history","text":"LK-20260717-release-validation-hardening 2026-07-17 129 ci compatible Release validation includes Base MATLAB compatibility schema: 2 id: LK-20260717-release-validation-hardening date: 2026-07-17 sequence: 129 type: ci compatibility: compatible scope: scheduled and release-candidate validation Context LabKit already exposed `buildtool baseMatlab`, but GitHub Actions did not run the broad MATLAB product-ownership analysis. A release candidate could therefore pass headless, coverage, and GUI jobs while the explicit Base MATLAB compatibility gate remained a local-only check. Decision and rationale Run the existing public `baseMatlab` task in scheduled and manually dispatched workflows, and require it before a validated release tag can be created. Ordinary pull requests and main pushes retain the smaller headless gate because the static call scan and representative fallback workflows already run there. Changes Added a dedicated Base MATLAB compatibility job with JUnit, active-test, log, and artifact reporting. Added that job to the release-test dependency gate. Corrected the CI contract test so it discovers actual `tasks:` values and checks them against the executable build-task catalog. Updated artifact upload jobs to the current Node.js 24 action generation. User and data impact No App behavior or saved data changes. A release tag is now blocked when MATLAB resolves production source to an undeclared MathWorks product. Compatibility and migration The public `buildtool baseMatlab` command is unchanged. GitHub-hosted runners meet the action runtime requirement; no self-hosted runner migration is introduced. Validation The CI policy contract verifies job scope, public-task ownership, diagnostics, timeouts, parent checkout depth, and release-gate dependencies. The Base MATLAB task remains covered by its own dependency guardrail. Evidence `.github/workflows/matlab-tests.yml` is the executable release gate; `tests/cases/contract/project/ci/CiValidationPolicyGuardrailTest.m` protects its routing and dependency structure. Known limitations and follow-up The product-ownership analysis depends on MATLAB's dependency report and cannot replace manual scientific or interactive App validation."},{"title":"Response Review Stats uses one project contract","url":"history/records/2026/07/LK-20260716-response-review-structure.html","kind":"history","text":"LK-20260716-response-review-structure 2026-07-16 105 refactor compatible labkit_ResponseReviewStats_app Response Review Stats uses one project contract schema: 2 id: LK-20260716-response-review-structure date: 2026-07-16 sequence: 105 type: refactor compatibility: compatible component: `labkit_ResponseReviewStats_app` | `1.4.2 -> 1.4.3` scope: Neurophysiology scope: App structure Context Response Review Stats kept project creation, validation, its one source-schema migration, and session reconstruction in four generic lifecycle files. Session and presentation code also read the Runtime's nested source path directly, and the reset action depended on the generic lifecycle package. Decision and rationale Use the standard compact Runtime V2 structure: one definition, one project contract, and one root session factory. Metric alignment, measurement, summaries, source parsing, exports, and presentation already had clear owners and remain unchanged. Changes Consolidated product metadata, version, requirements, layout, actions, presentation, renderer, and debug capability in `definition.m`. Concentrated project creation, validation, and the version-1 source upgrade in `projectSpec.m`. Moved transient metric/alignment reconstruction to root `createSession.m` and reused it for the reset action. Replaced direct portable-reference reads with semantic `sourcePaths` lookup. Removed the generic lifecycle package and separate requirement/version files. User and developer impact Analysis JSON and segment CSV loading, automatic window recomputation, aligned preview, metric summaries, reset, output-folder choice, CSV export, and result manifest behavior are unchanged. Developers now see the entire durable schema history through one entry instead of four lifecycle files. Compatibility and migration Durable schema version 2 is unchanged. Version-1 projects still move their singular source to the canonical source collection before validation and the next save. Validation Focused tests cover source migration, segment parsing, time-grid alignment, measurement, pair-group summaries, and default-grid selection. The hidden GUI workflow loads representative metrics, presents them, and exports the CSV and manifest through real Runtime callbacks. Evidence [Response Review And Stats](../../../../apps/neurophysiology/response-review-stats/README.md) [Runtime and Lifecycle](../../../../framework/guides/runtime.md) [Nerve Response Analysis](../../../../apps/neurophysiology/nerve-response-analysis/README.md) Known limitations and follow-up Automated checks do not validate biological interpretation of a chosen baseline/noise window. The larger RHS Preview and Nerve Response Analysis Apps still require independent ownership review."},{"title":"Runtime V2 lifecycle ownership across the app fleet","url":"history/records/2026/07/LK-20260715-runtime-v2-app-migration.html","kind":"history","text":"LK-20260715-runtime-v2-app-migration 2026-07-15 59 refactor breaking labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app labkit_GaitAnalysis_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_VideoMarker_app labkit_FigureStudio_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app Runtime V2 lifecycle ownership across the app fleet schema: 2 id: LK-20260715-runtime-v2-app-migration date: 2026-07-15 sequence: 59 type: refactor compatibility: breaking component: `labkit.ui` | `5.2.0 -> 6.0.0` component: `labkit_DICPostprocess_app` | `1.3.6 -> 1.4.0` component: `labkit_DICPreprocess_app` | `1.4.0 -> 1.5.0` component: `labkit_ChronoOverlay_app` | `1.3.6 -> 1.4.0` component: `labkit_CIC_app` | `1.3.8 -> 1.4.0` component: `labkit_CSC_app` | `1.3.10 -> 1.4.0` component: `labkit_EIS_app` | `1.3.4 -> 1.4.0` component: `labkit_VTResistance_app` | `1.3.8 -> 1.4.0` component: `labkit_GaitAnalysis_app` | `1.0.0 -> 1.1.0` component: `labkit_BatchImageCrop_app` | `1.6.8 -> 1.7.0` component: `labkit_CurvatureMeasurement_app` | `1.3.5 -> 1.4.0` component: `labkit_FLIRThermal_app` | `1.3.0 -> 1.4.0` component: `labkit_FocusStack_app` | `1.4.9 -> 1.5.0` component: `labkit_ImageEnhance_app` | `1.5.8 -> 1.6.0` component: `labkit_ImageMatch_app` | `1.5.8 -> 1.6.0` component: `labkit_VideoMarker_app` | `1.2.0 -> 1.3.0` component: `labkit_FigureStudio_app` | `0.1.5 -> 0.2.0` component: `labkit_NerveResponseAnalysis_app` | `1.3.5 -> 1.4.0` component: `labkit_ResponseReviewStats_app` | `1.3.5 -> 1.4.0` component: `labkit_RHSPreview_app` | `1.3.4 -> 1.4.0` component: `labkit_ECGPrint_app` | `1.3.5 -> 1.4.0` scope: `docs/` scope: `docs/ui-runtime-redesign.md` scope: runtime migration guidance Context App lifecycle, callback ordering, UI-derived state, project persistence, graphics resources, and result packaging were implemented repeatedly across apps. Migrating only the lifecycle names would have preserved the main author cost: each app would still need to understand raw controls, figure callbacks, and multiple competing state copies. Decision and rationale Make Runtime V2 the only write path and give the framework ownership of launch, startup, the FIFO event queue, atomic state commits, deterministic presentation, dialogs, interaction resources, project persistence, and result manifests. Apps retain explicit project/session/presentation/result contracts and all domain calculations, workflow decisions, plots, schemas, and exports. This deliberately optimizes ownership and learning cost rather than total app line count. Explicit app contracts can add code in a large scientific app, but they replace hidden closure state and callback plumbing with one inspectable model. The remaining 36 public UI functions have distinct reviewed contracts; the earlier 32-function planning target was not forced through vague APIs. Changes Migrated all twenty public apps to Runtime V2 definitions, standard launch, canonical project/session state, semantic events, pure presentation, and managed resources. Standardized current project writes and result manifests while retaining app-owned payload migrations and existing output files. Retired Runtime V1 writes and removed the public control-registry mutation, standalone editor/runtime, preview mutation, dialog, title, and dispatch compatibility surfaces. Kept supported V1 snapshots and named legacy app projects as read-only import formats; subsequent saves use the current project codec. Updated app requirements to `labkit.ui >=6 <7` and made UI 6 the breaking public facade boundary. Replaced Video Marker's optional `vision.PointTracker` branch with one deterministic app-owned multiscale patch matcher, retaining confidence, constant-velocity fallback, subpixel coordinates, and forward-cache behavior without Computer Vision Toolbox. Added traceable temporary MathWorks-product debt declarations: rapid app work must ship a base-MATLAB fallback plus deterministic and Toolbox-parity tests, and product analysis must continue to see the dependency until replacement. Re-audited already migrated apps, removed five dead or presenter-only helper files, and updated helper-quality classification from retired role packages to the current workflow-first lifecycle/state/result/UI boundaries. Corrected Runtime V2 interaction event construction so paired-anchor cell payloads remain one scalar semantic event. DIC Preprocess point matching now accepts alternating reference/moving points, enables alignment after two complete pairs, and completes the in-workbench rigid registration flow. Hardened the same scalar-envelope invariant for app-managed Runtime V2 resources and `runBusy` callback capture, including legitimate cell-form MATLAB callbacks and cell-valued resource payloads. Made project replacement invalidate the prior presentation cache before the fresh session is rendered, so opening an unchanged project still rebuilds app-owned preview graphics and other ephemeral visual resources. Corrected shared file-event index decoding for R2025a string results, so CIC and the other multi-file V2 apps can select and remove imported items. Stopped presentation commits from rerunning unchanged preview requests, and made DIC point-label updates preserve image handles and zoom viewports. Routed anchor-editor wheel input to the shared image zoom implementation without the retired wrapper's same-name recursion. Deferred CIC and VT batch DTA decoding until a file becomes visible or the batch is exported, so multi-file selection no longer blocks on every source. Let Gait Analysis read current Video Marker project/recovery envelopes and legacy Video/Image Marker autosaves directly as pose-coordinate inputs. Added interaction-mode subtitles for curve anchors, point marking, paired anchors, scale references, and fixed point slots; restored axes context menus after renderer resets; and promoted screenshot/project commands to top-level window entries. Moved Video Marker's Session controls to the top of the Video page, added an `Open MAT` project shortcut, and made New setup explicitly cancel, save, or discard before clearing the current project. Reorganized human documentation into getting-started, app workflow, public API, development, and focused-guide layers. Split app authoring from the user catalog, added the missing contract API reference, and made a project guardrail verify that every public `+labkit` function remains indexed under its owning facade. User and data impact App entrypoint names, scientific calculations, workflow decisions, plots, and existing export filenames remain stable. Current projects have a consistent save/reopen path, external sources use portable references, and result exports include a standard manifest. Runtime errors and modal interactions now pass through framework services, which also makes hidden validation deterministic. Compatibility and migration The migrated app versions require `labkit.ui >=6 <7`; they must not be copied into a UI 5 checkout. Supported V1 snapshots and documented legacy projects remain importable but are not written again. After import, save a new current project if continued editing or recovery is required. Validation Focused Runtime V2, project, interaction-hub, DIC point-matching, CIC, Figure Studio, app-boundary, and public-surface tests passed. The latest repair checkpoint additionally passed CIC/VT GUI workflows (2/2), Gait/app compatibility tests (21/21), Curvature and Video Marker GUI methods (6/6), focused framework menu/interaction tests (8/8), and three targeted interaction-hint methods (3/3). A real local legacy Video Marker autosave was read successfully without copying it into the repository. The documentation hierarchy checkpoint passed relative-link validation and 44/44 focused documentation, app-structure, build-task, and history guardrails. The Phase-6 `buildtool changedFast` checkpoint passed 15 framework GUI tests, 284 headless tests with one environment-assumption skip, and six representative GUI workflows. The final broad gate and any manual pointer or visual checks are reported with the merge handoff rather than embedded as mutable history. Video Marker replacement tests exercise integer and subpixel translation, flat-patch fallback, repeated-input determinism, prediction caching, and coordinate parity with `vision.PointTracker` when that product is installed. `buildtool baseMatlab` confirms the production source resolves only to MATLAB. Evidence `4454ca30` introduced the Runtime V2 kernel; the app migrations then ran from Chrono Overlay (`5fe76ee4`) through Figure Studio (`21936e8d`). `1ef46bfd` removed the retired UI runtime, and `8e109ff4` completed the framework contracts used by the migrated apps. The commits listed in the migration sequence above preserve the individual app checkpoints; this record explains their shared architectural result. Known limitations and follow-up Explicit domain contracts mean that large apps do not necessarily have fewer production lines. Further extraction is justified only by repeated mechanics, not by file-size targets. Pointer feel, drag ergonomics, and scientific visual judgment still require the documented manual app review before merge. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Runtime exposes GUI-free source-record creation","url":"history/records/2026/07/LK-20260716-gui-free-source-records.html","kind":"history","text":"LK-20260716-gui-free-source-records 2026-07-16 100 feat additive labkit.ui Runtime exposes GUI-free source-record creation schema: 2 id: LK-20260716-gui-free-source-records date: 2026-07-16 sequence: 100 type: feat compatibility: additive component: `labkit.ui` | `7.4.1 -> 7.4.2` scope: Runtime V2 sources scope: App project migration Context Runtime V2 owned the portable-reference schema and exposed a stable path accessor, but source-record creation was available only through callback-bound project services. GUI-free project migrations and legacy importers therefore could not create canonical sources without copying private reference fields. Batch Crop and Video Marker both contained such schema copies. Decision and rationale Expose the canonical source record, not the portable-reference implementation. `labkit.ui.runtime.sourceRecord` accepts stable App identity, semantic role, path, and required status, then delegates the nested representation to the Runtime. The injected callback service uses the same factory. Changes Added the GUI-free `labkit.ui.runtime.sourceRecord` factory with validation and complete MATLAB help. Routed `services.project.sourceRecord`, `upsertSource`, and `reconcileSources` through the same public contract. Extended source tests to cover factory output and invalid inputs. Updated the framework API map and portable-source guidance. User and developer impact User project behavior is unchanged. App authors can now implement project migrations and legacy imports without opening a UI callback or constructing runtime-owned reference fields. Current file paths remain available only through `sourcePaths`. Compatibility and migration The addition is compatible within UI 7. Existing source records and injected services retain their shape and behavior. Apps should replace copied portable reference construction when their project/import code is migrated. Validation Focused Runtime source tests cover empty arrays, canonical creation, resolved path lookup, ordered/optional ID lookup, invalid references, and invalid factory arguments. Package-surface, version, documentation, and history guardrails protect the new public contract. Evidence [Runtime and Lifecycle](../../../../framework/guides/runtime.md) [App Framework](../../../../framework/README.md) [Architecture](../../../../development/build-apps/architecture.md) Known limitations and follow-up Existing Apps that still copy or read portable-reference fields must migrate to `sourceRecord` and `sourcePaths` before the legacy source boundary is fully retired. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Runtime owns portable source-reference details","url":"history/records/2026/07/LK-20260716-source-path-accessor.html","kind":"history","text":"LK-20260716-source-path-accessor 2026-07-16 91 feat compatible labkit.ui Runtime owns portable source-reference details schema: 2 id: LK-20260716-source-path-accessor date: 2026-07-16 sequence: 91 type: feat compatibility: compatible component: `labkit.ui` | `7.3.0 -> 7.4.0` scope: Runtime source boundary scope: App maintenance cost Context Runtime V2 owned source creation, rebasing, relinking, and validation, but an App still had to read `reference.originalPath` whenever it loaded or displayed a source. This exposed the nested persistence representation throughout session factories, actions, presenters, and workflow packages. Several Apps then added their own identical path loops or ID lookup functions. Decision and rationale Keep source `id`, `role`, and `required` as stable App-facing project data, but treat the nested portable reference as a runtime-owned value. Add one pure `labkit.ui.runtime.sourcePaths` accessor that works before UI construction and therefore serves session creation as well as actions and presentation. Passing UI-bound injected services into `CreateSession` was rejected: it would couple otherwise pure state reconstruction to figure, queue, dialog, and resource-service construction merely to read a path. Changes Added `sourcePaths(sources)` for paths in source order. Added `sourcePaths(sources,ids)` for strict lookup in requested ID order. Defined empty, malformed-reference, and unknown-ID behavior in executable public help and unit tests. Stopped documenting portable-reference fields as an App read contract. Added the accessor to the guarded public UI runtime surface. User and developer impact Saved projects and file resolution behave unchanged. App code can now read, compare, load, and present source paths without knowing how a portable reference stores its current or relative location. Compatibility and migration The addition is compatible within UI 7. Existing direct field reads continue to work temporarily, but tracked Apps will move to the accessor during their family-by-family Runtime V2 consolidation. No project migration is required. Validation Unit tests cover source order, requested ID order, empty results, missing IDs, and invalid runtime references. Package-surface and documentation guardrails cover the new public contract. Evidence [Runtime and Lifecycle](../../../../framework/guides/runtime.md) [Complete App Tutorial](../../../../development/build-apps/complete-app.md) Known limitations and follow-up Existing App-local path loops remain until their owning App commits are migrated and behavior-tested. Once all consumers use this accessor, a contract guard will prevent production Apps from reading portable-reference fields. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Runtime uses one source contract for launch, migration, and layout","url":"history/records/2026/07/LK-20260717-retired-runtime-compatibility.html","kind":"history","text":"LK-20260717-retired-runtime-compatibility 2026-07-17 126 refactor breaking labkit.ui Runtime uses one source contract for launch, migration, and layout schema: 2 id: LK-20260717-retired-runtime-compatibility date: 2026-07-17 sequence: 126 type: refactor compatibility: breaking component: `labkit.ui` | `7.4.7 -> 7.5.0` scope: Runtime launch contract scope: Project payload migration scope: Semantic layout validation Context UI Runtime had already introduced one-definition App launch, one version-aware `Project.Migrate` callback, and presenter-owned managed interactions. Transitional branches still accepted separate requirements and version factories, ordered `Project.Migrations` cells, and a `toolPanel` layout kind whose public constructor no longer existed. No public or accepted private App used those branches; framework tests and stale guidance were their only remaining consumers. Decision and rationale Keep one writable source contract for each capability. `launch` receives one definition factory and reads product metadata and requirements from that definition. Runtime calls the App's single `Migrate(project,fromVersion)` callback for each missing payload step. Layout sections contain supported semantic controls, while presenter interaction specs own axes tools. This removes source-level compatibility that could hide an incomplete App migration. Read-only support for current saved project envelopes, declared legacy MAT imports, and older supported payload versions remains intact. Changes Removed requirements/version factory dispatch from `runtime.launch` and added a direct diagnostic for the retired call form. Rejected `Project.Migrations` and removed its project-reader fallback. Removed `toolPanel` validation, sizing, building, diagnostics, and current manual guidance. Converted Runtime tests to complete single definitions and added regression checks for both retired contracts. User and developer impact Current Apps and their project files behave the same. App developers have one place to review identity, version, requirements, layout, and optional runtime capabilities. Source code still using a three-factory launch or `Project.Migrations` now fails immediately with migration guidance instead of silently taking a legacy path. Compatibility and migration This is a source-contract cleanup within UI 7. Call `labkit.ui.runtime.launch(@app.definition,varargin{:})`, move product metadata and `Requirements` into `definition.m`, and replace migration callback cells with one callback that switches on `fromVersion`. No saved-data rewrite is required. Validation Focused Runtime GUI tests cover single-definition launch, rejection of the retired launch form, ordered project migration and atomic load, rejection of `Project.Migrations`, managed interactions, and startup progress. Contract tests cover App layout structure and public surface drift. Evidence [Runtime and lifecycle](../../../../framework/guides/runtime.md) [App development](../../../../development/build-apps/app-development.md) [Public API reference](../../../../reference/README.md) Known limitations and follow-up Historical records retain their original descriptions of the transitional APIs. They document how the architecture evolved and are not current usage guidance. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Runtime-only P-code app packages","url":"history/records/2026/07/LK-20260708-runtime-only-p-code-app-packages.html","kind":"history","text":"LK-20260708-runtime-only-p-code-app-packages 2026-07-08 41 refactor compatible Runtime-only P-code app packages schema: 2 id: LK-20260708-runtime-only-p-code-app-packages date: 2026-07-08 sequence: 41 type: refactor compatibility: compatible scope: historical project evolution Context Single-app P-code packages included a P-coded copy of the full launcher even though their purpose was to run one protected app. That exposed maintenance and packaging actions that were meaningful only in a source checkout. Decision and rationale Make P-code output a minimal runtime package with a direct app entry script. Keep the full launcher in source and source-package distributions, where its installation, profiling, and packaging tools are available. Changes Project deployment tooling, no component version change. `Package P-code` now creates a runtime-only single-app package instead of shipping a P-coded LabKit launcher and launcher maintenance tools. P-code package manifests and README instructions point users to the direct `run_` entry file. P-code packaging no longer requires `labkit_launcher.m` or `labkit_launcher.p` to exist in the package root being used as the runtime source. User and data impact Recipients of a P-code package start the protected app with `run_` and no longer see unrelated launcher maintenance actions. The protected app and its data formats are unchanged. Compatibility and migration Users of P-code packages should run `run_` from the unzipped package instead of `labkit_launcher`. Source packages still include and support the launcher. Validation Commit `75f63f1` expanded `PackageLabKitAppToolTest` for runtime-only package contents and updated the generated package instructions. Evidence Mainline commit `75f63f1`. Known limitations and follow-up P-code packages intentionally omit source-oriented launcher features. Users who need installation or packaging tools should use a source or source-package distribution."},{"title":"Runtime-owned project shape validation","url":"history/records/2026/07/LK-20260716-runtime-owned-project-shape-validation.html","kind":"history","text":"LK-20260716-runtime-owned-project-shape-validation 2026-07-16 117 refactor compatible labkit.ui labkit_ChronoOverlay_app labkit_CIC_app labkit_CSC_app labkit_EIS_app labkit_VTResistance_app Runtime-owned project shape validation schema: 2 id: LK-20260716-runtime-owned-project-shape-validation date: 2026-07-16 sequence: 117 type: refactor compatibility: compatible component: `labkit.ui` | `7.4.4 -> 7.4.5` component: `labkit_ChronoOverlay_app` | `1.4.4 -> 1.4.5` component: `labkit_CIC_app` | `1.4.4 -> 1.4.5` component: `labkit_CSC_app` | `1.4.4 -> 1.4.5` component: `labkit_EIS_app` | `1.4.4 -> 1.4.5` component: `labkit_VTResistance_app` | `1.4.4 -> 1.4.5` scope: Runtime V2 project validation scope: electrochemistry App maintenance cost Context Runtime already validated canonical project buckets and portable source records after actions committed. During project restore, however, the App validator ran before that framework validation. Every persistent App consequently repeated the same bucket and source-record checks so malformed loaded projects would fail safely. Decision and rationale Validate framework-owned project structure before invoking App-owned project validation on both initial state and project restore. App validators should describe only their own schema and scientific rules. This gives canonical shape and source records one owner while retaining strict validation at the load boundary. Changes Added one private Runtime project-shape validator shared by restore and semantic state validation. Runtime now rejects a malformed loaded payload before its App validator can inspect domain fields. Removed 67 net lines of repeated canonical bucket and source-record checks from the five electrochemistry `projectSpec.m` files. Kept each App's legal choices, numeric bounds, result fields, and other domain-specific validation unchanged. User and data impact Valid projects and scientific results are unchanged. Malformed projects now receive the framework's consistent invalid-state error for canonical structure failures instead of an App-specific bucket or source error. No payload migration is required. Compatibility and migration The accepted project schema is unchanged. This is a validation-ownership change, not a data-format change, and current or older supported project versions continue through the same migration callbacks. Validation The Runtime project GUI test loads a payload missing a canonical bucket and verifies atomic rejection before App validation. Focused electrochemistry GUI tests exercise load, analysis, plotting, and export paths with the reduced App validators. Evidence [Runtime and Lifecycle](../../../../framework/guides/runtime.md) defines the framework and App validation boundary. [App Development](../../../../development/build-apps/app-development.md) tells App authors which validation remains in `projectSpec.m`. Each affected [Electrochemistry App](../../../../apps/electrochemistry/README.md) manual identifies its domain-owned validator responsibilities. Known limitations and follow-up Other App families still repeat some canonical structure checks. They will move to the same boundary only after their role and cross-field constraints are separated from the generic checks and covered by focused tests. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Runtime-owned session defaults across App families","url":"history/records/2026/07/LK-20260716-runtime-owned-session-defaults.html","kind":"history","text":"LK-20260716-runtime-owned-session-defaults 2026-07-16 116 refactor compatible labkit.ui labkit_DICPostprocess_app labkit_DICPreprocess_app labkit_GaitAnalysis_app labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FLIRThermal_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app labkit_VideoMarker_app labkit_FigureStudio_app labkit_NerveResponseAnalysis_app labkit_ResponseReviewStats_app labkit_RHSPreview_app labkit_ECGPrint_app Runtime-owned session defaults across App families schema: 2 id: LK-20260716-runtime-owned-session-defaults date: 2026-07-16 sequence: 116 type: refactor compatibility: compatible component: `labkit.ui` | `7.4.3 -> 7.4.4` component: `labkit_DICPostprocess_app` | `1.4.3 -> 1.4.4` component: `labkit_DICPreprocess_app` | `1.5.4 -> 1.5.5` component: `labkit_GaitAnalysis_app` | `2.0.4 -> 2.0.5` component: `labkit_BatchImageCrop_app` | `1.7.3 -> 1.7.4` component: `labkit_CurvatureMeasurement_app` | `1.4.3 -> 1.4.4` component: `labkit_FLIRThermal_app` | `1.4.3 -> 1.4.4` component: `labkit_FocusStack_app` | `1.5.2 -> 1.5.3` component: `labkit_ImageEnhance_app` | `1.6.3 -> 1.6.4` component: `labkit_ImageMatch_app` | `1.6.3 -> 1.6.4` component: `labkit_VideoMarker_app` | `1.5.2 -> 1.5.3` component: `labkit_FigureStudio_app` | `0.2.5 -> 0.2.6` component: `labkit_NerveResponseAnalysis_app` | `1.4.3 -> 1.4.4` component: `labkit_ResponseReviewStats_app` | `1.4.3 -> 1.4.4` component: `labkit_RHSPreview_app` | `1.4.3 -> 1.4.4` component: `labkit_ECGPrint_app` | `1.4.3 -> 1.4.4` scope: Runtime V2 session construction scope: App maintenance cost Context Runtime V2 already adds the canonical `selection`, `workflow`, `view`, and `cache` session buckets after an App factory returns. Its workflow service also creates `logLines` on the first log operation. Fifteen App factories still repeated empty buckets or initialized that framework-owned log, making their state declarations longer and obscuring which transient fields were genuinely App-specific. Decision and rationale Treat canonical session shape and workflow-log initialization as Runtime control-plane responsibilities. An App session factory now returns only the selection, workflow, view, and cache fields that express its own workflow. This preserves explicit App state while removing framework boilerplate. Changes Removed empty canonical session buckets from affected App factories. Removed every App-owned `logLines` initializer; the injected workflow service remains the only writer and lazy initializer. Removed Image Enhance's direct log-panel presenter binding; Runtime commits the workflow log to every declared log panel. Added `services.project.newState()` and moved full-project reset actions onto that Runtime-owned creation and normalization path. Added a fleet-wide structure guard that rejects empty canonical buckets and direct workflow-log initialization in `createSession.m`. Updated every affected App manual and patch version. User and data impact There is no workflow, scientific, persistence, or saved-data change. Runtime normalization produces the same complete in-memory session before validation, presentation, or the first callback. Existing project files remain compatible. Compatibility and migration The change is source-compatible within Runtime V2 and requires no project payload migration. Direct tests of an App's raw session factory should inspect only App-owned fields; tests that need canonical empty buckets should create state through Runtime. Validation The fleet structure guard covers every public App factory. Focused App unit and hidden-GUI suites verify restored state, initial presentation, workflow logs, and representative source-backed sessions across the affected families. Evidence [Runtime and Lifecycle](../../../../framework/guides/runtime.md) defines canonical session normalization and workflow logging. [App Development](../../../../development/build-apps/app-development.md) requires `createSession.m` to rebuild only App-specific transient data. Each affected App manual describes its remaining session-owned fields. Known limitations and follow-up Most factories still perform necessary source decoding or reconstruction. They should not be removed merely to reduce file count. Further simplification requires evidence that a repeated reconstruction pattern is domain-neutral and belongs behind a stable Runtime service. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Searchable MATLAB-generated documentation site and launcher workflow groups","url":"history/records/2026/07/LK-20260715-documentation-site.html","kind":"history","text":"LK-20260715-documentation-site 2026-07-15 60 feat additive labkit_launcher Searchable MATLAB-generated documentation site and launcher workflow groups schema: 2 id: LK-20260715-documentation-site date: 2026-07-15 sequence: 60 type: feat compatibility: additive component: `labkit_launcher` | `1.4.0 -> 1.5.0` scope: `docs/` scope: `site/` scope: `tools/docs/` scope: `buildfile.m` Context The documentation had become a set of large Markdown pages with no generated site, global search, code-entity pages, or enforced relationship between the public MATLAB surface and its reference material. Launcher actions were also presented as a mostly flat list, making routine app launch, installation, maintenance, profiling, and packaging appear equally related. Decision and rationale Keep authoring in tracked Markdown, structured navigation/catalog metadata, and MATLAB help blocks, then use one repository-owned MATLAB compiler to emit the tracked static site. Bind every non-private `labkit.*` API and each explicitly cataloged app-owned scientific API to its source declaration. Keep private helpers out of detailed reference pages. Group launcher actions by user intent and make documentation regeneration a first-level maintenance action. This follows the separation used by MATLAB and Qt documentation while keeping the build offline and dependency-free. Changes Added a MATLAB-only static documentation compiler, responsive site chrome, global client-side search, source links, related-API links, and deterministic generated-tree comparison. Added structured page and app-owned API catalogs. Generated reference now covers reusable library functions plus explicitly supported GUI-free app calculation entry points, while rejecting `private/` paths. Added public documentation build and consistency tasks and project guardrails for catalog coverage, search visibility, and generated output. Reorganized launcher buttons into Run Apps, Versions and Install, Development and Maintenance, and Package and Publish groups. Added Update Documentation to the launcher. It rebuilds `site/`, opens the generated home page, and reports narrative-page, API-page, file, and output location details. User and data impact Users can browse and search documentation without a MATLAB documentation server, move between related public functions, and regenerate the same site after source changes. Existing app data, projects, calculations, and exports are unchanged. Launcher maintenance actions are easier to distinguish from normal app launch and installation tasks. Compatibility and migration The feature is additive. Existing Markdown links remain valid in the source repository. `site/` is generated and must not be edited manually. Source-only or P-code app packages that omit documentation tools show the launcher action disabled with an explanatory tooltip. Validation The compiler generated 20 narrative pages, 133 public API pages, and 157 tracked files. Focused documentation/build guardrails passed after verifying a byte-for-byte rebuild. Hidden-GUI launcher layout, documentation generation, and missing-tool behavior tests passed 3/3. Evidence `tools/docs/renderLabKitDocs.m` and `docs/site.json` define the deterministic build and navigation contract. `docs/catalogs/api.json` defines the supported app-owned public API surface. Initial searchable site and launcher integration `bdeba572`. Per-app documentation publication `82734a47`. Later information-architecture rebuild `8b55e2b9`. Known limitations and follow-up The first generated site still contains transitional narrative pages that must be split into app-family, per-app, framework, and focused API guides. Cataloged scientific APIs need richer standalone examples and algorithm/unit sections before the documentation rewrite is complete."},{"title":"Shared image facade","url":"history/records/2026/06/LK-20260630-shared-image-facade.html","kind":"history","text":"LK-20260630-shared-image-facade 2026-06-30 24 feat compatible labkit.image labkit_BatchImageCrop_app labkit_CurvatureMeasurement_app labkit_FocusStack_app labkit_ImageEnhance_app labkit_ImageMatch_app Shared image facade schema: 2 id: LK-20260630-shared-image-facade date: 2026-06-30 sequence: 24 type: feat compatibility: compatible introduced: `labkit.image` | `1.0.0` component: `labkit_BatchImageCrop_app` | `1.3.9 -> 1.4.0` component: `labkit_CurvatureMeasurement_app` | `1.2.3 -> 1.2.4` component: `labkit_FocusStack_app` | `1.2.5 -> 1.3.0` component: `labkit_ImageEnhance_app` | `1.3.5 -> 1.4.0` component: `labkit_ImageMatch_app` | `1.3.5 -> 1.4.0` scope: historical project evolution Context Batch Crop, Focus Stack, Image Enhance, and Image Match each carried similar code for supported extensions, path normalization, image reads, preview sizing, and basic enhancement. Small differences between those copies produced inconsistent file and display behavior. Decision and rationale Introduce `labkit.image` for GUI-free operations with neutral meaning, then move the image apps to that shared API. Keep crop geometry, focus fusion, matching, protected enhancement, and export schemas in their respective apps. Changes `labkit.image` `1.0.0` Batch Crop, Curvature, Focus Stack, Image Enhance, and Image Match advanced within their image-facade adoption lines. Added a GUI-free image facade for file input, display normalization, basic processing, and preview support. Adopted that facade across image-measurement apps. User and data impact Supported image selection, display-name handling, preview normalization, and common enhancement primitives became consistent across the migrated apps. Scientific app outputs and source files did not change format. Compatibility and migration Image apps kept their entry points and exports while adopting the shared operations. App code that called the retired local helpers needed to use the image facade or the owning app operation. Validation Commit `7023e87e` added a dedicated `LabKitImageFacadeTest` suite and updated app compatibility, package-boundary, public-surface, and focused image-app tests. Evidence Main commit `7023e87e`. Known limitations and follow-up The first facade release covered common MATLAB image formats and basic processing only. Thermal decoding and workflow-specific algorithms remained outside `labkit.image`. labkit.image.adjustBrightnessContrast Apply simple brightness and contrast adjustment. labkit.image.adjustHueSaturation Apply HSV hue and saturation adjustment. labkit.image.assertSupportedPaths Throw when any path has an unsupported image extension. labkit.image.displayName Return a short image-file display name. labkit.image.ensureRgb Return image data with exactly three color channels. labkit.image.fileDialogFilter Return a file-chooser-compatible image filter. labkit.image.grayWorldWhiteBalance Apply generic gray-world white balance. labkit.image.im2double Convert image data to double using MATLAB's im2double contract. labkit.image.isSupportedPath Return true when a path has a supported image extension. labkit.image.localContrast Enhance local value-channel contrast with a mean-filter mask. labkit.image.meanFilter2 Apply normalized 2-D mean filtering with edge correction. labkit.image.normalizePaths Normalize image file path inputs to a string column. labkit.image.previewBudget Downsample image data to fit a display pixel budget. labkit.image.readFiles Read image files into path/name/image records. labkit.image.resizeToFit Resize an image to fit within maximum row/column limits. labkit.image.rgb2gray Convert RGB data using MATLAB's rgb2gray call contract. labkit.image.sharpen Apply generic unsharp-mask sharpening. labkit.image.supportedExtensions Return extensions supported by LabKit image file inputs. labkit.image.version Return the LabKit image facade contract version. labkit.image.writeFile Write one image file, creating the parent folder when needed."},{"title":"Single-click DIC rigid point matching","url":"history/records/2026/07/LK-20260713-dic-rigid-point-editor.html","kind":"history","text":"LK-20260713-dic-rigid-point-editor 2026-07-13 50 feat additive labkit.ui labkit_DICPreprocess_app Single-click DIC rigid point matching schema: 2 id: LK-20260713-dic-rigid-point-editor date: 2026-07-13 sequence: 50 type: feat compatibility: additive component: `labkit.ui` | `5.0.4 -> 5.1.0` component: `labkit_DICPreprocess_app` | `1.3.6 -> 1.4.0` Context DIC manual rigid matching had draggable points but maintained a separate pointer implementation and required a less consistent placement workflow than the ROI-center anchors used by Imager Reconstruction. Decision and rationale Extend the existing app-neutral anchor editor with a discrete point mode, then keep moving/fixed pair order, numbering, minimum pair count, and rigid-fit policy inside DIC. Changes Added `mode=\"points\"`: one blank click appends a point, dragging refines it, no connecting curve is drawn, and deletion remains under explicit controls. Migrated the DIC modal to two shared point-mode editors while preserving ordered moving/fixed pairs, labels, undo, cancel, and acceptance rules. Retained toolbox-free image display and rigid alignment behavior. User and data impact Feature placement now follows the same direct click-and-drag model as Imager ROI anchors. Point coordinates and the resulting rigid transform keep their existing N-by-2 pixel-coordinate contract. Compatibility and migration The default anchor-editor curve mode is unchanged. DIC exports and transform math are unchanged; this is an additive interaction improvement. Validation UI anchor-editor tests cover discrete point append and no-path behavior. The DIC GUI workflow covers toolbox-free modal cancellation and app launch wiring. Evidence Primary sources are `labkit.ui.interaction.anchorEditor` and `dic_preprocess.userInterface.selectRigidPointPairs`. Commit `392a073e` introduced their shared DIC rigid-point interaction. Known limitations and follow-up Automated hidden-GUI tests cannot judge pointing ergonomics; final interaction feel still requires a short manual placement-and-drag check. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Single-definition App product contract","url":"history/records/2026/07/LK-20260716-single-definition-contract.html","kind":"history","text":"LK-20260716-single-definition-contract 2026-07-16 77 feat additive labkit.ui Single-definition App product contract schema: 2 id: LK-20260716-single-definition-contract date: 2026-07-16 sequence: 77 type: feat compatibility: additive component: `labkit.ui` | `7.1.0 -> 7.2.0` scope: App authoring scope: Runtime V2 launch metadata Context Every App repeated product metadata and facade requirements in `version.m` and `requirements.m`, while `definition.m` separately described the product's runtime behavior. Thin entrypoints had to join all three factories, and the launcher and release guardrails treated the duplicate files as separate facts. Decision and rationale Make `definition.m` the one App product contract. Product identity, version, requirements, layout, and optional runtime capabilities now have one owner. The public entrypoint can delegate through `launch(@definition, varargin{:})`. Changes Added `Command`, optional `DisplayName`, `Family`, `AppVersion`, `Updated`, and `Requirements` fields to `labkit.ui.runtime.define`. Added single-definition launch and lightweight `version` and `requirements` request handling. Kept the previous three-factory launch form only as a temporary bridge while existing Apps move in reviewed family-sized changes. Extended the minimal real-GUI launch test to obtain all metadata from the same definition. User and developer impact App users see no workflow or scientific change. App authors no longer need to keep three product declarations synchronized after their App is migrated. Compatibility and migration The addition is compatible within UI 7. Existing definitions continue to run during the migration window. Each App will move its exact metadata and facade ranges into `definition.m` before its redundant files are removed. The bridge is not a permanent supported architecture. Validation Focused runtime tests exercise normal launch plus `version` and `requirements` requests from one minimal definition. Documentation, facade-version, and App contract tests protect the metadata shape during the family migrations. Evidence [Runtime and Lifecycle](../../../../framework/guides/runtime.md) documents the single definition and field meanings. [App Development](../../../../development/build-apps/app-development.md) shows the reduced static App file set. Known limitations and follow-up Existing Apps, launcher static discovery, release version guards, tutorials, and the App-builder skill still need family-by-family migration. The legacy launch bridge is removed only after those consumers no longer use it. labkit.ui.debug.context Create an app-neutral callback tracing and diagnostic context. labkit.ui.interaction.anchorPath Build a visible path through image anchor points. labkit.ui.interaction.enablePopout Add a standalone-figure action to an axes context menu. labkit.ui.interaction.scaleBarCalibration Convert a known image distance into pixels per unit. labkit.ui.interaction.scaleBarGeometry Compute serializable image scale-bar overlay geometry. labkit.ui.layout.action Create an app-command layout node. labkit.ui.layout.field Create a labeled scalar field layout node. labkit.ui.layout.filePanel Create a file input panel layout node. labkit.ui.layout.group Create a grouped UI layout. labkit.ui.layout.logPanel Create a read-only log panel layout node. labkit.ui.layout.panner Create a numeric spinner with a linked slider. labkit.ui.layout.previewArea Create a workspace preview/axes area layout node. labkit.ui.layout.rangeField Create a paired start/end or min/max field layout node. labkit.ui.layout.resultTable Create a titled result table layout node. labkit.ui.layout.section Create a titled control-section layout node. labkit.ui.layout.statusPanel Create a read-only status/details panel layout node. labkit.ui.layout.tab Create a LabKit control or workspace tab layout node. labkit.ui.layout.workbench Create a declarative LabKit workbench layout. labkit.ui.layout.workspace Create a right-side LabKit workbench workspace layout node. labkit.ui.plot.clampData Keep data coordinates inside the visible axes box. labkit.ui.plot.clear Prepare an axes for an app-owned redraw. labkit.ui.plot.fit Fit axes limits to finite plotted X/Y data. labkit.ui.plot.fitCanvas Fit a preview axes into a fixed-aspect pixel frame. labkit.ui.plot.message Show a centered empty-state message in an axes. labkit.ui.plot.offsetData Move data coordinates by normalized axes fractions. labkit.ui.runtime.create Build a LabKit workbench from a declarative layout. labkit.ui.runtime.defaultOutputFolder Return a source-adjacent app output folder. labkit.ui.runtime.define Create a LabKit declarative app runtime definition. labkit.ui.runtime.emptySourceRecords Create an empty Runtime V2 source array. labkit.ui.runtime.launch Dispatch requests and launch a LabKit runtime definition. labkit.ui.runtime.loadState Load a compatible Runtime V2 project or declared legacy import. labkit.ui.runtime.saveState Save a Runtime V2 project to a MAT file. labkit.ui.runtime.sourcePaths Read resolved paths from Runtime V2 source records. labkit.ui.runtime.sourceRecord Create one canonical Runtime V2 external-source record. labkit.ui.version Return the LabKit UI facade contract version."},{"title":"Source-adjacent Video Marker autosave","url":"history/records/2026/07/LK-20260716-source-adjacent-video-autosave.html","kind":"history","text":"LK-20260716-source-adjacent-video-autosave 2026-07-16 69 fix compatible labkit.ui labkit_VideoMarker_app Source-adjacent Video Marker autosave schema: 2 id: LK-20260716-source-adjacent-video-autosave date: 2026-07-16 sequence: 69 type: fix compatibility: compatible component: `labkit.ui` | `6.0.3 -> 6.0.4` component: `labkit_VideoMarker_app` | `1.4.0 -> 1.4.1` scope: Runtime V2 project recovery scope: Video Marker autosave location Context The first explicit Runtime V2 autosave action wrote only the framework's hidden recovery generation. Video Marker already had a product contract for a visible, stable autosave beside the source video, and users select those MAT files directly in downstream workflows such as Gait Analysis. Decision and rationale Keep generic debounced recovery framework-owned, but let an app provide a deterministic autosave destination when that location is part of its workflow. Video Marker derives the destination from the source video and never asks the user to choose it. Changes Added `services.project.saveAutosave(state,filepath)` without changing named project ownership or dirty status. Restored the visible `Video Marker Autosaves` folder and stable `
    - +

    guide

    App Development

    @@ -28,13 +28,13 @@

    Create An App

    A static App begins with only the product entrypoint, definition, and layout it actually uses:

    apps/<family>/<app_slug>/labkit_<AppName>_app.m
     apps/<family>/<app_slug>/+<app_slug>/definition.m
    -apps/<family>/<app_slug>/+<app_slug>/+userInterface/buildWorkbenchLayout.m
    -

    runtime.define supplies an empty version-1 project, empty session, empty action registry, and empty presenter model when those components are omitted. Add definitionActions.m for interactions, a presenter for dynamic views, createSession.m for transient decoded/cache state, and projectSpec.m only when the App owns durable data. That single project file contains local create, validate, and migrate functions. Its migrate callback exists only after a saved project schema has actually changed; Runtime owns the version loop.

    +apps/<family>/<app_slug>/+<app_slug>/+workbench/buildLayout.m
    +

    labkit.app.Definition supplies empty project/session and default presentation behavior when optional components are omitted. Bind real business callbacks directly from the layout. Add +workbench/present.m for derived visible state, createSession.m for transient decoded/cache state, and projectSpec.m only when the App owns durable data. That single project file contains local create, validate, and migrate functions. Its migrate callback exists only after a saved project schema has actually changed; Runtime owns the version loop.

    Runtime and App architecture names remain versionless. Put facade/App compatibility in the existing version and requirement metadata, and put saved payload numbers only in projectSpec migration logic. Do not create version-named packages, files, functions, types, tests, or manual sections.

    The project validator owns only App-specific requirements: domain fields, legal choices and ranges, cross-field relationships, source roles, and scientific invariants. Runtime validates the five canonical project buckets and standard portable source records before the callback runs. Do not repeat those framework checks in each App.

    Create additional packages only for concrete workflows that need them, for example +sourceFiles, +analysisRun, +resultFiles, +cropGeometry, or +thermalFrames. Avoid generic buckets such as +actions, +ops, +io, +helpers, and +utils.

    Define The Runtime Contract

    -

    definition.m returns a plain struct created by labkit.ui.runtime.define. It is the App's single product contract and names the public command, stable ID, display metadata, App version, compatible LabKit facades, and layout builder. Project schema, session factory, action registry, presenter, renderers, and startup event are opt-in capabilities.

    +

    definition.m returns one immutable labkit.app.Definition. It is the App's single product contract and names the public command, stable ID, display metadata, App version, compatible LabKit facades, and workbench. Project schema, session factory, presenter, post-layout start callback, and debug sample are opt-in capabilities. Callbacks and renderers are owned directly by their layout nodes.

    The complete field tables, callback signatures, canonical project/session buckets, presenter shape, and renderer contract are documented in Runtime and Lifecycle. For a complete file-by-file implementation, follow Build a Complete App.

    The framework owns:

      @@ -44,10 +44,12 @@

      Define The Runtime Contract

    • managed interactions and resources
    • debug tracing and result manifests
    -

    The app owns durable state.project, transient state.session, workflow handlers, presentation models, and scientific behavior.

    +

    The app owns durable state.project, transient state.session, semantic callbacks, presentation models, and scientific behavior.

    Build The Workbench

    -

    +userInterface/buildWorkbenchLayout.m returns a data-only labkit.ui.layout.* tree. Keep tab, section, and workspace builders in the same order users see them. It must not create graphics handles, read files, run calculations, mutate state, or schedule startup work.

    -

    +userInterface/presentWorkbench.m is the pure bridge from canonical state to semantic control properties, prepared plot models, and managed interaction specs. Renderers draw prepared models and should not own workflow decisions.

    +

    +workbench/buildLayout.m returns a data-only labkit.app.layout.* tree. Keep tabs, sections, and workspace pages in the same order users see them. For a complex App, compose capability-owned layout fragments instead of flattening every control into this file. It must not create graphics handles, read files, run calculations, mutate state, or schedule startup work.

    +

    +workbench/present.m is the pure assembly bridge from canonical state to a complete labkit.app.view.Snapshot. Compose feature-owned fragments with Snapshot.include. Renderers live with the capability they draw, receive only axes and a prepared model, and do not own workflow decisions.

    +

    Use the complete runtime value only at this assembly bridge and at callbacks referenced directly by layout signals. Name it applicationState, unpack project and session at the top, and call feature presenters with the exact values they display. A feature presenter should look like present(results, selection, displayOptions), not present(state).

    +

    A direct callback is the transaction adapter. Its signature is (applicationState, callbackContext) for a button or (applicationState, typedEventValue, callbackContext) for a value-bearing signal. It may perform short, visible state mutation in workflow order, then delegate calculations and writes through explicit inputs. Do not pass the complete state or callback context into a generic second action layer.

    Name Workflow Code

    Use concrete verb-object names such as:

    +sourceFiles/readSourceFiles.m
    @@ -79,7 +81,7 @@ 
     
  • App Framework
  • Architecture
  • Testing
  • - +
    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/development/build-apps/architecture.html b/site/development/build-apps/architecture.html index cbe69da8f..22801af0e 100644 --- a/site/development/build-apps/architecture.html +++ b/site/development/build-apps/architecture.html @@ -18,7 +18,7 @@
    - +

    guide

    Architecture

    @@ -48,19 +48,20 @@

    Runtime Entrypoints

    The launcher keeps update, discovery, and repair logic self-contained: it uses native MATLAB UI and local helper functions so users can repair a damaged zip install even if packages, apps, docs, or scripts have been deleted. It configures the MATLAB path for app entry points. MATLAB desktop project metadata belongs to each developer's local workspace.

    Tools under tools/ are source-checkout support utilities rather than app runtime APIs. The launcher may call a small, explicit subset for maintenance and deployment actions, such as profiling a selected app or packaging a single app for offline deployment. Single-app deployment packages include the launcher and only those launcher-needed tool folders, not the whole source checkout. Direct syntax, options, outputs, and artifact behavior are documented in Maintainer Tools.

    Ownership Boundaries

    -
    AreaOwns
    App entry pointPublic launch name delegated to one runtime definition, including lightweight requirements/version/debug requests.
    App packageApp definition, workflow state, command handlers, presenters, calculations, summaries, exports, and app-local helpers.
    labkit.uiDeclarative app runtime, app shell, readiness/busy state, data-only workbench layouts, semantic view updates, reusable tools, and diagnostics.
    labkit.imageGUI-free image file IO, display normalization, resizing, mean filtering, and basic enhancement primitives.
    labkit.thermalGUI-free thermal source-file parsing, raw thermal matrices, embedded calibration metadata, raw-to-temperature conversion, and thermal colormap rendering.
    labkit.dtaGUI-free Gamry DTA discovery, loading, parsed curves, and pulse helpers.
    labkit.biosignalGUI-free recording import, channel extraction, filtering, events, segments, templates, and measurements.
    labkit.rhsGUI-free Intan RHS discovery, header parsing, block indexing, and lazy waveform window reads.
    +
    AreaOwns
    App entry pointPublic launch name delegated to one runtime definition, including lightweight requirements/version/debug requests.
    App packageApp definition, workflow state, command handlers, presenters, calculations, summaries, exports, and app-local helpers.
    labkit.appApp definition and launch, semantic layout and view snapshots, typed events, callback capabilities, projects, diagnostics, results, interactions, and private native runtime lifecycle.
    labkit.imageGUI-free image file IO, display normalization, resizing, mean filtering, and basic enhancement primitives.
    labkit.thermalGUI-free thermal source-file parsing, raw thermal matrices, embedded calibration metadata, raw-to-temperature conversion, and thermal colormap rendering.
    labkit.dtaGUI-free Gamry DTA discovery, loading, parsed curves, and pulse helpers.
    labkit.biosignalGUI-free recording import, channel extraction, filtering, events, segments, templates, and measurements.
    labkit.rhsGUI-free Intan RHS discovery, header parsing, block indexing, and lazy waveform window reads.

    Apps own experiment-specific vocabulary, thresholds, protocol roles, plots, result schemas, export formats, alerts, and log wording. Reusable facades own domain-neutral mechanics that multiple apps can share.

    App Package Shape

    The target app shape is workflow-first. Each app keeps one MATLAB package under its app folder:

    apps/<family>/<app_slug>/labkit_<AppName>_app.m
     apps/<family>/<app_slug>/+<app_slug>/definition.m
    -apps/<family>/<app_slug>/+<app_slug>/+userInterface/buildWorkbenchLayout.m
    +apps/<family>/<app_slug>/+<app_slug>/+workbench/buildLayout.m

    Those three files are a complete static App. Add only the capabilities the product needs:

    -
    definitionActions.m                         semantic commands or bound events
    -projectSpec.m                               durable create/validate/migrate contract
    +
    projectSpec.m                               durable create/validate/migrate contract
     createSession.m                             transient App-specific reconstruction
    -+userInterface/presentWorkbench.m           dynamic semantic view models
    -+userInterface/<renderer>.m                 drawing for a registered preview model
    ++workbench/present.m dynamic snapshot assembly ++<workflowCapability>/layoutSection.m feature-owned controls ++<workflowCapability>/present.m feature-owned snapshot fragment ++<workflowCapability>/draw.m feature-owned plot renderer

    definition.m is the single product contract. It owns identity, display metadata, App version, facade requirements, layout, and references to any optional capabilities. projectSpec.m is the one durable-schema entry and keeps its create, validate, and version-aware migrate functions local. Runtime owns the migration loop. Do not add separate requirements.m, version.m, generic +appLifecycle or +appState packages, or per-version migration files.

    Add workflow packages only when the App has that user-facing capability:

    +sourceFiles/     choosing, reading, validating, and previewing source data
    @@ -69,17 +70,30 @@ 

    App Package Shape

    +cropGeometry/ app-owned crop geometry operations +thermalFrames/ app-owned thermal frame queues and display choices +debugArtifacts/ app-owned clean-room sample and debug artifact generation
    -

    Create only the packages the app needs. Names should describe a workflow or domain capability that changes together, not a broad technical phase. Avoid generic +actions, +renderers, +ops, +io, +export, +helpers, and +utils packages for new app code. Avoid fixed +app namespaces, family-level private/ helpers, *Workflow.m string dispatchers, and +core/dispatch.m routers.

    +

    Create only the packages the app needs. Names should describe a workflow or domain capability that changes together, not a broad technical phase. Avoid generic +actions, +renderers, +ops, +io, +ui, +userInterface, +view, +export, +helpers, and +utils packages for new app code. Avoid fixed +app namespaces, family-level private/ helpers, *Workflow.m string dispatchers, and +core/dispatch.m routers.

    +state, +actions, +ui, +view, +ops, +io, and +export packages were retired with the workflow-first migration. Current app work should follow the workflow-first shape.

    -

    UI Boundary

    -

    App GUIs use the layered UI foundation:

    -
    LayerApp-facing API
    Runtimelabkit.ui.runtime.launch, define, emptySourceRecords, sourceRecord, sourcePaths, saveState, loadState, portable source references, and source-adjacent output defaults; the runtime privately owns request dispatch, queueing, resources, presentation, interactions, recovery, diagnostics, and result manifests.
    Layoutlabkit.ui.layout.workbench, workspace, tab, section, group, field, rangeField, panner, action, filePanel, previewArea, resultTable, logPanel, statusPanel
    PlotAdvanced renderer helpers: clear, fit, fitCanvas, offsetData, clampData, message
    InteractionGUI-free anchorPath, scaleBarCalibration, scaleBarGeometry, plus enablePopout; editor/runtime objects are private.
    +

    App SDK Boundary

    +

    App GUIs use the explicit labkit.app SDK:

    +
    LayerApp-facing API
    Definitionlabkit.app.Definition owns identity, requirements, optional project/session boundaries, workbench, presentation, and launch.
    Layoutlabkit.app.layout.* owns semantic controls, containers, workspace pages, direct callbacks, bindings, and plot renderers.
    Viewlabkit.app.view.Snapshot owns complete derived visible state and prepared renderer models.
    Callback boundaryTyped labkit.app.event.* values and sealed, specifically named labkit.app.CallbackContext operations.
    Optional contractslabkit.app.project.*, result.*, and dialog.*.

    Reusable facades publish MATLAB-native contract versions through their version() APIs. Apps declare required facade ranges in the Requirements field of definition.m, and labkit.contract checks those ranges in tests and at launch. This is a same-repo maintenance guardrail; routine users still update LabKit as one repository.

    Apps publish AppVersion and Updated metadata from the same definition for the launcher and window title. App versions are not dependency constraints and do not belong in labkit.contract. Project guardrails check X.Y.Z format and require versioned code changes to increase the corresponding definition, launcher, or facade version. The release guide explains how to select and publish the next version.

    Image workflows may use labkit.image for generic image file filters, source image reads, display-name normalization, RGB double conversion, preview-size fitting, mean filtering, basic enhancement primitives, and image writes. Apps still own processing step semantics, ROI/background policy, matching formulas, crop geometry, focus-stack algorithms, DIC behavior, export schemas, and user workflow text.

    Thermal workflows may use labkit.thermal for radiometric source reads, embedded calibration metadata, raw thermal matrices, Celsius conversion, and linear thermal palette rendering. Apps still own file queues, display-range defaults, log/gamma display-mapping controls, export manifests, colorbar placement, overlay-removal workflow wording, measurements, and user-facing decisions. Generic image IO and filters stay in labkit.image; thermal file parsing and raw-to-temperature mechanics stay in labkit.thermal.

    -

    definition.m returns the app runtime contract. It names the project schema, optional session factory, data-only layout builder, handler registry, presenter, renderers, and optional Start. The framework runtime validates the definition, generates semantic callbacks, builds the shell, owns the event queue and readiness/busy state, routes diagnostics, and protects hidden test behavior.

    -

    +userInterface/buildWorkbenchLayout.m returns a data-only labkit.ui.layout.* tree. It should not create MATLAB UI handles, mutate app state, perform IO, run calculations, write exports, schedule startup, or set row/column layout mechanics. App command handlers own app-specific state changes, alerts, refresh decisions, and log wording. +userInterface/presentWorkbench.m is the pure bridge from canonical state to semantic control properties, prepared preview models, and controlled interaction specs.

    +

    definition.m returns the app runtime contract. Layout nodes own concrete callbacks and renderers, so Apps maintain no parallel registries. The framework compiles the static graph, builds the shell, owns the transactional event queue, persistence, resources, diagnostics, and private native adapter.

    +

    +workbench/buildLayout.m should read as the product's user workflow. Capability packages own their controls, state transitions, prepared view models, rendering, alerts, and wording. +workbench/present.m extracts exact state inputs and composes those feature fragments; it is not a second monolithic implementation of the App.

    +

    The state funnel

    +

    The runtime commits one canonical value containing project and session, but that value is an SDK transaction envelope, not the App's domain model. Keep it at the edge:

    +
    layout signal
    +    -> capability action(applicationState, event, callbackContext)
    +        -> calculation(exact scientific inputs)
    +        -> writer(exact result and destination inputs)
    +
    +runtime presentation
    +    -> workbench.present(applicationState)
    +        -> capability.present(exact visible inputs)
    +            -> renderer(axes, prepared model)
    +

    Only createSession, workbench.present, OnStart, and a function bound directly to a layout signal accept the complete envelope. Those functions name it applicationState, expose the typed event and callbackContext in their signatures, and unpack it immediately. Feature presenters, renderers, calculations, writers, and local helpers receive named narrow inputs.

    +

    Do not introduce a second App object, service bag, callback registry, or feature-wide context type to hide these inputs. If a calculation needs five scientific values, list those five values. If several always travel together and form a stable app-owned concept, give that concept a semantic struct and validate it at its owner.

    Reusable Extraction Rule

    A helper may move into +labkit only when all of these are true:

      @@ -98,7 +112,7 @@

      Extraction Quality

      Validation Boundary

      The default automated validation boundary is:

      buildtool headless
      -

      This covers project contracts, reusable facade behavior, and non-GUI app helper behavior. GUI checks cover launch, layout, callback wiring, trace plumbing, reusable tool lifecycle, and hidden synthetic app workflows. Manual MATLAB review is still required for full interactive workflow feel.

      +

      This covers project contracts, reusable facade behavior, and non-GUI app helper behavior. GUI checks cover launch, layout, callback wiring, trace plumbing, reusable tool lifecycle, and hidden synthetic app workflows. Manual MATLAB review is still required for full interactive workflow feel.

      Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/development/build-apps/complete-app.html b/site/development/build-apps/complete-app.html index f01ef71e4..07afc47f6 100644 --- a/site/development/build-apps/complete-app.html +++ b/site/development/build-apps/complete-app.html @@ -18,262 +18,174 @@
    - +

    guide

    Build A Complete App

    -

    App development | Runtime field reference | Testing

    -

    This tutorial builds one current Runtime V2 App without copying historical lifecycle adapters. The example opens one numeric trace, applies a gain, previews the result, and keeps a summary in project state.

    -

    The important rule is progressive capability: start with three files and add state or workflow files only when the product actually needs them.

    -

    The Three-File Starting Point

    -

    A static App needs only:

    -
    apps/example/trace_viewer/
    -|-- labkit_TraceViewer_app.m
    -`-- +trace_viewer/
    -    |-- definition.m
    -    `-- +userInterface/
    -        `-- buildWorkbenchLayout.m
    -

    labkit.ui.runtime.define supplies an empty version-1 project, empty session, empty action registry, empty presenter, and no Start callback. A static layout therefore launches without projectSpec.m, createSession.m, definitionActions.m, or presentWorkbench.m.

    -

    The trace example is interactive and persistent, so its completed tree is:

    -
    apps/example/trace_viewer/
    +

    This guide builds a small trace viewer with the production labkit.app contract. The example keeps durable settings in a project, rebuilds transient file data in a session, draws a plot, and exports a result. Each file has one visible responsibility.

    +

    File Shape

    +
    apps/examples/trace_viewer/
     |-- labkit_TraceViewer_app.m
     `-- +trace_viewer/
         |-- definition.m
         |-- projectSpec.m
         |-- createSession.m
    -    |-- definitionActions.m
    -    |-- +sourceFiles/
    -    |   `-- readTrace.m
    -    |-- +analysisRun/
    -    |   `-- applyGain.m
    -    `-- +userInterface/
    -        |-- buildWorkbenchLayout.m
    -        |-- presentWorkbench.m
    -        `-- renderTrace.m
    -

    There is no separate requirements.m, version.m, +appLifecycle, +appState, or migration-file collection. Product metadata and optional capabilities live in definition.m; durable schema callbacks are local functions inside one projectSpec.m.

    -

    1. Add The Thin Entrypoint

    -

    labkit_TraceViewer_app.m delegates every request to the definition:

    + |-- +workbench/ + | |-- buildLayout.m + | `-- present.m + |-- +sourceTrace/ + | |-- readTrace.m + | |-- layoutSection.m + | |-- workspacePlot.m + | |-- present.m + | `-- draw.m + `-- +resultFiles/ + |-- layoutSection.m + `-- exportTrace.m
    +

    There is no handler table, renderer registry, or +userInterface bucket. +workbench is the product assembly boundary. Capability packages own their part of the user workflow.

    +

    1. Thin Entrypoint

    function varargout = labkit_TraceViewer_app(varargin)
    -%LABKIT_TRACEVIEWER_APP Inspect a scaled numeric trace.
    -    [varargout{1:nargout}] = labkit.ui.runtime.launch( ...
    -        @trace_viewer.definition, varargin{:});
    +[varargout{1:nargout}] = trace_viewer.definition().launch(varargin{:});
     end
    -

    The entrypoint does not add paths, create figures, parse files, or route callbacks. The same command handles normal launch, debug, requirements, and version requests through Runtime V2.

    -

    2. Declare One Product Contract

    -

    +trace_viewer/definition.m owns product metadata, facade compatibility, and the capabilities this App actually uses:

    -
    function def = definition()
    -    def = labkit.ui.runtime.define( ...
    -        "Command", "labkit_TraceViewer_app", ...
    -        "Id", "trace_viewer", ...
    -        "Title", "Trace Viewer", ...
    -        "Family", "Example", ...
    -        "AppVersion", "1.0.0", ...
    -        "Updated", "2026-07-16", ...
    -        "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ...
    -        "Project", trace_viewer.projectSpec(), ...
    -        "CreateSession", @trace_viewer.createSession, ...
    -        "Layout", @trace_viewer.userInterface.buildWorkbenchLayout, ...
    -        "Actions", trace_viewer.definitionActions(), ...
    -        "Present", @trace_viewer.userInterface.presentWorkbench, ...
    -        "Renderers", struct( ...
    -            "trace", @trace_viewer.userInterface.renderTrace));
    +

    The entrypoint does not build state, controls, services, or figures.

    +

    2. One App Definition

    +
    function app = definition()
    +app = labkit.app.Definition( ...
    +    Entrypoint="labkit_TraceViewer_app", ...
    +    AppId="examples.trace-viewer", ...
    +    Title="Trace Viewer", ...
    +    Family="Examples", ...
    +    AppVersion="1.0.0", ...
    +    Updated="2026-07-19", ...
    +    Requirements=labkit.contract.requirements("app", ">=1 <2"), ...
    +    ProjectSchema=trace_viewer.projectSpec(), ...
    +    CreateSession=@trace_viewer.createSession, ...
    +    Workbench=trace_viewer.workbench.buildLayout(), ...
    +    PresentWorkbench=@trace_viewer.workbench.present);
     end
    -

    Id is the permanent persistence identity, not a display label. Do not change it after project files exist. AppVersion versions the product; project payload Version below versions only the durable data schema.

    -

    For the three-file static form, omit Project, CreateSession, Actions, Present, and Renderers from this call.

    -

    3. Own Durable Data In One Project Spec

    -

    +trace_viewer/projectSpec.m contains the public project-spec entry and local schema functions:

    -
    function spec = projectSpec()
    -    spec = struct( ...
    -        "Version", 1, ...
    -        "Create", @createProject, ...
    -        "Validate", @validateProject, ...
    -        "Migrate", []);
    +

    Definition is a readable inventory, not an execution script. It validates the static layout, direct callback signatures, plot renderers, target IDs, project schema, and presentation contract before native UI mutation.

    +

    3. Durable Project

    +
    function schema = projectSpec()
    +schema = labkit.app.project.Schema( ...
    +    Version=1, ...
    +    Create=@createProject, ...
    +    Validate=@validateProject);
     end
     
     function project = createProject()
    -    project = struct();
    -    project.inputs = struct( ...
    -        "sources", labkit.ui.runtime.emptySourceRecords());
    -    project.parameters = struct("gain", 1);
    -    project.annotations = struct();
    -    project.results = struct("summary", table());
    -    project.extensions = struct();
    +project = struct( ...
    +    "inputs", struct("sources", struct([])), ...
    +    "parameters", struct("gain", 1), ...
    +    "results", struct("lastExport", []));
     end
     
     function accepted = validateProject(project)
    -    gain = project.parameters.gain;
    -    assert(isnumeric(gain) && isscalar(gain) && isfinite(gain), ...
    -        "trace_viewer:InvalidProject", ...
    -        "Trace Viewer gain must be one finite numeric scalar.");
    -    assert(istable(project.results.summary), ...
    -        "trace_viewer:InvalidProject", ...
    -        "Trace Viewer summary must be a table.");
    -    accepted = true;
    +accepted = isstruct(project) && isscalar(project) && ...
    +    isfield(project, "inputs") && isfield(project.inputs, "sources") && ...
    +    isfield(project, "parameters") && ...
    +    isnumeric(project.parameters.gain) && ...
    +    isscalar(project.parameters.gain) && ...
    +    isfinite(project.parameters.gain);
     end
    -

    Runtime validates the scalar project, its five canonical buckets, and standard source-record structure before this callback. The App callback therefore checks only Trace Viewer fields and invariants: gain and summary in this example. Runtime validates the complete value after creation, load, migration, and every action transaction. A validator checks; it does not repeat framework checks, repair, prompt, or access graphics.

    -

    When the schema later advances to version 2, change Version and provide one local version-aware function:

    -
    function project = migrateProject(project, fromVersion)
    -    switch fromVersion
    -        case 1
    -            project.annotations.note = "";
    -        otherwise
    -            error("trace_viewer:UnsupportedProjectVersion", ...
    -                "Cannot migrate payload version %d.", fromVersion);
    -    end
    +

    The project contains only durable App meaning. External files are portable source records owned by the runtime, not raw paths.

    +

    4. Transient Session

    +
    function session = createSession(project, callbackContext)
    +paths = callbackContext.resolveSourcePaths(project.inputs.sources);
    +trace = struct("x", [], "y", []);
    +if ~isempty(paths)
    +    trace = trace_viewer.sourceTrace.readTrace(paths(1));
    +end
    +session = struct("trace", trace);
     end
    -

    Set "Migrate", @migrateProject. Runtime calls it once per missing version and validates every returned payload. Do not create migrateProjectV1ToV2.m, migrateProjectV2ToV3.m, and similar files.

    -

    4. Rebuild Only App-Specific Session Data

    -

    +trace_viewer/createSession.m reconstructs data that should not be saved:

    -
    function session = createSession(project)
    -    sourcePath = labkit.ui.runtime.sourcePaths( ...
    -        project.inputs.sources, "trace");
    -    rawTrace = zeros(0, 1);
    -    if strlength(sourcePath) > 0
    -        rawTrace = trace_viewer.sourceFiles.readTrace(sourcePath);
    -    end
    -    [scaledTrace, ~] = trace_viewer.analysisRun.applyGain( ...
    -        rawTrace, project.parameters.gain);
    -    session = struct("cache", struct( ...
    -        "sourcePath", sourcePath, ...
    -        "rawTrace", rawTrace, ...
    -        "scaledTrace", scaledTrace));
    +

    createSession(project,callbackContext) is the one reconstruction boundary. The runtime calls it after project restore and file-list source changes. Session data is reconstructible and is not written into the project envelope.

    +

    5. Product Layout

    +
    function layout = buildLayout()
    +controls = { ...
    +    trace_viewer.sourceTrace.layoutSection(), ...
    +    trace_viewer.resultFiles.layoutSection()};
    +workspace = labkit.app.layout.workspace( ...
    +    trace_viewer.sourceTrace.workspacePlot());
    +layout = labkit.app.layout.workbench( ...
    +    controls, Workspace=workspace);
     end
    -

    Runtime supplies missing selection, workflow, view, and cache buckets. Return only App-specific fields. During project load, required portable sources are resolved or interactively relinked before this function runs.

    -

    Graphics handles, readers, listeners, timers, and cleanup callbacks do not belong in project or session values. Register them with Runtime resources.

    -

    5. Implement GUI-Free Workflow Functions

    -

    +trace_viewer/+sourceFiles/readTrace.m owns the accepted input:

    -
    function trace = readTrace(filepath)
    -    filepath = string(filepath);
    -    if ~isscalar(filepath) || strlength(filepath) == 0 || ~isfile(filepath)
    -        error("trace_viewer:SourceNotFound", ...
    -            "The selected trace file was not found.");
    -    end
    -    value = readmatrix(filepath);
    -    if ~isnumeric(value) || isempty(value)
    -        error("trace_viewer:InvalidSource", ...
    -            "The trace file must contain numeric values.");
    -    end
    -    trace = double(value(:, 1));
    +

    The assembly reads in user order. A complex App can use tabs and workspace pages here without flattening every feature into one file.

    +

    The source capability owns its controls:

    +
    function section = layoutSection()
    +section = labkit.app.layout.section("sourceTrace", "Trace", { ...
    +    labkit.app.layout.fileList("traceFiles", ...
    +        Label="Trace file", ...
    +        SelectionMode="single", ...
    +        Bind="project.inputs.sources", ...
    +        SourceRole="trace", ...
    +        SourceIdPrefix="trace"), ...
    +    labkit.app.layout.slider("gain", ...
    +        Label="Gain", Limits=[0.1 10], ...
    +        Bind="project.parameters.gain")});
     end
    -

    +trace_viewer/+analysisRun/applyGain.m owns the deterministic calculation:

    -
    function [scaled, summary] = applyGain(trace, gain)
    -    trace = double(trace(:));
    -    gain = double(gain);
    -    if ~isscalar(gain) || ~isfinite(gain)
    -        error("trace_viewer:InvalidGain", ...
    -            "Gain must be one finite scalar.");
    -    end
    -    scaled = trace .* gain;
    -    summary = table(numel(scaled), mean(scaled, "omitnan"), ...
    -        "VariableNames", {"sample_count", "mean_value"});
    +

    Standard fields and file lists need no App callback. Their bindings update canonical state transactionally.

    +

    The result capability binds a real business action directly:

    +
    function section = layoutSection()
    +section = labkit.app.layout.section("resultFiles", "Results", { ...
    +    labkit.app.layout.button("exportTrace", ...
    +        "Export trace", @trace_viewer.resultFiles.exportTrace, ...
    +        Tooltip="Export the calibrated trace and its sampling metadata.")});
     end
    -

    Both functions are directly testable without launching the GUI.

    -

    6. Register Semantic Actions

    -

    +trace_viewer/definitionActions.m maps layout event IDs to transactions:

    -
    function actions = definitionActions()
    -    actions = struct( ...
    -        "openTrace", @onOpenTrace, ...
    -        "gainChanged", @onGainChanged, ...
    -        "runAnalysis", @onRunAnalysis);
    -end
    -
    -function state = onOpenTrace(state, ~, services)
    -    [filepath, cancelled] = services.dialogs.inputFile( ...
    -        "*.txt;*.csv", "Open numeric trace", ...
    -        services.dialogs.defaultFolder("input"));
    -    if cancelled
    -        return;
    -    end
    -    rawTrace = trace_viewer.sourceFiles.readTrace(filepath);
    -    state.project.inputs.sources = services.project.upsertSource( ...
    -        state.project.inputs.sources, ...
    -        "trace", "trace", filepath, true);
    -    state.session.cache.sourcePath = filepath;
    -    state.session.cache.rawTrace = rawTrace;
    -    state = recompute(state);
    -    state = services.workflow.log(state, "Opened trace: " + filepath);
    -end
    -
    -function state = onGainChanged(state, ~, ~)
    -    state = recompute(state);
    +

    6. Complete View Snapshot

    +
    function view = present(applicationState)
    +view = labkit.app.view.Snapshot();
    +view = view.include(trace_viewer.sourceTrace.present( ...
    +    applicationState.session.trace, ...
    +    applicationState.project.parameters.gain));
    +view = view.enabled("exportTrace", ...
    +    ~isempty(applicationState.session.trace.x));
    +end
    +

    +workbench/present.m is a short assembly boundary. It extracts exact inputs and composes feature fragments with Snapshot.include; it does not perform IO or calculation.

    +
    function view = present(trace, gain)
    +model = struct("x", trace.x, "y", trace.y .* gain);
    +view = labkit.app.view.Snapshot().renderPlot("tracePlot", model);
    +end
    +

    7. Feature-Owned Renderer

    +
    function node = workspacePlot()
    +node = labkit.app.layout.plotArea( ...
    +    "tracePlot", @trace_viewer.sourceTrace.draw);
     end
     
    -function state = onRunAnalysis(state, ~, services)
    -    state = recompute(state);
    -    state = services.workflow.log(state, "Updated trace summary.");
    +function draw(axesById, model)
    +ax = axesById.main;
    +cla(ax);
    +plot(ax, model.x, model.y);
    +xlabel(ax, "Time (s)");
    +ylabel(ax, "Signal");
    +grid(ax, "on");
    +end
    +

    The plot area references the concrete renderer. The model carries prepared App meaning; drawing does not read project state or dispatch workflow actions.

    +

    8. Direct Business Callback

    +
    function applicationState = exportTrace( ...
    +        applicationState, callbackContext)
    +trace = applicationState.session.trace;
    +gain = applicationState.project.parameters.gain;
    +choice = callbackContext.chooseOutputFile( ...
    +    {"*.csv", "CSV files"}, "");
    +if choice.Cancelled
    +    return;
     end
     
    -function state = recompute(state)
    -    [scaled, summary] = trace_viewer.analysisRun.applyGain( ...
    -        state.session.cache.rawTrace, state.project.parameters.gain);
    -    state.session.cache.scaledTrace = scaled;
    -    state.project.results.summary = summary;
    -end
    -

    The App owns what each command means. Runtime owns queueing, busy state, rollback, validation, presentation commit, and error propagation. Use injected services for dialogs, logging, source identity, results, diagnostics, previews, and managed resources; do not read registries or UI controls.

    -

    7. Build A Data-Only Layout

    -

    +trace_viewer/+userInterface/buildWorkbenchLayout.m describes semantics:

    -
    function layout = buildWorkbenchLayout()
    -    commands = labkit.ui.layout.section("commands", "Commands", { ...
    -        labkit.ui.layout.action("openTrace", "Open trace", "openTrace"), ...
    -        labkit.ui.layout.field("gain", "Gain", ...
    -            "kind", "number", ...
    -            "value", 1, ...
    -            "Bind", "project.parameters.gain", ...
    -            "Event", "gainChanged"), ...
    -        labkit.ui.layout.action( ...
    -            "runAnalysis", "Run analysis", "runAnalysis")});
    -    tab = labkit.ui.layout.tab("main", "Trace", {commands});
    -    workspace = labkit.ui.layout.workspace("workspace", "Trace", { ...
    -        labkit.ui.layout.previewArea("tracePlot", "Scaled trace"), ...
    -        labkit.ui.layout.resultTable("summary", "Summary"), ...
    -        labkit.ui.layout.logPanel("appLog", "Log")});
    -    layout = labkit.ui.layout.workbench( ...
    -        "traceViewer", "Trace Viewer", ...
    -        "controlTabs", {tab}, "workspace", workspace);
    -end
    -

    The layout does not create handles, read files, mutate state, choose pixel geometry, or schedule callbacks. IDs are stable semantic references shared by the layout, action registry, and presenter.

    -

    8. Present One Committed View

    -

    +trace_viewer/+userInterface/presentWorkbench.m converts canonical state to declarative view models:

    -
    function view = presentWorkbench(state)
    -    trace = state.session.cache.scaledTrace;
    -    view = struct();
    -    view.controls.summary = struct( ...
    -        "Data", state.project.results.summary);
    -    view.previews.tracePlot = struct( ...
    -        "Renderer", "trace", ...
    -        "Model", struct( ...
    -            "x", (1:numel(trace)).', ...
    -            "y", trace, ...
    -            "hasData", ~isempty(trace)));
    -end
    -

    +trace_viewer/+userInterface/renderTrace.m owns drawing only:

    -
    function renderTrace(ax, model)
    -    labkit.ui.plot.clear(ax, "ResetScale", true);
    -    if ~model.hasData
    -        labkit.ui.plot.message(ax, "Open a trace to begin.");
    -        return;
    -    end
    -    plot(ax, model.x, model.y, "LineWidth", 1.2);
    -    xlabel(ax, "Sample");
    -    ylabel(ax, "Scaled value");
    -    grid(ax, "on");
    -    labkit.ui.plot.fit(ax);
    +tableValue = table(trace.x(:), trace.y(:) .* gain, ...
    +    VariableNames=["Time", "Signal"]);
    +writetable(tableValue, choice.Value);
    +applicationState.project.results.lastExport = string(choice.Value);
     end
    -

    Presenters do not write files or mutate state. Renderers do not choose scientific options or reset zoom for overlay-only edits.

    -

    9. Validate The Product

    -

    At minimum, add:

    +

    The callback signature exposes its runtime boundary. For larger exports, delegate table construction and result packaging to functions that accept only the trace, gain, and destination they require.

    +

    Validation

    +

    Test readers, calculations, project validation, snapshot fragments, renderers, and exports directly with synthetic values. Construct definition() in a headless test to validate the whole static contract. Run the App's bounded hidden-GUI workflow after smaller tests are stable; native dialogs and visual quality still require developer-led interactive validation.

    +

    Before merge, update the App version, owning manual, structured component history, generated documentation site, and the final branch validation gates.

    +
      -
    • GUI-free tests for readTrace and applyGain
    • -
    • definition tests for metadata, project creation, validation, and session reconstruction
    • -
    • one hidden GUI workflow covering launch, open, gain change, analysis, save, clear, and reopen
    • -
    • migration tests for every supported prior payload version
    • -
    • documentation examples that run in a clean MATLAB session
    • -
    -

    During iteration, run the exact affected files. Use the project test planner only at a coherent checkpoint; follow Testing for supported commands and the distinction between hidden GUI coverage and manual native dialog/interaction checks.

    -

    Capability Checklist

    -

    Add a file only when the answer is yes:

    -
    NeedAdd
    Static product metadata and layoutdefinition.m, layout builder
    User commands or bound eventsdefinitionActions.m
    Dynamic control/preview modelspresenter and any renderers
    Durable App-owned dataprojectSpec.m
    Rebuilt decoded/cache/selection statecreateSession.m
    Saved schema has changedlocal migrateProject in projectSpec.m
    Legacy top-level MAT variable is supportedlocal importer declared by LegacyImports
    Post-layout request/resource initializationa semantically named optional Start function
    -

    This is the current Runtime V2 architecture. Older split metadata files, generic lifecycle packages, and per-version migration files are retired, not alternative supported styles.

    +
  • App Development
  • +
  • Architecture
  • +
  • LabKit App SDK
  • +
  • Testing
  • +
    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/development/maintain-and-release/private-apps.html b/site/development/maintain-and-release/private-apps.html index aea262692..123f4ffc9 100644 --- a/site/development/maintain-and-release/private-apps.html +++ b/site/development/maintain-and-release/private-apps.html @@ -41,11 +41,10 @@

    Repository And Mounting Model

    labkit_<PrivateAppName>_app.m +<app_slug>/ definition.m - +userInterface/buildWorkbenchLayout.m - definitionActions.m optional + +workbench/buildLayout.m projectSpec.m optional createSession.m optional - +userInterface/presentWorkbench.m optional + +workbench/present.m optional +sourceFiles/... as needed +analysisRun/... as needed +resultFiles/... as needed
    @@ -73,12 +72,12 @@

    Structure Rules

  • keep one public entrypoint named labkit_<PrivateAppName>_app.m
  • keep app workflow code under the owning +<app_slug>/ package
  • use one definition.m for product identity, version, requirements, layout, and references to optional capabilities
  • -
  • start with only the entrypoint, definition, and data-only layout
  • -
  • add definitionActions.m only for semantic interactions and a pure presenter only for dynamic views
  • +
  • start with only the entrypoint, definition, and semantic workbench layout
  • +
  • bind callbacks and plot renderers directly on their owning layout controls; add +workbench/present.m only for dynamic visible state
  • add one projectSpec.m only for durable App-owned state; keep create, validate, and version-aware migrate functions local to that file
  • add root createSession.m only for App-specific transient reconstruction
  • group workflow code by concrete user capability, such as +sourceFiles, +analysisRun, +resultFiles, or another app-owned domain package
  • -
  • use shared LabKit facades such as labkit.ui.*, labkit.image.*, labkit.thermal.*, labkit.dta.*, labkit.rhs.*, and labkit.biosignal.*
  • +
  • use shared LabKit facades such as labkit.app.*, labkit.image.*, labkit.thermal.*, labkit.dta.*, labkit.rhs.*, and labkit.biosignal.*
  • Do not add separate requirements.m, version.m, generic +appLifecycle or +appState packages, or per-version migrateProjectVx.m files. Supported legacy top-level MAT variables use explicit import functions declared by the project spec. See Build a Complete App for a working file-by-file example and Runtime and Lifecycle for every definition, project, session, action, presenter, and renderer field.

    Do not put private app source under public apps/. Do not move private workflow formulas, private result schemas, private labels, or private data assumptions into +labkit.

    diff --git a/site/development/maintain-and-release/testing.html b/site/development/maintain-and-release/testing.html index 880cf468f..302c0fab0 100644 --- a/site/development/maintain-and-release/testing.html +++ b/site/development/maintain-and-release/testing.html @@ -18,7 +18,7 @@
    - +

    guide

    Testing

    @@ -182,11 +182,11 @@

    Artifacts

    Build tasks set LABKIT_ARTIFACTS while tests run, so apps launched in debug mode write their trace files into the same artifact root:

    artifacts/debug/<RunName>/<AppName>/<SessionId>/

    Coverage is report-only and not part of the default local check.

    -

    Runtime V2 Contract Coverage

    +

    App SDK Runtime Contract Coverage

    The workflow-first app contract is covered by layered tests, not by a single launch-only suite:

      -
    • AppPackageStructureGuardrailTest discovers every apps/**/labkit_*_app.m entrypoint, requires the canonical definition.m and +userInterface/buildWorkbenchLayout.m, verifies references to optional action/project/session/presentation capabilities when present, and rejects retired metadata files, generic lifecycle/state packages, per-version migration files, package-root app runners, and broad app buckets such as +actions, +state, +ui, +view, +ops, +io, and +export.
    • -
    • GuiLayoutUiRuntimeV2Test owns queued dispatch, canonical state, presentation commits, service injection, rollback, and Start behavior. GuiLayoutUiRuntimeV2ProjectTest owns project persistence, migrations, source relinking, and read-only legacy snapshot imports.
    • +
    • AppPackageStructureGuardrailTest discovers every apps/**/labkit_*_app.m entrypoint, requires the canonical definition.m and +workbench/buildLayout.m, verifies references to optional action/project/session/presentation capabilities when present, and rejects retired metadata files, generic lifecycle/state packages, per-version migration files, package-root app runners, and broad app buckets such as +actions, +state, +ui, +view, +ops, +io, and +export.
    • +
    • UiRuntimeKernelTest owns queued dispatch, canonical state, presentation commits, callback-capability injection, rollback, and OnStart behavior. UiProjectDocumentStoreTest owns project persistence, migrations, source relinking, and read-only legacy snapshot imports. UiMatlabPlatformAdapterTest owns the native component and interaction adapter boundary.
    • AppOwnedWorkflowBoundariesTest and AppLibraryCompatibilityTest keep app workflow code under the owning app tree and prevent apps from depending on removed helper-dump or old UI surfaces.
    • App GUI workflow tests should cover semantic controls, enabled states, workflow outcomes, debug traces, and exported results. Do not add broad launch-only coverage for every app when a real workflow or guardrail already covers the contract.
    • Debug sample-pack tests cover clean-room debug artifacts. Profiler evidence is used for performance regressions and should not replace correctness assertions.
    • diff --git a/site/framework/compatibility/contracts.html b/site/framework/compatibility/contracts.html index e774132e4..dc1ab616d 100644 --- a/site/framework/compatibility/contracts.html +++ b/site/framework/compatibility/contracts.html @@ -18,7 +18,7 @@
      - +

      guide

      Framework Compatibility Contracts

      @@ -80,7 +80,7 @@
    • App development explains how one App definition owns facade requirements and product version metadata.
    • Architecture describes the public LabKit modules that can appear in a requirement list.
    • Project history records API version and compatibility changes.
    • -
    +
    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/framework/guides/runtime.html b/site/framework/guides/runtime.html index cb07b875b..e7ef8ebc4 100644 --- a/site/framework/guides/runtime.html +++ b/site/framework/guides/runtime.html @@ -18,233 +18,145 @@
    - +

    guide

    Runtime And Lifecycle

    -

    Public API index | App development

    -

    This page explains how a LabKit app is declared, launched, updated, saved, and debugged. The current runtime manages queued events, project and session state, presentation, resources, interactions, saved projects, and result manifests. Old runtime snapshots can still be imported, but new apps use the current lifecycle exclusively.

    -

    labkit.ui is divided into these public packages:

    -
    PackageOwnsMain APIs
    labkit.ui.runtimeLaunch, canonical state, queued events, services, projects, and resources.launch, define, emptySourceRecords, sourceRecord, sourcePaths, saveState, loadState, defaultOutputFolder.
    labkit.ui.layoutData-only semantic workbench layouts.workbench, workspace, tab, section, group, field, rangeField, panner, action, filePanel, previewArea, resultTable, logPanel, statusPanel.
    labkit.ui.plotAdvanced renderer viewport and coordinate mechanics.clear, fit, fitCanvas, message, offsetData, clampData.
    labkit.ui.interactionManaged-interaction calculation helpers and popout enablement.anchorPath, scaleBarCalibration, scaleBarGeometry, enablePopout.
    -

    Apps call the package that provides the behavior they need. The earlier flat labkit.ui.* helper surface is no longer supported, and implementation details inside each package's private/ folder are not public APIs.

    -

    labkit.ui.version() returns the UI framework's version and compatibility information for labkit.contract requirement checks.

    -

    Declarative App Runtime

    -

    Definition And Launch

    -

    The UI surface makes app code read as a semantic description of a LabKit workflow, not as grid construction or a general MATLAB GUI DSL. Apps expose an app-owned definition.m and launch it through labkit.ui.runtime.launch. The framework owns lifecycle, callback dispatch, readiness, busy state, diagnostics, persistence, and resources. App packages declare durable project data, transient session data, workflow handlers, presentation, and data-only UI structure.

    -

    Public launch files stay thin. They delegate requests and GUI creation to the single app-owned definition:

    +

    labkit.app is the App-facing runtime contract. Apps declare product meaning; the private runtime owns native MATLAB components, event serialization, transactions, project documents, portable sources, resources, and cleanup.

    +

    Definition And Launch

    +

    Each App-owned definition.m is the single immutable product contract, and each entrypoint delegates to it:

    function varargout = labkit_Example_app(varargin)
    -    [varargout{1:nargout}] = labkit.ui.runtime.launch( ...
    -        @example.definition, varargin{:});
    +[varargout{1:nargout}] = example.definition().launch(varargin{:});
     end
    -
    function def = definition()
    -def = labkit.ui.runtime.define( ...
    -    "Command", "labkit_Example_app", ...
    -    "Id", "example", ...
    -    "Title", "Example App", ...
    -    "Family", "Examples", ...
    -    "AppVersion", "1.0.0", ...
    -    "Updated", "2026-07-16", ...
    -    "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ...
    -    "Layout", @example.userInterface.buildWorkbenchLayout);
    +
    function app = definition()
    +app = labkit.app.Definition( ...
    +    Entrypoint="labkit_Example_app", ...
    +    AppId="examples.example", ...
    +    Title="Example", ...
    +    Family="Examples", ...
    +    AppVersion="1.0.0", ...
    +    Updated="2026-07-19", ...
    +    Requirements=labkit.contract.requirements("app", ">=1 <2"), ...
    +    Workbench=example.workbench.buildLayout(), ...
    +    ProjectSchema=example.projectSpec(), ...
    +    CreateSession=@example.createSession, ...
    +    PresentWorkbench=@example.workbench.present);
     end
    -

    definition.m is a small MATLAB-scale DSL made of structs and function handles. It is not a new language, a generator, or a class hierarchy. The framework validates the definition, creates canonical state, generates callbacks, builds the workbench, commits the first presentation, and queues the optional Start event. App-specific launch payloads are available read-only through injected services.

    -

    Definition Component Contract

    -

    labkit.ui.runtime.define accepts these components. Required means every app must provide the field; optional components may be omitted.

    -
    ComponentRequiredValue and signatureResponsibility
    CommandYesPublic MATLAB function nameStable launcher command and request-error identity.
    IdYesStable nonempty textPermanent project, recovery, result, and diagnostic identity.
    TitleYesNonempty textWindow title without the runtime-added version and file state.
    DisplayNameNoNonempty textShort launcher name. Default: Title.
    FamilyYesNonempty textApp family used for launcher grouping and documentation.
    AppVersionYesX.Y.Z textApp product version, independent of project payload version.
    UpdatedYesYYYY-MM-DD textDate of the App product version.
    RequirementsYeslabkit.contract.requirements(...)Compatible reusable LabKit facade ranges.
    ProjectNoScalar project-declaration struct described belowDurable schema, validation, migration, and source relinking. Omission uses an empty version-1 project.
    CreateSessionNosession = createSession() or session = createSession(project)Rebuild transient selection, workflow, view, and cache state.
    LayoutYeslayout(), layout(callbacks), or layout(callbacks,state)Return one data-only labkit.ui.layout.workbench tree.
    ActionsNoStruct mapping action IDs to state = action(state,event,services)Apply semantic workflow transactions. Default: no actions.
    PresentNoview = present(state)Convert canonical state into semantic control, preview, and interaction properties. Default: an empty model.
    RenderersNoStruct mapping renderer IDs to renderer(), renderer(model), or renderer(ax,model)Draw a prepared presenter model without changing workflow state.
    StartNoRegistered action ID or an action functionQueue optional initialization after the first visible presentation.
    DebugSampleNopack = writer(debugContext)Create synthetic local debug inputs only during a debug launch.
    UtilitiesNoScalar structConfigure framework-owned plot, screenshot, and state commands.
    -

    Project Declaration And Durable Payload

    -

    Project is not the saved payload itself. It is the declaration that tells the runtime how to create, validate, migrate, and restore that payload:

    -
    FieldRequiredContract
    VersionYesPositive integer payload version. Version 1 has no migration entries.
    CreateYesproject = createProject() returns a complete new durable payload.
    ValidateYesaccepted = validateProject(project) checks the App-owned schema and returns a logical scalar or throws a field-specific error. Runtime first validates the canonical buckets and standard source records.
    MigrateFor version > 1project = migrate(project,fromVersion) upgrades exactly one version. The runtime calls it repeatedly for every missing step.
    LegacyImportsNoStruct mapping a trusted top-level MAT variable name to project = import(value) or [project,resume] = import(value). Imports are read-only adapters.
    CreateResumeNoresume = createResume(session,project) saves small navigation convenience, never authoritative data.
    ApplyResumeNosession = applyResume(session,resume,project) applies that convenience after a fresh session is built.
    RelinkSourcesNoproject = relink(project,unresolved,projectFile) handles a nonstandard source schema. Returning [] cancels load.
    -

    createProject returns one scalar struct with the five canonical buckets:

    -
    function project = createProject()
    -project = struct();
    -project.inputs = struct( ...
    -    "sources", labkit.ui.runtime.emptySourceRecords());
    -project.parameters = struct("threshold", 0.5);
    -project.annotations = struct();
    -project.results = struct("analysis", table());
    -project.extensions = struct();
    +

    Required Definition arguments are product metadata, requirements, and one labkit.app.layout.workbench value. Optional callbacks are:

    +
    ArgumentSignaturePurpose
    CreateSessionsession = callback(project,callbackContext)Rebuild transient App data from durable project state.
    PresentWorkbenchview = callback(applicationState)Return the App-owned fragment of the complete visible snapshot.
    OnStartapplicationState = callback(applicationState,callbackContext)Perform a real post-first-commit request or resource initialization.
    BuildDebugSamplesample = callback(callbackContext)Build clean-room debug input when the App supports it.
    +

    Ordinary default state needs no startup callback. Exact syntax and errors are in the generated public API reference.

    +

    launch("requirements") and launch("version") answer metadata without creating a figure. launch() constructs the private native adapter and shows the App.

    +

    Static Workbench Contract

    +

    +workbench/buildLayout.m returns a tree composed from labkit.app.layout.* values. Layout IDs are stable semantic identifiers and must be globally unique.

    +
    function layout = buildLayout()
    +controls = { ...
    +    labkit.app.layout.section("parameters", "Parameters", { ...
    +        labkit.app.layout.field("gain", ...
    +            Label="Gain", Kind="numeric", ...
    +            Bind="project.parameters.gain"), ...
    +        labkit.app.layout.button("exportResult", ...
    +            "Export", @example.resultFiles.exportResult, ...
    +            Tooltip="Export the current analyzed result.")})};
    +workspace = labkit.app.layout.workspace( ...
    +    labkit.app.layout.plotArea( ...
    +        "previewPlot", @example.previewPlot.draw));
    +layout = labkit.app.layout.workbench( ...
    +    controls, Workspace=workspace);
     end
    -

    The bucket names are stable; their app-owned fields are not prescribed by the framework. inputs stores portable source records and durable input metadata; parameters stores reproducible analysis and export choices; annotations stores user-authored scientific edits; results stores reproducible results that should survive reopen; and extensions is reserved for explicit future schema additions. Decoded images, open readers, graphics, listeners, timers, dialogs, and service handles never belong in the payload.

    -

    The runtime saves this payload inside one labkitProject envelope. The envelope owns format and producer metadata, source portability, payload version, and optional resume state; apps own only their payload fields and do not construct the envelope themselves.

    -

    For a project at version N, one Migrate callback owns every supported step. When an older document is opened, the runtime calls it with the saved version, validates that returned payload is serializable, advances one version, and repeats until N. It then validates the complete current project, rebuilds the session, and keeps the document visibly dirty until the user saves it. A migration changes durable data only; it does not open dialogs, draw, dispatch actions, or invent scientific values that cannot be derived from the saved payload.

    -

    For example, a current version-3 app declares:

    -
    project = struct( ...
    -    "Version", 3, ...
    -    "Create", @createProject, ...
    -    "Validate", @validateProject, ...
    -    "Migrate", @migrateProject);
    -

    projectSpec.m normally owns the three local functions behind these handles. migrateProject(project,fromVersion) uses fromVersion to select exactly one step. A version-1 saved payload cannot already contain version-2 fields, so the case for fromVersion == 1 derives them before returning. New projects start from Create, and current projects skip migration.

    -

    Runtime verifies that inputs, parameters, annotations, results, and extensions are scalar structs before it invokes Validate. When inputs.sources exists, Runtime also verifies the complete portable source-record contract. App validators therefore check only their own required fields, legal values, cross-field relationships, source roles, and scientific constraints. Repeating canonical bucket or source-record checks in every App would give one framework contract two owners.

    -

    Portable Source Records

    -

    Apps create external-file references through the GUI-free labkit.ui.runtime.sourceRecord(id,role,sourceValue,required) factory. The source value is normally a filepath; a legacy importer may instead pass an existing portable reference as one opaque struct so the Runtime can preserve and validate it without exposing reference construction to the App. Action handlers may use the equivalent filepath-based injected services.project.sourceRecord(id,role,filepath,required) service. The pure factory is available to project creation, migration, legacy import, tests, and other code that does not run inside a callback. A durable record has stable App-facing identity fields plus a runtime-owned portable reference:

    -
    source = labkit.ui.runtime.sourceRecord( ...
    -    "trace", "numericTrace", selectedPath, true);
    -currentPath = labkit.ui.runtime.sourcePaths(source);
    -

    Do not inspect or construct fields inside source.reference. A legacy importer that already received such a reference passes the whole struct back to sourceRecord; all other App code reads resolved paths through sourcePaths.

    -
    FieldMeaning
    idApp-stable identity for this source inside the project. IDs are unique within the app's source collection and are not file names.
    requiredWhether project load must resolve the file before committing the loaded state.
    roleApp-owned semantic role such as numericTrace, referenceImage, or movingImage.
    referenceRuntime-owned portable location data. Apps preserve this value but do not read or construct its fields.
    -

    An App reads current file locations with labkit.ui.runtime.sourcePaths(sources). Supplying a source ID or ordered ID collection returns those paths in requested order; a semantic source slot not yet present returns an empty string. This pure accessor is valid inside CreateSession, actions, presenters, and GUI-free workflow functions, so App code does not depend on the portable-reference schema.

    -

    Immediately before each write, Runtime copies the durable project and rebases its portable references from that write's actual MAT-file destination. It preserves additive reference data and does not mutate the live project merely to serialize it.

    -

    For unordered file collections, the injected services.project.reconcileSources(existing,paths,role,idPrefix,required) service preserves existing IDs by resolved path and assigns stable nonempty IDs to newly selected files. An empty selection returns the canonical empty source array; Apps do not create a placeholder record with an empty ID.

    -

    Result manifests follow the same rule. Start a variable-length output list with services.results.emptyOutputs(), then append records returned by services.results.output(...). The output factory validates real IDs and is not a struct-template constructor.

    -

    The runtime first tries the saved relative and original locations. When a required source cannot be resolved, it identifies the source by app ID, role, and filename and lets the user locate a replacement. Cancelling leaves the currently open app state unchanged. Apps read the resolved location through labkit.ui.runtime.sourcePaths; they do not parse the portable reference or implement their own repeated file-dialog loop.

    -

    Session, Actions, Presentation, And Renderers

    -

    CreateSession returns one scalar struct containing only App-specific transient fields. Runtime adds any missing canonical buckets:

    -
    session = struct( ...
    -    "selection", struct("currentIndex", 1), ...
    -    "view", struct("mode", "Preview"), ...
    -    "cache", struct("decodedInput", []));
    -

    Do not return empty buckets or initialize workflow.logLines. The injected workflow service creates that framework-owned log on first use. An action that starts a completely new project calls services.project.newState() so project creation, session reconstruction, and canonical normalization follow the same Runtime path as initial launch.

    -

    Project loading resolves missing required sources before CreateSession. Cancelling that relink leaves the live document unchanged. Once a source path exists, its decoder must either return the transient value or throw; a session factory must not convert corrupt, unsupported, or programming failures into an empty cache. Runtime reports the exception with the inputs.sources IDs, roles, and filenames, preserves the previous state and presentation, and lets the Load State command show the failure. An optional source may be absent only when the App explicitly defines absence as valid; an existing optional file is still decoded strictly.

    -

    selection is current user focus, workflow is transient progress and log state, view is presentation convenience, and cache is rebuildable decoded or calculated data. Session values are never the sole owner of scientific inputs, parameters, annotations, or results.

    -

    Each action receives the complete state.project and state.session, one normalized event, and injected services. It returns the complete updated state. An action may coordinate app-owned reader or calculation functions, but does not read controls or mutate graphics. The runtime validates and commits the returned state atomically.

    -

    Present(state) returns a scalar view struct whose optional roots are controls, previews, and interactions. Control fields are addressed by layout ID and contain semantic properties such as Value, Enabled, Items, Limits, Data, Files, or Status. A single-axis preview may specify Renderer and Model directly; a multi-axis preview uses previews.<previewId>.Axes.<axisId>. Interaction entries declare managed interaction kinds, targets, values, and semantic event IDs.

    -

    A registered renderer receives only its prepared model and, when requested, the declared target axes. It may clear and draw that axes, but it does not change project/session state, open files, display workflow dialogs, or install figure callbacks. Renderer IDs in presentation must exactly match fields in Renderers; action and interaction event IDs must exactly match fields in Actions.

    -

    Static Apps may omit Project, CreateSession, Actions, and Present. Apps add one projectSpec.m only when they own durable schema or imports, root createSession.m only for App-specific transient reconstruction, and definitionActions.m plus presenter functions only when they own semantic interactions or dynamic views. Version-aware migration is one local function behind Project.Migrate; Runtime owns the ordered version loop. App-specific work belongs in concrete workflow packages such as +sourceFiles, +analysisRun, +resultFiles, or a domain-specific package. Separate requirements.m, version.m, generic +appLifecycle/+appState packages, per-version migration files, and the older +state, +actions, +ui, and +view adapters are retired.

    -
    function layout = buildWorkbenchLayout(callbacks)
    -layout = labkit.ui.layout.workbench("exampleApp", "Example App", ...
    -    "controlTabs", controlTabs(callbacks), ...
    -    "workspace", previewWorkspace(callbacks), ...
    -    "usage", {"Load input data.", "Run analysis.", "Review/export results."});
    -end
    -
    -function tabs = controlTabs(callbacks)
    -    tabs = {setupTab(callbacks), reviewTab(), logTab()};
    -end
    -
    -function tab = setupTab(callbacks)
    -    tab = labkit.ui.layout.tab("setup", "Setup", { ...
    -        labkit.ui.layout.section("actions", "Actions", { ...
    -            labkit.ui.layout.action("run", "Run", callbacks.run, ...
    -                "priority", "primary"), ...
    -            labkit.ui.layout.action("reset", "Reset", callbacks.reset)})});
    -end
    -
    -function tab = reviewTab()
    -    tab = labkit.ui.layout.tab("review", "Review", { ...
    -        labkit.ui.layout.section("results", "Results", { ...
    -            labkit.ui.layout.resultTable("resultsTable", "Results", ...
    -                "columns", {"Name", "Status"})})});
    -end
    -
    -function tab = logTab()
    -    tab = labkit.ui.layout.tab("log", "Log", { ...
    -        labkit.ui.layout.section("logSection", "Log", { ...
    -            labkit.ui.layout.logPanel("appLog", "Log")})});
    -end
    -
    -function workspace = previewWorkspace(callbacks)
    -    workspace = labkit.ui.layout.workspace("workspace", "Preview", { ...
    -        labkit.ui.layout.previewArea("preview", "Preview", ...
    -            "layout", "single", ...
    -            "viewModes", {"Input", "Output"}, ...
    -            "onModeChange", callbacks.previewModeChanged)});
    +

    Layout controls own direct callbacks. Plot areas own direct renderers. There is no handler, renderer, command, or capability registry for Apps to maintain. Definition compiles the immutable graph once and validates callback roles, renderer roles, IDs, bindings, and view capabilities before UI mutation.

    +

    Complex Apps keep the top-level workbench readable by composing capability-owned layoutSection, workspaceTable, or workspacePlot functions in user order.

    +

    State And Transactions

    +

    Runtime state always has two App-owned buckets:

    +
    applicationState.project
    +applicationState.session
    +

    project is durable, validated meaning. session is transient and reconstructible. A callback receives the previous complete state and returns a candidate complete state. The runtime:

    +
      +
    1. serializes the event;
    2. +
    3. invokes the direct callback;
    4. +
    5. validates project and session shape;
    6. +
    7. builds and validates the complete view snapshot;
    8. +
    9. reconciles native components;
    10. +
    11. publishes state and view together.
    12. +
    +

    Failure rolls back both state and presentation and clears event-scoped resources. Apps do not implement busy flags, callback queues, readiness timers, or figure close guards.

    +

    Use direct Bind="project...." or Bind="session...." paths for ordinary fields, ranges, sliders, file sources, and selection. Bound controls need no callback or presenter operation unless the App has additional derived meaning.

    +

    Portable Sources And Session Reconstruction

    +

    labkit.app.layout.fileList owns file/folder selection, portable source records, removal, clearing, and optional selection binding:

    +
    labkit.app.layout.fileList("sources", ...
    +    Filters=["*.csv", "CSV files"], ...
    +    Bind="project.inputs.sources", ...
    +    SelectionBind="session.selection.sources", ...
    +    SourceRole="measurement", ...
    +    SourceIdPrefix="source")
    +

    The App does not mirror those UI lifecycle actions with callbacks. Runtime updates the bound source records and invokes CreateSession after source changes:

    +
    function session = createSession(project, callbackContext)
    +paths = callbackContext.resolveSourcePaths(project.inputs.sources);
    +session = struct("measurements", example.sourceFiles.read(paths));
     end
    -

    buildWorkbenchLayout.m stays data-only. It receives framework-generated semantic callbacks and returns a workbench layout. It does not create MATLAB handles, run IO, compute data, mutate app state, schedule startup work, or set concrete layout geometry.

    -

    Layout And Action Rules

    -

    Use these app-facing rules:

    -
      -
    • The default shell is a LabKit workbench: control tabs on the left and primary preview, plot, waveform, image, or canvas content on the right.
    • -
    • definition.m declares app identity, project schema, optional session factory, workbench layout, handler registry, presenter, prepared-data renderers, and optional Start event.
    • -
    • The framework runtime owns lifecycle scheduling, readiness/loading surface, generated callbacks, busy gating, debug exception plumbing, close guards, and hidden/minimized test behavior.
    • -
    • buildWorkbenchLayout.m describes controls and workspace structure only. App command handlers own app-specific state changes, alerts, refresh decisions, and log wording.
    • -
    • Control ids are globally unique within an app. The UI registry is keyed by those ids, not by tab or section placement.
    • -
    • Public layouts are semantic controls such as filePanel, field, panner, action, previewArea, resultTable, logPanel, and statusPanel. Primitive MATLAB controls are implementation details.
    • -
    • section layout nodes contain real semantic controls; do not leave empty titled sections as placeholders. Axes tools are declared through the presenter's managed interactions model rather than mounted into a layout-host control.
    • -
    • Callback values placed in the layout are framework-generated adapters; apps do not implement control-handle callbacks. App handlers are the functions in definitionActions.m, are named by user intent, and use state = handler(state,event,services). The normalized event carries id, source, target, value, and meta; injected services decode file entries and indices without exposing the UI registry.
    • -
    • Reference a generated callback directly as callbacks.actionId. Do not use an App-owned lookup that substitutes [] for an unknown action. A missing or misspelled action ID is a definition error and must fail while the layout is built, before the user receives a control that silently does nothing.
    • -
    -

    Identity Contracts

    -

    Runtime ids are developer-owned semantic names, not generated object handles. Name an object once at its declaration and reuse that id when referring to it; the runtime validates the resulting graph before it mutates the visible UI.

    -
    IdentityScope and contractCompatibility meaning
    App IdStarts with an ASCII letter and contains only letters, digits, _, -, or .. Public app ids are unique across the app catalog.Permanent after a project or recovery file has been written. Renaming it creates a different app identity.
    Layout node idNonempty MATLAB field name, globally unique in one workbench tree.Used to bind presenter controls and UI callbacks.
    Preview axis idNonempty MATLAB field name, unique within its preview area and in the runtime axes registry.Used by presenter axes models and services.previews.axes.
    Action idMATLAB struct field in definitionActions; every layout or interaction event reference must name a registered action.Renaming requires updating every declaration that emits the event.
    Renderer idMATLAB struct field in Renderers; every presented renderer reference must name a registered renderer.App-owned presentation contract.
    Durable source idNonempty text, unique in project.inputs.sources.Stable key for reconciliation and portable external-file references.
    Result output idNonempty text, unique within one result manifest.Stable machine-readable name for one exported artifact.
    Resource idNonempty text paired with an event, interaction, or figure scope.Calling set again with the same scope and id intentionally disposes and replaces the previous resource; use different ids for resources that coexist.
    -

    Invalid declarations fail during definition, layout preparation, state validation, or presentation preflight instead of silently overwriting a registry entry. Recovery folders use a reversible encoded app id so distinct legal ids cannot normalize to the same path. Discovery also checks the earlier MATLAB-normalized folder name, allowing existing recovery files to remain readable while all new writes use the collision-free key.

    -

    File Selection And Dialogs

    +

    Portable source records are opaque. Resolve their paths only at IO boundaries. Saved projects store portable references and use runtime relinking.

    +

    Typed Events

    +

    Callbacks are attached only where an App owns real behavior:

      -
    • filePanel owns file input mechanics: file chooser defaults, optional recursive folder scans, duplicate filename display, current selection, and file-entry events. Each file entry exposes id, index, path, name, displayName, and status. Callback events expose file entries through event.files, event.addedFiles, event.removedFiles, event.selectedFiles, and event.value for the current selection. Apps consume paths and indices through services.events; apps do not parse control ids, UI registries, or adapter structs.
    • -
    • The active filePanel selection is also a framework title context. When a file is selected through the panel or a presentation commit, the app window title and preview axes titles include file N/M: name.ext; when selection is cleared, the framework removes that suffix. Apps should keep preview titles focused on the view or measurement being shown, not duplicate the selected filename in app-local title strings.
    • -
    • Multi-file filePanel mode exposes direct commands for adding one or more files from one folder, every supported file directly under one folder, or every supported file recursively below a folder, plus Remove selected and Clear. Each command opens the native file or folder navigator immediately; there is no file-versus-folder question. The native file dialog supports ordinary same-folder multi-selection; separate folder commands cover directory-wide imports. Recursive folder scans count matching files first and ask for confirmation only when the count exceeds the panel warning threshold. File labels show sequence numbers plus short filenames, adding the nearest unique parent directory when repeated filenames would otherwise collide. Apps that allow duplicate source files should remove by file id or index from event.removedFiles instead of deleting by path. Programmatic file replacement uses presenter-owned Value and Selection properties. Apps with their own nearby status field can pass showStatus=false to omit the panel's internal count/status text.
    • -
    • Single-file filePanel mode opens one file directly, replaces the previous file on the next choose action, does not offer recursive folder selection, and displays the short filename in one read-only text field instead of a selectable list.
    • -
    • File and folder selection in handlers goes through services.dialogs, which owns safe remembered defaults outside the LabKit install root.
    • -
    • App output defaults should be source-adjacent when a source file or folder is known. Use labkit.ui.runtime.defaultOutputFolder(sourcePaths, subfolderName); the helper uses the first source path when multiple files are loaded and creates the app-specific subfolder before returning it.
    • -
    • App-owned alerts use services.dialogs.alert(message, title). Apps still own the wording and decision; the runtime owns modal and hidden-test mechanics.
    • +
    • button: state = callback(state,callbackContext)
    • +
    • field, range, slider, workspace page, or interaction change: state = callback(state,value,callbackContext)
    • +
    • table edit: state = callback(state,labkit.app.event.TableCellEdit,callbackContext)
    • +
    • table selection: state = callback(state,labkit.app.event.TableCellSelection,callbackContext)
    -

    Saved Projects And Result Utilities

    -
      -
    • labkit.ui.runtime.saveState/loadState retain one stable public signature. Apps write a versioned labkitProject envelope with only durable project buckets, ordered app payload migrations, producer provenance, optional resume data, and additive extension preservation. Loads inventory the MAT file before loading a recognized variable, migrate and validate off to the side, resolve required sources, create a fresh session, invalidate the previous document's presentation cache, and then replace live state and repaint every preview once. This repaint also occurs when the loaded project has the same semantic values as the current document, because ephemeral graphics are not durable state. Saves use temporary-file readback plus atomic replacement. Project edits drive the dirty title, debounced bounded recovery, and unsaved-close wording; recovery never owns or overwrites an explicit project path. An app action may call services.project.saveAutosave(state) to update that same recovery copy immediately. Apps with a stable product-owned recovery location may call saveAutosave(state,filepath) instead. Both forms avoid a file dialog and leave the named project path and dirty status unchanged. Declared old snapshots and legacy app variables are import-only and are never written again.
    • -
    • Apps whose saved state refers to external source files store canonical source records created by services.project.sourceRecord. The runtime derives their portable relative references at each actual save destination. On load it checks the path relative to the loaded project first, then the original absolute path, then the saved filename beside the project. Relative references use / inside MAT payloads, so a project/source directory tree can move between cloud-drive roots and operating systems. If a required source remains unresolved, the runtime identifies its saved filename and role, asks whether to locate it, and opens one file chooser for that source. A selected file receives a new reference relative to the loaded project before session creation. The repaired document opens as unsaved work so the new location can be retained; cancelling leaves the current project and view unchanged. Apps may declare Project.RelinkSources only when their source schema needs behavior beyond this standard source-record flow. Projects migrated from an older payload, snapshot, or declared legacy variable also open as unsaved work. Save State reuses the opened path and atomically replaces it with the current labkitProject format; opening alone never rewrites the source MAT file.
    • -
    -

    Workbench Utilities And Preview Axes

    -
      -
    • The workbench shell includes a native Plot menu plus top-level Screenshot, Save State, and Load State entries. Plot commands operate on every registered preview axes in the app, so multi-axes workspaces do not require users to repeat the same command per view. Apps can use define(..., "Utilities", struct(...)) to hide the bar or disable groups of commands without adding app-local shell buttons. Utility commands operate on framework-owned runtime or visible preview axes; scientific result exports remain app-owned.
    • -
    • previewArea belongs in workspace by default. Its optional viewModes selector is workspace-owned, and apps can react through onModeChange.
    • -
    • previewArea axes install LabKit-managed, pointer-gated mouse-wheel zoom by default. Scrolling over controls, logs, or empty figure space does not zoom plots, and users should not need to click a preview before wheel zoom works. Time-series axes with a time x-label zoom the horizontal time axis only. Apps can set per-axis scrollZoomAxes values of xy, x, or y when a secondary fixed-width scale or histogram axis should receive wheel events without changing its horizontal span. Preview axes are also registered with the workbench utility menus, whose plot commands act on all registered visible axes.
    • -
    • labkit.ui.interaction.enablePopout(ax) installs the standard axes context menu action, which copies the visible axes into a standalone MATLAB figure with optional copied-figure text buttons for font size, plotted data line width, axes line width, grid visibility, and a Studio handoff. Popout edits operate on the copied figure only. Plot axes remain freely resizable, while image axes preserve locked data aspect ratio. Data-package export and generated reconstruction scripts belong in the Figure Studio workflow rather than the lightweight popout window; app-owned scientific CSV/MAT result exports still belong in the app package.
    • -
    -

    Parameter Controls And Layout Sizing

    -
      -
    • Parameter value controls (field, rangeField, and panner) debounce semantic onChange callbacks by default so short bursts of edits submit only the latest value after roughly 0.5 seconds of idle time. Explicit actions, file selection, and table edits run immediately. Apps should put expensive recompute work behind those semantic callbacks or explicit action buttons rather than binding work to lower-level MATLAB value-changing events.
    • -
    • panner is the preferred app layout control for bounded numeric parameters. It renders as a compact numeric spinner plus a linked slider when limits are finite, and as spinner-only when limits are intentionally unbounded. Slider drags update the numeric readout continuously and submit semantic changes through the same debounced callback queue as spinner edits; release submits the final committed value.
    • -
    • Text-heavy controls have conservative automatic heights owned by the framework. App layout nodes must not set concrete height, row-count, spacing, padding, chrome, row-height, or column-width properties.
    • -
    • Text-bearing controls default to complete display over single-line compactness. The framework enables wrapping where MATLAB supports it, shrinks long labels within a small readability range, gives text-heavy rows extra height, and keeps the full text available as a tooltip when supported. Apps should shorten wording when it improves workflow clarity, but should not add app-local layout or font-size patches to prevent clipping.
    • -
    • Section height is automatic: the builder estimates height from child control types and framework spacing defaults. Apps declare only the page, section, and control order.
    • -
    • group composes related semantic controls inside a section and replaces the older action-only grouping concept. Apps should use group(id, "", {...}) for inline tool rows and group(id, title, {...}) for titled subgroups. With layout="auto", a group whose children are all action layout nodes uses the action layout; mixed controls use a compact form layout. Action groups wrap into at most two columns by default, collapse to one column for long labels, and let a single odd final action span the row. Apps should not create app-local button grids just to place several actions near each other.
    • -
    • Use app-level usage/usageTitle on labkit.ui.layout.workbench for static workflow instructions. The framework places that read-only usage panel at the bottom of the first control tab.
    • -
    • Presenter Limits and Items properties are committed before bound values; the runtime clamps or selects safely without firing app callbacks.
    • -
    -

    The public layout-node grammar is semantic: pages, sections, controls, order, values, and callbacks. When a workflow needs a control that cannot be expressed with the ordinary layout nodes, add a named framework or app-owned layout node instead of placing MATLAB layout code in buildWorkbenchLayout.m.

    -

    Control tabs with more than one section include draggable horizontal separators by default. A tab may opt out with resize="none" when a fixed stack is intentional.

    -

    Runtime State And Transactions

    -

    A definition always declares product metadata, requirements, Id, Title, and Layout. It may add Project, CreateSession, Actions, Present, Renderers, and Start. It launches through labkit.ui.runtime.launch, which owns lightweight request dispatch, contract checks, runtime creation, output normalization, and the versioned title.

    -

    Runtime state has exactly two roots, project and session. Project contains inputs, parameters, annotations, results, and extensions; session contains selection, workflow, view, and cache. Both slices contain only plain serializable MATLAB data. Graphics, listeners, timers, tools, callbacks, services, and debug contexts stay in the framework resource registry.

    -

    Project factories use labkit.ui.runtime.emptySourceRecords(); the runtime owns that shape, handlers populate it through services.project operations, and all App layers read paths through labkit.ui.runtime.sourcePaths().

    -

    The Project declaration owns its version, factory, validator, migrations, and named read-only legacy imports. Optional CreateResume and ApplyResume hooks may restore conveniences such as the current frame, never durable data.

    -

    Events run through one non-recursive FIFO queue per figure. A handler has the signature state = handler(state, event, services). Nested services.dispatch calls enqueue a later transaction, so the current handler finishes one state commit and one presentation commit first. A failed handler, validator, or presenter restores the last committed state and visible presentation.

    -

    Ordinary value controls may use a semantic binding such as "Bind", "project.parameters.gamma" and may name an optional Event to run after the value is staged. Omit Event when the bound value is the complete state change; the runtime still validates and commits the transaction, and the App does not need a no-op handler. Present(state) returns control properties and prepared preview models by semantic id. Registered renderers receive an axes and model; presenters and actions never receive the raw UI registry. A renderer runs only when its declared renderer/model request changes; state-only commits preserve existing graphics and viewports. Plot utilities are inferred from the layout. Dynamic Items and Limits are applied before bound values. Runtime saves one labkitProject; named legacy variables import read-only.

    -

    Event is meaningful only on a control that also declares Bind. An unbound control must use an explicit generated onChange callback; the runtime rejects an unbound Event during launch so a migrated control cannot appear editable while silently discarding user changes.

    -

    After shell, state, first presentation, and interaction hub exist, the runtime queues optional Start with injected app-neutral services. An optional DebugSample writer runs only for debug launches, without app startup glue. Commits mirror session.workflow.logLines into semantic logPanel controls. Injected services.dialogs provides input-file, input-folder, output-file, and output-folder selection with safe defaults and test-injectable choosers. App handlers therefore do not call uigetfile, uigetdir, or save-dialog helpers directly. A Start handler may resolve a registered preview with services.previews.axes(previewId, axisId) only to attach a framework-managed listener or timer; actions, presenters, and semantic state still use plain data and preview models.

    -

    Injected services.resources.set(scope,id,value) registers a nonsemantic event-, session-, or figure-scoped resource with the Runtime's default cleanup. Pass a fourth cleanup function only when the value needs custom disposal. Replacing the same scope and id disposes the prior value before registration.

    -

    Each figure owns one private interaction hub. Preview targets register as previewId or previewId.axisId; the hub owns hover wheel/zoom routing, drag callbacks, and atomic groups. Apps never set figure callbacks. Present may declare anchors, pairedAnchors, pointSlots, rectangle, regionSelection, or scaleBarReference with semantic targets, values, events, and image sizes. regionSelection emits a dragged rectangle to Event or clicked point to BackgroundEvent. Kind/target changes replace or dispose resources; programmatic values suppress events, while user edits enqueue the declared event. When an interaction event changes an overlay model and causes its renderer to run again, the runtime preserves any manually zoomed or panned axes limits. Ordinary file-selection, reset, and data-change events may still let the renderer fit a new view.

    -

    Start And Readiness

    -

    LabKit app startup is a framework runtime state, not an app-owned progress dialog convention. labkit.ui.runtime.launch builds the shell, canonical state, first presentation, and interaction hub, then queues the optional definition Start handler through the same transaction queue as every later event. The runtime paints a readiness boundary when this work is slow enough to be perceptible and clears it only after the first visible presentation commits.

    -

    Fast apps should not flash a loading strip. The app window remains hidden until the initial workspace is ready, while the launcher progress dialog mirrors the current framework phase. A direct command-window launch prints the same phases with a [LabKit startup] prefix. This keeps slow or failed startup observable without exposing a blank or partially constructed app window. Hidden or minimized GUI test modes preserve readiness state for assertions while avoiding disruptive visual UI and command output.

    -

    Nonessential work belongs behind a user event or a framework-owned managed resource, not an app-created startup timer. App code should express initial domain work in its single Start handler and must not mutate readiness flags or manage loading controls directly.

    -

    Busy State

    -

    Every labkit.ui.layout.action callback runs as an app-wide action transaction. The framework marks the app busy before invoking the app callback and clears that busy state after the callback returns or errors. While the figure is busy, other semantic callbacks return without invoking app code, so repeated clicks or value changes do not submit duplicate work even when the user waits and interacts again before the first action finishes.

    -

    The default busy text comes from the action label:

    -
    labkit.ui.layout.action("exportCrops", ...
    -    "Export cropped images", callbacks.exportCrops, ...
    -    "enabled", false)
    -

    This displays Working: Export cropped images in the window title while the callback runs. Use busyMessage only when the title text needs to differ from the button label.

    -

    Busy state is app-wide. While the callback runs, LabKit marks the figure busy, sets a busy pointer, and appends the busy message to the window title. Direct changes made by the App callback to its pointer, title, or interaction state remain in place after the transaction; cleanup only restores framework-owned values that the callback left unchanged.

    -

    LabKit-created app figures install a framework close guard. Closing any LabKit app shows an in-window confirmation prompt before deleting the figure. If the user tries to close an app while a semantic action is active, the prompt uses busy-state wording.

    -

    The app-window close shortcut uses the same path: Command-W on macOS and Control-W elsewhere request a guarded close rather than bypassing unfinished work prompts. When a close shortcut opens the confirmation prompt, repeating or holding the same close shortcut confirms the close.

    -

    Apps should not maintain close-guard dirty state. The framework owns close confirmation for public and private LabKit apps.

    -

    Busy transactions intentionally do not create modal progress dialogs or mutate control Enable values. Apps should not maintain their own busy-control lists; App render logic still owns permanent button enablement rules.

    -

    Presentation And Plotting

    -

    Apps do not receive the UI registry and do not call control setters. A pure Present(state) function returns semantic control properties, prepared preview models, and controlled interaction specs. The runtime applies dynamic items and limits before values, suppresses callbacks during the commit, mirrors workflow logs, and invokes changed renderer/model requests with only (axes, model). Unchanged preview requests retain their graphics handles and axes limits.

    -

    For a resultTable, the presenter may supply Data, ColumnName, and RowName. The runtime applies headers before data so one table can display different imported CSV or worksheet shapes without predeclaring placeholder columns. A presenter may also set a semantic control's Visible property. Hidden workspace panels collapse their rows so another table or preview can use the full right-side workspace.

    -

    A workspace normally contains panels directly. When an App needs several user-selectable right-side pages, the same workspace node may instead contain two or more tab nodes, each containing workspace panels. Runtime builds one native tab group and owns page selection and geometry. Direct panels and tab pages cannot be mixed, a single tab is rejected as unnecessary structure, and every workspace tab must contain at least one panel. Existing single-workspace Apps keep their direct-panel form.

    -

    Renderers may use ordinary MATLAB graphics plus the small advanced plot surface: clear, fit, fitCanvas, message, offsetData, and clampData. clear and fit own full-redraw viewport mechanics; offsetData and clampData honor log scales and reversed axes. Image preparation, annotations, labels, and scientific plot meaning remain app-owned.

    -

    A renderer may call labkit.ui.interaction.enablePopout(ax) after drawing an axes. The workbench also installs the standard popout action automatically and restores it after a renderer resets the axes or replaces its children.

    -

    Managed Interactions

    -

    Apps declare controlled interaction specs from their presenter rather than constructing editor objects. Supported kinds include anchors, pairedAnchors, pointSlots, rectangle, regionSelection, interval, and scaleBarReference. The figure-scoped interaction hub owns pointer routing, wheel zoom, drag capture/release, callback restoration, event enqueueing, and resource cleanup.

    -

    While an anchor interaction is active, the framework writes the exact add, drag, and removal gesture into the preview subtitle. Curve anchors use double-click to add/delete, point-marking modes use a single click plus their explicit Undo/Clear controls, and paired matching explains that both previews must be clicked in corresponding order.

    -

    Apps persist only semantic values such as points, rectangles, intervals, and calibration. labkit.ui.interaction.anchorPath, scaleBarCalibration, and scaleBarGeometry remain GUI-free advanced helpers for calculations, presentation models, and exports. Apps never place editor/runtime objects or graphics handles in project or session state.

    -

    Debug

    -

    Debug launches create an ignored artifacts/debug/<app>/<run>/manifest.json. This is a local index for the anonymous debug sample pack, trace log, and expected debug output folder; it is not project state and is safe to remove with the rest of that debug run. By contrast, *.labkit.json beside an exported result is a result manifest containing output status, byte counts, SHA-256 hashes, parameters, provenance, and summary data, and should normally stay with the exported files.

    -

    labkit.ui.runtime.launch owns normal, debug, requirements, and version requests. A debug launch remains:

    -
    [fig, debug] = appName("debug");
    -

    The runtime injects diagnostics and workflow-log services. Handlers that catch and continue after an exception call services.diagnostics.report; visible log lines go through services.workflow.log and are mirrored into semantic logPanel controls on commit. Apps do not construct debug contexts, wrap UI callbacks, or append separately to a UI registry.

    -

    Each debug launch writes a session folder under artifacts/debug/<AppName>/<SessionId>/ containing trace.log, samples/, outputs/, and manifest.json. The trace is authoritative when the GUI is unresponsive; the Log tab is its human-readable workflow mirror. Framework instrumentation records active operations and callback failures without adding app-owned lifecycle glue.

    -

    Callback Policy

    -

    Reusable helpers and tools keep three callback classes separate:

    -
    Callback classPurpose
    User semantic callbacksNotify the app that the user changed app-relevant state.
    Internal refresh callbacksKeep controls, graphics, and derived readouts synchronized without re-entering app semantics.
    Programmatic callbacksApply app-initiated state changes and report source as programmatic when exposed through trace.
    -

    All setX(value) style APIs should do nothing when the requested value is already current. Internal synchronization should not fire app-facing semantic callbacks. Composed tools should trace callback reason/source as user, internal, or programmatic when an event crosses the app/tool boundary.

    -

    Framework And App Responsibilities

    -

    labkit.ui provides the app-neutral GUI shell, view construction, axes rendering, interaction lifecycle, composed tools, diagnostics, and reusable control-state mechanics.

    -

    Experiment names, formulas, thresholds, parser calls, result fields, export schemas, plotting annotations, and workflow-specific action order remain in the app. Apps pass labels, semantic action ids, prepared vectors, tables, debug contexts, and option values into UI helpers.

    +

    Name the boundary values explicitly and delegate domain work through narrow inputs:

    +
    function applicationState = replaceGroupValue( ...
    +        applicationState, cellEdit, callbackContext)
    +arguments
    +    applicationState (1,1) struct
    +    cellEdit (1,1) labkit.app.event.TableCellEdit
    +    callbackContext (1,1) labkit.app.CallbackContext
    +end
    +
    +groups = applicationState.project.groups;
    +applicationState.project.groups = ...
    +    example.groupData.replaceValue(groups, ...
    +        cellEdit.Row, cellEdit.Column, cellEdit.NewValue);
    +end
    +

    Do not pass the complete state or callback context into calculation code that only needs groups and one edited value.

    +

    Complete View Snapshots

    +

    Runtime starts from layout defaults, bindings, file state, log text, and status text. PresentWorkbench returns only derived App-owned operations:

    +
    function view = present(applicationState)
    +view = labkit.app.view.Snapshot();
    +view = view.include(example.previewPlot.present( ...
    +    applicationState.session.measurements, ...
    +    applicationState.project.parameters));
    +view = view.enabled("exportResult", ...
    +    ~isempty(applicationState.session.measurements));
    +end
    +

    The combined snapshot must cover every semantic target exactly as its layout capabilities require. Snapshot.include composes feature-owned fragments without opening a generic property-patch schema.

    +

    Plot presentation passes a prepared model to the renderer declared by its plot area:

    +
    view = view.renderPlot("previewPlot", model);
    +
    function draw(axesById, model)
    +ax = axesById.main;
    +cla(ax);
    +plot(ax, model.x, model.y);
    +end
    +

    Renderers own drawing and viewport policy, not workflow decisions or project mutation. Display-only graphics disable hit testing. Managed interaction specs own editable gestures and event-scoped resources.

    +

    Declare managed gestures statically on their plot area and provide their current value in the snapshot:

    +
    crop = labkit.app.interaction.rectangle( ...
    +    "cropRegion", @example.cropGeometry.moveCrop);
    +plot = labkit.app.layout.plotArea( ...
    +    "previewPlot", @example.previewPlot.draw, Interactions={crop});
    +
    view = view.rectangle( ...
    +    "cropRegion", project.annotations.crop, ImageSize=size(image));
    +

    Named contracts also cover anchor paths, paired anchors, fixed point slots, transient region selection, intervals, and scale references. Apps never author Kind, Targets, Event, or Options transport structs.

    +

    Renderer mechanics such as complete clears, empty-state messages, fitting, fixed-aspect canvases, and axes-relative annotation placement live under labkit.app.plot.*. Apps own when those operations occur, user wording, and whether a semantic change should preserve or fit the viewport.

    +

    CallbackContext

    +

    labkit.app.CallbackContext is sealed and exposes specifically named runtime operations for dialogs, status and diagnostics, portable sources, project documents, result packages, render surfaces, and managed resources. It does not expose figures, component registries, queues, lifecycle handles, or a nested service bag.

    +

    Use context methods only at a callback or reconstruction boundary. Pure readers, calculations, result builders, and render-model builders accept ordinary explicit values.

    +

    callbackContext.chooseOption(prompt, choices, ...) owns ordinary native confirmation choices. Title controls the dialog title, DefaultChoice selects the Enter-key action, and CancelChoice is returned when the user dismisses the dialog. All three named choices must be members of the declared nonempty unique choice row. File and folder methods remain separate because they return paths and use platform file choosers.

    +

    An App-specific project button may choose a MAT file and return callbackContext.restoreProjectDocument(filepath). The context prepares the same migrated, relinked project/session candidate used by the framework Load State menu; the active callback transaction still owns validation, native presentation, rollback, document metadata, and title publication. callbackContext.newProjectDocument() similarly returns the schema's fresh project/session state and publishes a new unsaved document identity only when that callback transaction commits.

    +

    Persistence, Results, And Cleanup

    +

    labkit.app.project.Schema owns current project creation, validation, and ordered version migration. Runtime owns the project envelope, atomic save, restore, recovery, and relinking loop.

    +

    labkit.app.result.File and labkit.app.result.Package describe App-owned outputs. CallbackContext.writeResultPackage writes through the runtime so source and project provenance remain consistent.

    +

    Resources have event, interaction, document, or application scope. Replacing the same scope and ID is idempotent; the runtime cleans every surviving resource on scope end or close.

    Validation

    -

    Reusable UI contracts are covered by the source-aligned UI and project build tasks listed in Testing.

    -

    Automated GUI tests validate launch, layout, callback wiring, trace plumbing, reusable tool lifecycle, and hidden synthetic app workflows. Full interactive drawing, file selection, visual inspection, scientific validity, and workflow feel still require manual MATLAB GUI validation.

    +

    Use focused contract tests for Definition, layout, callbacks, snapshots, project schema, and runtime transactions. Add downstream App tests for changed behavior and a bounded hidden-GUI test for native wiring. Automated hidden GUI tests do not prove dialog quality, pointer feel, scientific validity, or a complete interactive workflow.

    +

    See Testing and Build A Complete App.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/framework/index.html b/site/framework/index.html index 500a6bb0a..8fd267e89 100644 --- a/site/framework/index.html +++ b/site/framework/index.html @@ -5,7 +5,7 @@ -LabKit App Framework - LabKit MATLAB Workbench +LabKit App SDK - LabKit MATLAB Workbench @@ -18,43 +18,60 @@
    - +

    explanation

    -

    LabKit App Framework

    -

    labkit.ui is the application framework above the domain libraries and concrete apps. It provides lifecycle management, semantic workbench layouts, action dispatch, project save/load support, presentation updates, managed interactions, debug traces, and top-level utilities. App packages supply the workflow decisions, scientific calculations, data schemas, labels, and exports.

    +

    LabKit App SDK

    +

    labkit.app is the MATLAB App SDK above LabKit's GUI-free domain libraries. Apps own scientific workflow, calculations, units, wording, plots, and exports. The SDK owns semantic layout, transactions, project documents, portable sources, dialogs, resources, results, and native component lifecycle.

    Start Here

    -
    GoalDocumentation
    Understand how an App starts and processes actionsRuntime and lifecycle
    Declare and validate compatible LabKit modulesCompatibility contracts
    Build or refactor a concrete AppApp development
    Choose reusable package boundariesArchitecture
    Look up exact MATLAB function syntaxPublic API reference
    Validate framework or GUI changesTesting
    -

    Module Overview

    -
    PackageResponsibilityTypical entry points
    labkit.ui.runtimeLaunch, lifecycle, queued actions, canonical state, persistence, dialogs, resourceslaunch, define, saveState, loadState
    labkit.ui.layoutData-only semantic workbench descriptionsworkbench, tab, section, filePanel, previewArea, action
    labkit.ui.plotViewport-safe plot and image mechanicsclear, fit, fitCanvas, message, clampData
    labkit.ui.interactionManaged axes interactions and reusable geometryenablePopout, anchorPath, scaleBarGeometry
    labkit.ui.debugStructured trace and exception contextcontext
    labkit.contractFramework-wide facade requirements and compatibility checksrequirements, checkRequirements, assertRequirements
    -

    labkit.contract is documented with the App Framework because definitions and startup consume it, while its stable MATLAB namespace remains separate: it checks compatibility for UI and domain facades rather than becoming a UI-only dependency.

    -

    Framework concepts and source names are versionless. Compatibility belongs to labkit.ui.version and App requirements; saved-data versions belong to project migration contracts. Do not add version-named runtimes, packages, functions, types, tests, or manual sections when the framework contract changes.

    -

    This package division is the supported public surface. Primitive MATLAB UI handles, registry mutation, concrete grid geometry, event queues, renderer internals, and resource cleanup implementations stay private.

    -

    Runtime Model

    -

    An App declares a definition and passes it to labkit.ui.runtime.launch:

    -
    function varargout = labkit_Example_app(varargin)
    -    [varargout{1:nargout}] = labkit.ui.runtime.launch( ...
    -        @example.definition, varargin{:});
    +
    GoalDocumentation
    Build or refactor an AppBuild a Complete App
    Understand ownership boundariesArchitecture
    Declare compatible LabKit modulesCompatibility contracts
    Look up exact MATLAB syntaxPublic API reference
    Validate framework or GUI changesTesting
    +

    SDK Map

    +

    The required path has three concepts:

    +
    APIPurpose
    labkit.app.DefinitionApp identity, lifecycle callbacks, layout, and launch
    labkit.app.layout.*Semantic inputs, displays, containers, and workbench structure
    labkit.app.view.SnapshotDerived visible state and App-owned rendering
    +

    Read an App in this order: definition.m shows its complete contract, +workbench/buildLayout.m shows the user workflow, and its capability packages show the controls, state transitions, presentation, and rendering owned by each feature. Open projectSpec.m or createSession.m only when the definition names those optional capabilities.

    +

    Layout controls bind directly to named App callbacks, and plot areas bind directly to their renderer. Apps do not maintain handler, renderer, or capability registries. Runtime-injected values are labkit.app.CallbackContext and typed labkit.app.event.* payloads. Callbacks name those boundary values explicitly and delegate domain work through narrow inputs.

    +

    Optional capabilities are grouped by purpose:

    +
    PackagePurpose
    labkit.app.event.*Typed callback payloads
    labkit.app.interaction.*Managed plot gestures with direct callbacks
    labkit.app.plot.*Domain-neutral axes redraw, message, fit, and annotation mechanics
    labkit.app.project.*Durable project schema and migration
    labkit.app.result.*Exported files and result packages
    labkit.app.dialog.*Explicit dialog outcomes
    labkit.app.CallbackContextRuntime operations available inside callbacks
    +

    Smallest App

    +
    function app = definition()
    +    workbench = labkit.app.layout.workbench({ ...
    +        labkit.app.layout.field("gain", Label="Gain", ...
    +            Kind="numeric", Bind="project.parameters.gain"), ...
    +        labkit.app.layout.button("run", "Run", @runAnalysis, ...
    +            Tooltip="Compute the result from the current gain.")});
    +    app = labkit.app.Definition( ...
    +        Entrypoint="labkit_Example_app", AppId="example", ...
    +        Title="Example", Family="Examples", ...
    +        AppVersion="1.0.0", Updated="2026-07-19", ...
    +        Requirements=labkit.contract.requirements("app", ">=1 <2"), ...
    +        Workbench=workbench);
     end
    -

    A static App package needs only that thin entrypoint, one definition.m, and its semantic layout. The definition is the single product contract: it owns the command, stable ID, names, family, App version, update date, LabKit requirements, layout, and any optional capabilities. The runtime supplies an empty version-1 project, empty session, no actions, and an empty presentation model until the App opts into more behavior.

    -

    The framework then validates the definition, creates canonical project and session state, builds the semantic layout, generates callbacks, commits the first presentation, and queues the optional start action. App handlers receive semantic events plus injected services and return updated state. They do not own busy flags, callback plumbing, persistence envelopes, or UI resource lifetimes. Project loading resolves portable external sources before session creation; when a required source moved, the framework identifies it and lets the user locate a replacement without committing a partial project. Migrated or relinked documents remain visibly unsaved until Save State atomically writes the current project format. Each project, explicit autosave, and recovery write recalculates source-relative paths from that MAT file's actual destination, so moving a saved project tree does not depend on the folder from which the source was first imported.

    -

    An existing source that fails decoding is different from a missing path: Runtime aborts the candidate session, reports its inputs.sources identities and filenames to diagnostics, and preserves the currently open project and presentation. Session factories do not turn decoder or programming exceptions into empty caches.

    -

    A control whose complete behavior is writing one state path uses Bind without Event; Runtime commits it without requiring an empty App action. Handlers register plain nonsemantic values through services.resources.set(scope,id,value) and supply a fourth cleanup function only when the resource needs custom disposal.

    -

    An App CreateSession factory returns only its own transient selection, view, workflow, or cache fields. The runtime adds the canonical session buckets and initializes workflow logging; Apps do not repeat empty framework-owned structures. An action that must discard the complete current document calls services.project.newState(), which rebuilds both project and session through the current definition and applies the same canonical normalization used at launch. App code must not approximate a full reset by calling its session factory directly.

    -

    The same ownership applies to durable validation. Runtime verifies the five canonical project buckets and any standard portable source records before it calls the App's project validator. The App validator owns only its domain fields, legal values, relationships, roles, and scientific invariants.

    -

    Variable-length sources and manifest outputs start from framework-provided empty arrays (emptySourceRecords and services.results.emptyOutputs()), then append validated real records. Apps do not construct empty-ID placeholder records merely to copy their struct shape. App code creates current source records through labkit.ui.runtime.sourceRecord, passes an existing legacy portable reference back to the same factory as one opaque value, and reads current locations through labkit.ui.runtime.sourcePaths rather than depending on the runtime-owned portable-reference fields. ID-based lookup preserves requested order and returns an empty path for an optional semantic source slot that has not been selected yet.

    -

    A persistent App exposes one projectSpec.m entry containing its project version plus local create, validate, and migrate functions. Runtime owns the loop across missing versions and validates each returned payload; App packages do not publish one migration file per historical step.

    -

    These are the only supported source forms: launch receives one definition factory, and a versioned project exposes one Migrate(project,fromVersion) callback. Separate requirements/version launch factories, migration callback collections, and layout-only tool hosts are not part of the current UI contract. Declared legacy MAT imports and supported older project payloads remain read-only data compatibility, not alternate App source architectures.

    -

    Read Runtime and lifecycle for the detailed definition fields, state transaction rules, startup/readiness behavior, plot and interaction contracts, debug semantics, callback policy, and the responsibilities of the framework and app.

    -

    Public API Documentation

    -

    Exact syntax, inputs, outputs, defaults, legal values, examples, and related functions are generated from the help block immediately following each public function declaration. The documentation contract discovers every non-private labkit.ui function, requires explicit failure behavior, verifies each implemented option and default, and executes every Example: block. Open the API reference and search for a fully qualified symbol such as labkit.ui.runtime.launch or labkit.ui.version.

    +

    The entrypoint calls definition().launch(...). Definition compiles the immutable semantic graph before creating a figure, validates direct callback and renderer signatures, and builds one private native platform plan.

    +

    Paved Road

    +
      +
    • Bind ordinary state with Bind="project..." or Bind="session...".
    • +
    • Use labkit.app.layout.fileList for portable file records and selection. Set AllowDuplicatePaths=true only when separate workflow tasks may share one resolved path, and present row-level workflow state with Snapshot.fileItemStatuses. Source changes rebuild the transient session; Apps do not mirror choose, remove, clear, or selection UI events.
    • +
    • Give every scientific or workflow action an App-owned Tooltip. The framework guarantees a nonempty label-based fallback, while repository guardrails require tracked Apps to explain the action instead of repeating its visible label. File-list choose/folder/remove/clear controls expose dedicated tooltip options and domain-neutral label defaults.
    • +
    • Rebuild transient data with session = createSession(project,context) and resolve opaque source records with context.resolveSourcePaths.
    • +
    • Return only derived view state from labkit.app.view.Snapshot; runtime supplies layout defaults, bindings, file state, log text, and status text.
    • +
    • Use labkit.app.layout.dataTable with labkit.app.event.TableCellEdit and labkit.app.event.TableCellSelection; Apps never decode native events.
    • +
    • Use labkit.app.layout.plotArea and a fixed renderer(axesById,model) callback. axesById is always a named struct, even when the plot area declares only one axis.
    • +
    • Pass one workspace node or a row cell array of vertically arranged nodes to workspace.page; growable tables and plots share the available page height without an App-authored wrapper section.
    • +
    • Use layout.group(..., Title="...") for a nested reader-facing control boundary inside a section; leave Title blank for arrangement-only groups.
    • +
    • A control tab containing one growable file list, table, log, status, or plot surface fills the available tab height. Tabs with longer mixed content remain scrollable.
    • +
    • Declare editable overlays with labkit.app.interaction.* on the plot area; supply their current values with same-named Snapshot methods.
    • +
    • Use labkit.app.plot.clearAxes, showMessage, and fitAxesToGraphics for renderer mechanics; Apps still decide message wording and viewport policy.
    • +
    • Use labkit.app.project.Schema, labkit.app.result.File, and labkit.app.result.Package only when those optional capabilities exist.
    • +
    • Use labkit.app.project.sourceRecord only in pure project creation or migration code that must turn a legacy path into a portable source value; runtime callbacks still resolve paths through CallbackContext.
    • +
    +

    Runtime validates candidate state and the complete view snapshot before publishing either. The private MATLAB adapter maps semantic IDs to native components, preserves plot viewports, normalizes native event differences, and never exposes component registries to Apps.

    +

    Framework concepts and source names are versionless. Compatibility belongs to labkit.app.version; saved-data versions belong to labkit.app.project.Schema.

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/getting-started/index.html b/site/getting-started/index.html index 3d0f0ffc8..cce1238ea 100644 --- a/site/getting-started/index.html +++ b/site/getting-started/index.html @@ -70,7 +70,7 @@

    Next Steps

  • LabKit Launcher
  • Public API reference
  • Development guide
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/index.html b/site/history/index.html index 58fb29d2b..537565592 100644 --- a/site/history/index.html +++ b/site/history/index.html @@ -30,7 +30,7 @@

    Reading The Timeline

    The folder is chronological, while the metadata inside each record names the apps, framework, or libraries affected by the change. Every record also has a global positive-integer sequence under history metadata schema 2. Sequence values are unique and contiguous: the first record is 1, and each later change takes the next value even when several changes share a date. The generated timeline sorts by descending sequence, so titles and filenames cannot reorder same-day versions. Dates must remain nondecreasing as sequence increases.

    The documentation site uses that metadata to show a relevant Change history section on each component page. A change that affects several components is stored once and linked from all of them.

    Find A Change

    -

    Use the site search for an app command, public function, Change ID, or feature name. You can also browse the complete timeline below by date. Current version numbers come from the launcher, facade version.m files, and App definition.m metadata rather than from a separate table in this history.

    Complete timeline

    +

    Use the site search for an app command, public function, Change ID, or feature name. You can also browse the complete timeline below by date. Current version numbers come from the launcher, facade version.m files, and App definition.m metadata rather than from a separate table in this history.

    Complete timeline

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260623-facade-contract-baseline-and-release-validation-hardening.html b/site/history/records/2026/06/LK-20260623-facade-contract-baseline-and-release-validation-hardening.html index cdb281132..518e4b1dc 100644 --- a/site/history/records/2026/06/LK-20260623-facade-contract-baseline-and-release-validation-hardening.html +++ b/site/history/records/2026/06/LK-20260623-facade-contract-baseline-and-release-validation-hardening.html @@ -18,7 +18,7 @@
    - +

    history

    Facade contract baseline and release validation hardening

    @@ -66,7 +66,7 @@

    Evidence

  • Main commits a25b79f9, 3673e548, 49d9f41b, and 7e39b558.
  • Known limitations and follow-up

    -

    App and launcher display versions were added in the separate version-metadata change later the same day.

    +

    App and launcher display versions were added in the separate version-metadata change later the same day.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260623-version-metadata-baseline.html b/site/history/records/2026/06/LK-20260623-version-metadata-baseline.html index 801d13425..374c6d1e0 100644 --- a/site/history/records/2026/06/LK-20260623-version-metadata-baseline.html +++ b/site/history/records/2026/06/LK-20260623-version-metadata-baseline.html @@ -18,7 +18,7 @@
    - +

    history

    Version metadata baseline

    @@ -72,7 +72,7 @@

    Evidence

  • Main commit d70c2607.
  • Known limitations and follow-up

    -

    These component versions describe displayed app/library builds; compatibility between reusable packages is handled separately by labkit.contract.

    +

    These component versions describe displayed app/library builds; compatibility between reusable packages is handled separately by labkit.contract.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260624-file-panel-migration.html b/site/history/records/2026/06/LK-20260624-file-panel-migration.html index 5ec2b23a7..db55dfc6e 100644 --- a/site/history/records/2026/06/LK-20260624-file-panel-migration.html +++ b/site/history/records/2026/06/LK-20260624-file-panel-migration.html @@ -18,7 +18,7 @@
    - +

    history

    File-panel migration

    @@ -73,7 +73,7 @@

    Evidence

  • Main commit b145c904.
  • Known limitations and follow-up

    -

    This first file-panel generation was later refined for append behavior, native dialog edge cases, and Runtime V2 events.

    +

    This first file-panel generation was later refined for append behavior, native dialog edge cases, and Runtime V2 events.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260626-launcher-manager-and-stale-callback-fix.html b/site/history/records/2026/06/LK-20260626-launcher-manager-and-stale-callback-fix.html index 73037ccde..8816a478d 100644 --- a/site/history/records/2026/06/LK-20260626-launcher-manager-and-stale-callback-fix.html +++ b/site/history/records/2026/06/LK-20260626-launcher-manager-and-stale-callback-fix.html @@ -18,7 +18,7 @@
    - +

    history

    Launcher manager and stale callback fix

    @@ -55,7 +55,7 @@

    Evidence

  • Main commits fe8654c9, ef89cf77, and 3d23b7f1.
  • Known limitations and follow-up

    -

    Version selection still depended on network access for remote revisions; already installed local versions remained usable offline.

    +

    Version selection still depended on network access for remote revisions; already installed local versions remained usable offline.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260628-app-diagnostics-and-hardened-ui-workflows.html b/site/history/records/2026/06/LK-20260628-app-diagnostics-and-hardened-ui-workflows.html index 66ab8bef9..80c1d74f7 100644 --- a/site/history/records/2026/06/LK-20260628-app-diagnostics-and-hardened-ui-workflows.html +++ b/site/history/records/2026/06/LK-20260628-app-diagnostics-and-hardened-ui-workflows.html @@ -18,7 +18,7 @@
    - +

    history

    App diagnostics and hardened UI workflows

    @@ -64,7 +64,7 @@

    Evidence

  • Main commits e966457b and f5bc6f98.
  • Known limitations and follow-up

    -

    Diagnostics report the state and stack available at capture time; they do not replace a reproducible input or a focused regression test.

    +

    Diagnostics report the state and stack available at capture time; they do not replace a reproducible input or a focused regression test.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260628-batch-crop-file-workflow-feedback.html b/site/history/records/2026/06/LK-20260628-batch-crop-file-workflow-feedback.html index 36753f6fd..65a7b2c08 100644 --- a/site/history/records/2026/06/LK-20260628-batch-crop-file-workflow-feedback.html +++ b/site/history/records/2026/06/LK-20260628-batch-crop-file-workflow-feedback.html @@ -18,7 +18,7 @@
    - +

    history

    Batch Crop file workflow feedback

    @@ -54,7 +54,7 @@

    Evidence

  • Main commit 61e8edd3.
  • Known limitations and follow-up

    -

    Later file-panel work extended the same title context and append behavior to the rest of the app fleet.

    +

    Later file-panel work extended the same title context and append behavior to the rest of the app fleet.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260629-tool-panel-hosts-and-image-app-fixes.html b/site/history/records/2026/06/LK-20260629-tool-panel-hosts-and-image-app-fixes.html index 7fee6173d..e9c666e7a 100644 --- a/site/history/records/2026/06/LK-20260629-tool-panel-hosts-and-image-app-fixes.html +++ b/site/history/records/2026/06/LK-20260629-tool-panel-hosts-and-image-app-fixes.html @@ -18,7 +18,7 @@
    - +

    history

    Tool-panel hosts and image app fixes

    @@ -59,7 +59,7 @@

    Evidence

  • Main commits f2189aef, 77084fbe, and 871739cd.
  • Known limitations and follow-up

    -

    The first toolPanel API was later replaced by the current semantic layout and managed-interaction model.

    +

    The first toolPanel API was later replaced by the current semantic layout and managed-interaction model.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260629-ui-diagnostics-and-release-v3-0-0.html b/site/history/records/2026/06/LK-20260629-ui-diagnostics-and-release-v3-0-0.html index fe11a92d8..a86bc763e 100644 --- a/site/history/records/2026/06/LK-20260629-ui-diagnostics-and-release-v3-0-0.html +++ b/site/history/records/2026/06/LK-20260629-ui-diagnostics-and-release-v3-0-0.html @@ -18,7 +18,7 @@
    - +

    history

    UI diagnostics and release v3.0.0

    @@ -55,7 +55,7 @@

    Evidence

  • Main commits 21eff4dc and release tag commit 349a7549.
  • Known limitations and follow-up

    -

    This record marks the release boundary; individual diagnostic mechanics are described in their earlier component history records.

    +

    This record marks the release boundary; individual diagnostic mechanics are described in their earlier component history records.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260630-app-alerts-through-ui-facade.html b/site/history/records/2026/06/LK-20260630-app-alerts-through-ui-facade.html index 5f7c67627..a0080f07f 100644 --- a/site/history/records/2026/06/LK-20260630-app-alerts-through-ui-facade.html +++ b/site/history/records/2026/06/LK-20260630-app-alerts-through-ui-facade.html @@ -18,7 +18,7 @@
    - +

    history

    App alerts through UI facade

    @@ -65,7 +65,7 @@

    Evidence

  • Main commit 8d7c83b1.
  • Known limitations and follow-up

    -

    The historical labkit.ui.app.showAlert entry point was later replaced by the injected services.dialogs.alert service in Runtime V2.

    +

    The historical labkit.ui.app.showAlert entry point was later replaced by the injected services.dialogs.alert service in Runtime V2.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260630-close-guards-and-caught-exception-diagnostics.html b/site/history/records/2026/06/LK-20260630-close-guards-and-caught-exception-diagnostics.html index bcaf774c4..be64f5519 100644 --- a/site/history/records/2026/06/LK-20260630-close-guards-and-caught-exception-diagnostics.html +++ b/site/history/records/2026/06/LK-20260630-close-guards-and-caught-exception-diagnostics.html @@ -18,7 +18,7 @@
    - +

    history

    Close guards and caught-exception diagnostics

    @@ -67,7 +67,7 @@

    Evidence

  • Main commits c0028a81 and a81853ef.
  • Known limitations and follow-up

    -

    Runtime V2 later replaced direct debug-log and close-guard calls with lifecycle and diagnostic services, but retained the requirement that caught exceptions remain observable.

    +

    Runtime V2 later replaced direct debug-log and close-guard calls with lifecycle and diagnostic services, but retained the requirement that caught exceptions remain observable.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260630-file-panel-layout-stabilization.html b/site/history/records/2026/06/LK-20260630-file-panel-layout-stabilization.html index 48429ba7d..4c38aac65 100644 --- a/site/history/records/2026/06/LK-20260630-file-panel-layout-stabilization.html +++ b/site/history/records/2026/06/LK-20260630-file-panel-layout-stabilization.html @@ -18,7 +18,7 @@
    - +

    history

    File-panel layout stabilization

    @@ -52,7 +52,7 @@

    Evidence

  • Main commits 7f8df1cd and 02b2f1b6.
  • Known limitations and follow-up

    -

    Later runtime generations replaced this private UI 3.x builder, but retained the principle that shared components own and test their responsive geometry.

    +

    Later runtime generations replaced this private UI 3.x builder, but retained the principle that shared components own and test their responsive geometry.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260630-migration-helper-cleanup.html b/site/history/records/2026/06/LK-20260630-migration-helper-cleanup.html index 27f3305a1..61bca32b1 100644 --- a/site/history/records/2026/06/LK-20260630-migration-helper-cleanup.html +++ b/site/history/records/2026/06/LK-20260630-migration-helper-cleanup.html @@ -18,7 +18,7 @@
    - +

    history

    Migration helper cleanup

    @@ -59,7 +59,7 @@

    Evidence

  • Main commits 7f73b71b, e3349af6, 733fb951, 98a2b02c, and 391540a7.
  • Known limitations and follow-up

    -

    This was a behavior-preserving cleanup. Later workflow-first packages replaced some of the package names shown in the historical commits.

    +

    This was a behavior-preserving cleanup. Later workflow-first packages replaced some of the package names shown in the historical commits.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260630-output-folder-prompts.html b/site/history/records/2026/06/LK-20260630-output-folder-prompts.html index 1b10d1bff..7714d7f94 100644 --- a/site/history/records/2026/06/LK-20260630-output-folder-prompts.html +++ b/site/history/records/2026/06/LK-20260630-output-folder-prompts.html @@ -18,7 +18,7 @@
    - +

    history

    Output folder prompts

    @@ -61,7 +61,7 @@

    Evidence

  • Main commit c5055b98.
  • Known limitations and follow-up

    -

    Runtime V2 later moved dialog access into injected services, preserving the same separation between app decisions and platform dialog mechanics.

    +

    Runtime V2 later moved dialog access into injected services, preserving the same separation between app decisions and platform dialog mechanics.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/06/LK-20260630-shared-image-facade.html b/site/history/records/2026/06/LK-20260630-shared-image-facade.html index 0809767b7..d76a7dd5e 100644 --- a/site/history/records/2026/06/LK-20260630-shared-image-facade.html +++ b/site/history/records/2026/06/LK-20260630-shared-image-facade.html @@ -18,7 +18,7 @@
    - +

    history

    Shared image facade

    @@ -59,7 +59,7 @@

    Evidence

  • Main commit 7023e87e.
  • Known limitations and follow-up

    -

    The first facade release covered common MATLAB image formats and basic processing only. Thermal decoding and workflow-specific algorithms remained outside labkit.image.

    +

    The first facade release covered common MATLAB image formats and basic processing only. Thermal decoding and workflow-specific algorithms remained outside labkit.image.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260701-debug-sample-packs.html b/site/history/records/2026/07/LK-20260701-debug-sample-packs.html index c8785fdb7..8e14b1e38 100644 --- a/site/history/records/2026/07/LK-20260701-debug-sample-packs.html +++ b/site/history/records/2026/07/LK-20260701-debug-sample-packs.html @@ -18,7 +18,7 @@
    - +

    history

    Debug sample packs

    @@ -70,7 +70,7 @@

    Evidence

  • Main commit 279befbc.
  • Known limitations and follow-up

    -

    Sample packs were deliberately app-owned because each workflow knows which inputs make a useful reproduction. They do not contain or archive a user's original files.

    +

    Sample packs were deliberately app-owned because each workflow knows which inputs make a useful reproduction. They do not contain or archive a user's original files.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260701-image-app-workflow-improvements.html b/site/history/records/2026/07/LK-20260701-image-app-workflow-improvements.html index 1375d9c4d..234b8fb28 100644 --- a/site/history/records/2026/07/LK-20260701-image-app-workflow-improvements.html +++ b/site/history/records/2026/07/LK-20260701-image-app-workflow-improvements.html @@ -18,7 +18,7 @@
    - +

    history

    Image app workflow improvements

    @@ -66,7 +66,7 @@

    Evidence

  • Main commits 15a798ba and 70bfcfd4.
  • Known limitations and follow-up

    -

    Preview budgeting improved responsiveness but did not by itself remove every eager image read. Batch Crop file selection was made lazy in the following profiling work.

    +

    Preview budgeting improved responsiveness but did not by itself remove every eager image read. Batch Crop file selection was made lazy in the following profiling work.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260701-thermal-facade-and-flir-app.html b/site/history/records/2026/07/LK-20260701-thermal-facade-and-flir-app.html index 572ac68e5..0d86d07e8 100644 --- a/site/history/records/2026/07/LK-20260701-thermal-facade-and-flir-app.html +++ b/site/history/records/2026/07/LK-20260701-thermal-facade-and-flir-app.html @@ -18,7 +18,7 @@
    - +

    history

    Thermal facade and FLIR app

    @@ -56,7 +56,7 @@

    Evidence

  • Main commit 977c9457.
  • Known limitations and follow-up

    -

    Camera-specific metadata support depended on the formats represented by the synthetic and available local samples. Later changes refined display ranges, preview cost, and point and ROI measurement tools.

    +

    Camera-specific metadata support depended on the formats represented by the synthetic and available local samples. Later changes refined display ranges, preview cost, and point and ROI measurement tools.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260702-profiling-and-validation-speedups.html b/site/history/records/2026/07/LK-20260702-profiling-and-validation-speedups.html index fae835bf6..f8aa22cf9 100644 --- a/site/history/records/2026/07/LK-20260702-profiling-and-validation-speedups.html +++ b/site/history/records/2026/07/LK-20260702-profiling-and-validation-speedups.html @@ -18,7 +18,7 @@
    - +

    history

    Profiling and validation speedups

    @@ -61,7 +61,7 @@

    Evidence

  • Main commits c07dfc0a, 74025fee, eadcca82, 25912c54, and fcfc36d8.
  • Known limitations and follow-up

    -

    These changes reduced measured work but did not eliminate the white window shown before all startup setup completed. The following startup pass changed when windows were painted and when scroll navigation was installed.

    +

    These changes reduced measured work but did not eliminate the white window shown before all startup setup completed. The following startup pass changed when windows were painted and when scroll navigation was installed.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260702-startup-responsiveness.html b/site/history/records/2026/07/LK-20260702-startup-responsiveness.html index 1a1ef7ed9..52f6a3ec5 100644 --- a/site/history/records/2026/07/LK-20260702-startup-responsiveness.html +++ b/site/history/records/2026/07/LK-20260702-startup-responsiveness.html @@ -18,7 +18,7 @@
    - +

    history

    Startup responsiveness

    @@ -54,7 +54,7 @@

    Evidence

  • Main commit 7d4ef11e.
  • Known limitations and follow-up

    -

    Earlier painting improved perceived responsiveness, but it also made progress communication important. Later runtime work introduced explicit startup stages and lifecycle-managed readiness reporting.

    +

    Earlier painting improved perceived responsiveness, but it also made progress communication important. Later runtime work introduced explicit startup stages and lifecycle-managed readiness reporting.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260703-csc-export-and-viewport-policy.html b/site/history/records/2026/07/LK-20260703-csc-export-and-viewport-policy.html index 2fe7b1873..6afbb47a5 100644 --- a/site/history/records/2026/07/LK-20260703-csc-export-and-viewport-policy.html +++ b/site/history/records/2026/07/LK-20260703-csc-export-and-viewport-policy.html @@ -18,7 +18,7 @@
    - +

    history

    CSC export and viewport policy

    @@ -70,7 +70,7 @@

    Evidence

  • Main commit a69829c6.
  • Known limitations and follow-up

    -

    All-cycle CSV output was a separate export choice rather than a new session format. Later CSC work refined column naming and voltage/current organization.

    +

    All-cycle CSV output was a separate export choice rather than a new session format. Later CSC work refined column naming and voltage/current organization.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260703-declarative-app-runtime.html b/site/history/records/2026/07/LK-20260703-declarative-app-runtime.html index cd9e006a8..f1fdc9824 100644 --- a/site/history/records/2026/07/LK-20260703-declarative-app-runtime.html +++ b/site/history/records/2026/07/LK-20260703-declarative-app-runtime.html @@ -18,7 +18,7 @@
    - +

    history

    Declarative app runtime

    @@ -69,7 +69,7 @@

    Evidence

  • Main commit 568b3e9b.
  • Known limitations and follow-up

    -

    The first declarative vocabulary still used the mixed app, spec, view, tool, and diag package names. The UI 4 and UI 5 passes refined how groups, plots, interactions, and lifecycle responsibilities were named.

    +

    The first declarative vocabulary still used the mixed app, spec, view, tool, and diag package names. The UI 4 and UI 5 passes refined how groups, plots, interactions, and lifecycle responsibilities were named.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260703-ui-groups-migration.html b/site/history/records/2026/07/LK-20260703-ui-groups-migration.html index 153c7b9d1..ef92c2e6d 100644 --- a/site/history/records/2026/07/LK-20260703-ui-groups-migration.html +++ b/site/history/records/2026/07/LK-20260703-ui-groups-migration.html @@ -18,7 +18,7 @@
    - +

    history

    UI groups migration

    @@ -72,7 +72,7 @@

    Evidence

  • Main commit e81243a3.
  • Known limitations and follow-up

    -

    This was a breaking definition-format change and established the UI 4.x line. UI 5 later renamed facade packages by responsibility but retained explicit groups as the layout model.

    +

    This was a breaking definition-format change and established the UI 4.x line. UI 5 later renamed facade packages by responsibility but retained explicit groups as the layout model.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260704-ui-utility-snapshots-and-popout-tools.html b/site/history/records/2026/07/LK-20260704-ui-utility-snapshots-and-popout-tools.html index 704dfd45d..7e866d4b2 100644 --- a/site/history/records/2026/07/LK-20260704-ui-utility-snapshots-and-popout-tools.html +++ b/site/history/records/2026/07/LK-20260704-ui-utility-snapshots-and-popout-tools.html @@ -18,7 +18,7 @@
    - +

    history

    UI utility snapshots and popout tools

    @@ -53,7 +53,7 @@

    Evidence

  • Main commit 0155cd12.
  • Known limitations and follow-up

    -

    Snapshots covered state declared serializable by the app; they were not raw MATLAB workspace dumps. Figure Studio later provided a dedicated workflow for more extensive figure cleanup and export.

    +

    Snapshots covered state declared serializable by the app; they were not raw MATLAB workspace dumps. Figure Studio later provided a dedicated workflow for more extensive figure cleanup and export.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260706-ui-5-facade-redesign-app-migration-and-plot-refresh.html b/site/history/records/2026/07/LK-20260706-ui-5-facade-redesign-app-migration-and-plot-refresh.html index 31731390b..248ebba30 100644 --- a/site/history/records/2026/07/LK-20260706-ui-5-facade-redesign-app-migration-and-plot-refresh.html +++ b/site/history/records/2026/07/LK-20260706-ui-5-facade-redesign-app-migration-and-plot-refresh.html @@ -18,7 +18,7 @@
    - +

    history

    UI 5 facade redesign, app migration, and plot refresh

    @@ -116,7 +116,7 @@

    Evidence

  • FIG import normalization 7edd619f.
  • Known limitations and follow-up

    -

    UI 5 clarified package ownership, but app code still performed much of its own lifecycle and rendering coordination. Runtime V2 later took explicit ownership of startup, callbacks, presenters, injected services, and serializable state.

    +

    UI 5 clarified package ownership, but app code still performed much of its own lifecycle and rendering coordination. Runtime V2 later took explicit ownership of startup, callbacks, presenters, injected services, and serializable state.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260707-debug-workflows-launcher-tools-and-changelog-governance.html b/site/history/records/2026/07/LK-20260707-debug-workflows-launcher-tools-and-changelog-governance.html index 5b132e2b9..73b9f7a1a 100644 --- a/site/history/records/2026/07/LK-20260707-debug-workflows-launcher-tools-and-changelog-governance.html +++ b/site/history/records/2026/07/LK-20260707-debug-workflows-launcher-tools-and-changelog-governance.html @@ -18,7 +18,7 @@
    - +

    history

    Debug workflows, launcher tools, and changelog governance

    @@ -83,7 +83,7 @@

    Evidence

  • PR #34 squash merge 9db01952 and release tag v3.1.0.
  • Known limitations and follow-up

    -

    The first deployment tool packaged one app at a time. Multi-app bundles were added later. The changelog model introduced here was subsequently replaced by component-linked history pages generated with the documentation site.

    +

    The first deployment tool packaged one app at a time. Multi-app bundles were added later. The changelog model introduced here was subsequently replaced by component-linked history pages generated with the documentation site.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260709-default-labkit-close-protection.html b/site/history/records/2026/07/LK-20260709-default-labkit-close-protection.html index 4a31755ff..d7ec8bc20 100644 --- a/site/history/records/2026/07/LK-20260709-default-labkit-close-protection.html +++ b/site/history/records/2026/07/LK-20260709-default-labkit-close-protection.html @@ -18,7 +18,7 @@
    - +

    history

    Default LabKit close protection

    @@ -61,7 +61,7 @@

    Evidence

  • Mainline commit 0c9f472b.
  • Known limitations and follow-up

    -

    This policy protected the window, not the semantic completeness of each saved project. Runtime V2 later combined default close handling with app-owned dirty state and lifecycle services.

    +

    This policy protected the window, not the semantic completeness of each saved project. Runtime V2 later combined default close handling with app-owned dirty state and lifecycle services.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260709-preview-area-per-axis-wheel-zoom.html b/site/history/records/2026/07/LK-20260709-preview-area-per-axis-wheel-zoom.html index eeede8e9d..f5fe8c595 100644 --- a/site/history/records/2026/07/LK-20260709-preview-area-per-axis-wheel-zoom.html +++ b/site/history/records/2026/07/LK-20260709-preview-area-per-axis-wheel-zoom.html @@ -18,7 +18,7 @@
    - +

    history

    Preview-area per-axis wheel zoom

    @@ -54,7 +54,7 @@

    Evidence

  • Mainline commit 3c143eb.
  • Known limitations and follow-up

    -

    The option controlled wheel navigation only. Toolbar zoom, pan, linked axes, and app-specific limit policies remained separate concerns.

    +

    The option controlled wheel navigation only. Toolbar zoom, pan, linked axes, and app-specific limit policies remained separate concerns.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260713-base-matlab-image-compatibility.html b/site/history/records/2026/07/LK-20260713-base-matlab-image-compatibility.html index 134106987..de02b4c88 100644 --- a/site/history/records/2026/07/LK-20260713-base-matlab-image-compatibility.html +++ b/site/history/records/2026/07/LK-20260713-base-matlab-image-compatibility.html @@ -18,7 +18,7 @@
    - +

    history

    Base-MATLAB image compatibility

    @@ -66,7 +66,7 @@

    Evidence

  • Mainline commit bcd5f51f.
  • Known limitations and follow-up

    -

    Base MATLAB compatibility does not mean every optional accelerated algorithm is numerically identical by construction. Where a toolbox branch affects scientific values, representative parity and idempotency tests remain required until the repository-owned implementation fully replaces it.

    +

    Base MATLAB compatibility does not mean every optional accelerated algorithm is numerically identical by construction. Where a toolbox branch affects scientific values, representative parity and idempotency tests remain required until the repository-owned implementation fully replaces it.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260713-dic-rigid-point-editor.html b/site/history/records/2026/07/LK-20260713-dic-rigid-point-editor.html index 2a494fa77..bea7dda23 100644 --- a/site/history/records/2026/07/LK-20260713-dic-rigid-point-editor.html +++ b/site/history/records/2026/07/LK-20260713-dic-rigid-point-editor.html @@ -18,7 +18,7 @@
    - +

    history

    Single-click DIC rigid point matching

    @@ -49,7 +49,7 @@

    Validation

    Evidence

    Primary sources are labkit.ui.interaction.anchorEditor and dic_preprocess.userInterface.selectRigidPointPairs. Commit 392a073e introduced their shared DIC rigid-point interaction.

    Known limitations and follow-up

    -

    Automated hidden-GUI tests cannot judge pointing ergonomics; final interaction feel still requires a short manual placement-and-drag check.

    +

    Automated hidden-GUI tests cannot judge pointing ergonomics; final interaction feel still requires a short manual placement-and-drag check.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260713-flir-calibration-provenance.html b/site/history/records/2026/07/LK-20260713-flir-calibration-provenance.html index 874fec41f..1b296f63c 100644 --- a/site/history/records/2026/07/LK-20260713-flir-calibration-provenance.html +++ b/site/history/records/2026/07/LK-20260713-flir-calibration-provenance.html @@ -18,7 +18,7 @@
    - +

    history

    Traceable FLIR temperature calibration

    @@ -49,7 +49,7 @@

    Validation

    Evidence

    The model and provenance contract are documented in docs/api/thermal.md; branch checkpoint 7391e293 carries the implementation before the final squash merge.

    Known limitations and follow-up

    -

    Diagnostics describe source and fallback use; they do not estimate a physical uncertainty interval for a particular camera, surface, or environment.

    +

    Diagnostics describe source and fallback use; they do not estimate a physical uncertainty interval for a particular camera, surface, or environment.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260713-managed-calculation-constants.html b/site/history/records/2026/07/LK-20260713-managed-calculation-constants.html index 7068945e4..ea690a144 100644 --- a/site/history/records/2026/07/LK-20260713-managed-calculation-constants.html +++ b/site/history/records/2026/07/LK-20260713-managed-calculation-constants.html @@ -18,7 +18,7 @@
    - +

    history

    Managed scientific and conversion constants

    @@ -54,7 +54,7 @@

    Validation

    Evidence

    Source comments use the Constant: marker and guardrail diagnostics name the unmanaged file and line. Commit 125338c0 introduced the calculation-constant checks and the corresponding source annotations.

    Known limitations and follow-up

    -

    The scanner targets semantically suspicious precision and notation; review is still required for simple integers or short decimals whose meaning is hidden.

    +

    The scanner targets semantically suspicious precision and notation; review is still required for simple integers or short decimals whose meaning is hidden.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260713-matlab-compatible-image-conversion.html b/site/history/records/2026/07/LK-20260713-matlab-compatible-image-conversion.html index f6593f819..c5620b03c 100644 --- a/site/history/records/2026/07/LK-20260713-matlab-compatible-image-conversion.html +++ b/site/history/records/2026/07/LK-20260713-matlab-compatible-image-conversion.html @@ -54,7 +54,7 @@

    Validation

    Evidence

    The current API contracts are documented in the Image Library. Commit e3f71c2d aligned the conversion functions with MATLAB call behavior.

    Known limitations and follow-up

    -

    The compatibility layer intentionally covers the LabKit-used MATLAB contracts, not every Image Processing Toolbox function.

    +

    The compatibility layer intentionally covers the LabKit-used MATLAB contracts, not every Image Processing Toolbox function.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260714-video-marker-continuous-marking.html b/site/history/records/2026/07/LK-20260714-video-marker-continuous-marking.html index f1f46a356..b0015f955 100644 --- a/site/history/records/2026/07/LK-20260714-video-marker-continuous-marking.html +++ b/site/history/records/2026/07/LK-20260714-video-marker-continuous-marking.html @@ -18,7 +18,7 @@
    - +

    history

    Video Marker visual skeleton setup and continuous marking

    @@ -60,7 +60,7 @@

    Evidence

  • Continuous-marking and skeleton workflow redesign bcba1833.
  • Known limitations and follow-up

    -

    Automated hidden-GUI tests verify callbacks and state transitions but cannot judge visual density or pointer ergonomics; those still need a short manual review with a representative video.

    +

    Automated hidden-GUI tests verify callbacks and state transitions but cannot judge visual density or pointer ergonomics; those still need a short manual review with a representative video.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260714-video-marker-predictive-navigation.html b/site/history/records/2026/07/LK-20260714-video-marker-predictive-navigation.html index d8362606c..8c0548ad8 100644 --- a/site/history/records/2026/07/LK-20260714-video-marker-predictive-navigation.html +++ b/site/history/records/2026/07/LK-20260714-video-marker-predictive-navigation.html @@ -18,7 +18,7 @@
    - +

    history

    Video Marker predictive frame navigation

    @@ -61,7 +61,7 @@

    Evidence

  • Predictive marking and portable file references 6bfd74c8.
  • Known limitations and follow-up

    -

    KLT is a short-range tracker. Low-texture, occluded, or strongly deforming points can be rejected and require human correction; that correction is the next anchor rather than an error state.

    +

    KLT is a short-range tracker. Low-texture, occluded, or strongly deforming points can be rejected and require human correction; that correction is the next anchor rather than an error state.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260715-runtime-v2-app-migration.html b/site/history/records/2026/07/LK-20260715-runtime-v2-app-migration.html index 103cbe5a5..9392787af 100644 --- a/site/history/records/2026/07/LK-20260715-runtime-v2-app-migration.html +++ b/site/history/records/2026/07/LK-20260715-runtime-v2-app-migration.html @@ -18,7 +18,7 @@
    - +

    history

    Runtime V2 lifecycle ownership across the app fleet

    @@ -93,7 +93,7 @@

    Evidence

  • The commits listed in the migration sequence above preserve the individual app checkpoints; this record explains their shared architectural result.
  • Known limitations and follow-up

    -

    Explicit domain contracts mean that large apps do not necessarily have fewer production lines. Further extraction is justified only by repeated mechanics, not by file-size targets. Pointer feel, drag ergonomics, and scientific visual judgment still require the documented manual app review before merge.

    +

    Explicit domain contracts mean that large apps do not necessarily have fewer production lines. Further extraction is justified only by repeated mechanics, not by file-size targets. Pointer feel, drag ergonomics, and scientific visual judgment still require the documented manual app review before merge.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-destination-rebased-source-references.html b/site/history/records/2026/07/LK-20260716-destination-rebased-source-references.html index ee63fce5e..07e65a054 100644 --- a/site/history/records/2026/07/LK-20260716-destination-rebased-source-references.html +++ b/site/history/records/2026/07/LK-20260716-destination-rebased-source-references.html @@ -18,7 +18,7 @@
    - +

    history

    Destination-rebased source references

    @@ -54,7 +54,7 @@

    Evidence

  • UI Framework describes Runtime V2 project ownership and persistence.
  • Known limitations and follow-up

    -

    The portable-reference creation and resolution algorithms remain public in UI 6 for compatibility. They are implementation mechanics rather than App-facing workflow APIs and are reviewed with the remaining Runtime public surface for a single future major-boundary cleanup.

    +

    The portable-reference creation and resolution algorithms remain public in UI 6 for compatibility. They are implementation mechanics rather than App-facing workflow APIs and are reviewed with the remaining Runtime public surface for a single future major-boundary cleanup.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-dic-source-slots.html b/site/history/records/2026/07/LK-20260716-dic-source-slots.html index e7c3bd747..3765d5b82 100644 --- a/site/history/records/2026/07/LK-20260716-dic-source-slots.html +++ b/site/history/records/2026/07/LK-20260716-dic-source-slots.html @@ -18,7 +18,7 @@
    - +

    history

    DIC uses framework-owned optional source slots

    @@ -58,7 +58,7 @@

    Evidence

  • Runtime and Lifecycle
  • Known limitations and follow-up

    -

    Other families still contain direct portable-reference reads. They will move to the same accessor before the repository-wide no-leak contract is enabled.

    +

    Other families still contain direct portable-reference reads. They will move to the same accessor before the repository-wide no-leak contract is enabled.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-explicit-autosave.html b/site/history/records/2026/07/LK-20260716-explicit-autosave.html index 7f5ad6ac3..5095921c1 100644 --- a/site/history/records/2026/07/LK-20260716-explicit-autosave.html +++ b/site/history/records/2026/07/LK-20260716-explicit-autosave.html @@ -18,7 +18,7 @@
    - +

    history

    Explicit application autosave

    @@ -55,7 +55,7 @@

    Evidence

  • Video Marker documents the Session button and no-dialog behavior.
  • Known limitations and follow-up

    -

    An autosave is a recovery aid, not a user-named archival project. Users still use Save State when they need a deliberate project filename or location.

    +

    An autosave is a recovery aid, not a user-named archival project. Users still use Save State when they need a deliberate project filename or location.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-figure-studio-single-definition.html b/site/history/records/2026/07/LK-20260716-figure-studio-single-definition.html index fdd7e6012..1309750d7 100644 --- a/site/history/records/2026/07/LK-20260716-figure-studio-single-definition.html +++ b/site/history/records/2026/07/LK-20260716-figure-studio-single-definition.html @@ -18,7 +18,7 @@
    - +

    history

    Figure Studio adopts one product definition

    @@ -57,7 +57,7 @@

    Evidence

  • Runtime and Lifecycle defines the shared product contract.
  • Known limitations and follow-up

    -

    Figure Studio still has a meaningful version-1 project schema, rebuildable plot session cache, and startup resource installation. These will move to the projectSpec/action capability model after the framework supports that model; they were not deleted merely to reduce file count.

    +

    Figure Studio still has a meaningful version-1 project schema, rebuildable plot session cache, and startup resource installation. These will move to the projectSpec/action capability model after the framework supports that model; they were not deleted merely to reduce file count.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-gui-free-source-records.html b/site/history/records/2026/07/LK-20260716-gui-free-source-records.html index 168e436ff..3753b825d 100644 --- a/site/history/records/2026/07/LK-20260716-gui-free-source-records.html +++ b/site/history/records/2026/07/LK-20260716-gui-free-source-records.html @@ -18,7 +18,7 @@
    - +

    history

    Runtime exposes GUI-free source-record creation

    @@ -55,7 +55,7 @@

    Evidence

  • Architecture
  • Known limitations and follow-up

    -

    Existing Apps that still copy or read portable-reference fields must migrate to sourceRecord and sourcePaths before the legacy source boundary is fully retired.

    +

    Existing Apps that still copy or read portable-reference fields must migrate to sourceRecord and sourcePaths before the legacy source boundary is fully retired.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-minimal-app-definition.html b/site/history/records/2026/07/LK-20260716-minimal-app-definition.html index 30bbf3ee2..95741d188 100644 --- a/site/history/records/2026/07/LK-20260716-minimal-app-definition.html +++ b/site/history/records/2026/07/LK-20260716-minimal-app-definition.html @@ -18,7 +18,7 @@
    - +

    history

    Minimal App definitions

    @@ -56,7 +56,7 @@

    Evidence

  • App Development maps files to capabilities instead of treating them as universal boilerplate.
  • Known limitations and follow-up

    -

    Apps that own a project schema still use explicit project specification. The next lifecycle cleanup consolidates each App's project factory, validator, and ordered migration steps behind one projectSpec.m entry point rather than scattered version-step files.

    +

    Apps that own a project schema still use explicit project specification. The next lifecycle cleanup consolidates each App's project factory, validator, and ordered migration steps behind one projectSpec.m entry point rather than scattered version-step files.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-opaque-source-import.html b/site/history/records/2026/07/LK-20260716-opaque-source-import.html index bb7346876..72fb27564 100644 --- a/site/history/records/2026/07/LK-20260716-opaque-source-import.html +++ b/site/history/records/2026/07/LK-20260716-opaque-source-import.html @@ -18,7 +18,7 @@
    - +

    history

    Legacy import preserves portable references without exposing their schema

    @@ -55,7 +55,7 @@

    Evidence

  • Architecture
  • Known limitations and follow-up

    -

    Opaque-reference import is intended for trusted project importers, not as a general App-defined serialization extension. Video Marker is the first legacy importer scheduled to consume the overload.

    +

    Opaque-reference import is intended for trusted project importers, not as a general App-defined serialization extension. Video Marker is the first legacy importer scheduled to consume the overload.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-public-api-help-contracts.html b/site/history/records/2026/07/LK-20260716-public-api-help-contracts.html index 13fc4001a..04ed94da5 100644 --- a/site/history/records/2026/07/LK-20260716-public-api-help-contracts.html +++ b/site/history/records/2026/07/LK-20260716-public-api-help-contracts.html @@ -18,7 +18,7 @@
    - +

    history

    Public library help contracts and reference validation

    @@ -60,7 +60,7 @@

    Evidence

  • Public help blocks and generated API pages are the maintained contract.
  • Known limitations and follow-up

    -

    Interactive calls and calls requiring laboratory files remain Typical Call: sketches until the repository provides synthetic, non-sensitive fixtures that make them executable examples.

    +

    Interactive calls and calls requiring laboratory files remain Typical Call: sketches until the repository provides synthetic, non-sensitive fixtures that make them executable examples.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-runtime-identity-contracts.html b/site/history/records/2026/07/LK-20260716-runtime-identity-contracts.html index 38468db07..603d9b436 100644 --- a/site/history/records/2026/07/LK-20260716-runtime-identity-contracts.html +++ b/site/history/records/2026/07/LK-20260716-runtime-identity-contracts.html @@ -18,7 +18,7 @@
    - +

    history

    Validated runtime identity contracts

    @@ -57,7 +57,7 @@

    Evidence

  • The app identity contract scans every public app definition.
  • Known limitations and follow-up

    -

    Identity validation protects one runtime and the public app catalog. External private-app catalogs remain responsible for running the same contract test in their own workspace.

    +

    Identity validation protects one runtime and the public app catalog. External private-app catalogs remain responsible for running the same contract test in their own workspace.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-runtime-owned-project-shape-validation.html b/site/history/records/2026/07/LK-20260716-runtime-owned-project-shape-validation.html index d776ffa96..c0b582bdd 100644 --- a/site/history/records/2026/07/LK-20260716-runtime-owned-project-shape-validation.html +++ b/site/history/records/2026/07/LK-20260716-runtime-owned-project-shape-validation.html @@ -18,7 +18,7 @@
    - +

    history

    Runtime-owned project shape validation

    @@ -60,7 +60,7 @@

    Evidence

  • Each affected Electrochemistry App manual identifies its domain-owned validator responsibilities.
  • Known limitations and follow-up

    -

    Other App families still repeat some canonical structure checks. They will move to the same boundary only after their role and cross-field constraints are separated from the generic checks and covered by focused tests.

    +

    Other App families still repeat some canonical structure checks. They will move to the same boundary only after their role and cross-field constraints are separated from the generic checks and covered by focused tests.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-runtime-owned-session-defaults.html b/site/history/records/2026/07/LK-20260716-runtime-owned-session-defaults.html index e04d9ea69..b0e4bc3fb 100644 --- a/site/history/records/2026/07/LK-20260716-runtime-owned-session-defaults.html +++ b/site/history/records/2026/07/LK-20260716-runtime-owned-session-defaults.html @@ -18,7 +18,7 @@
    - +

    history

    Runtime-owned session defaults across App families

    @@ -72,7 +72,7 @@

    Evidence

  • Each affected App manual describes its remaining session-owned fields.
  • Known limitations and follow-up

    -

    Most factories still perform necessary source decoding or reconstruction. They should not be removed merely to reduce file count. Further simplification requires evidence that a repeated reconstruction pattern is domain-neutral and belongs behind a stable Runtime service.

    +

    Most factories still perform necessary source decoding or reconstruction. They should not be removed merely to reduce file count. Further simplification requires evidence that a repeated reconstruction pattern is domain-neutral and belongs behind a stable Runtime service.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-runtime-source-relink.html b/site/history/records/2026/07/LK-20260716-runtime-source-relink.html index e133f355e..d3da67755 100644 --- a/site/history/records/2026/07/LK-20260716-runtime-source-relink.html +++ b/site/history/records/2026/07/LK-20260716-runtime-source-relink.html @@ -18,7 +18,7 @@
    - +

    history

    Interactive recovery of missing project sources

    @@ -56,7 +56,7 @@

    Evidence

  • Runtime source resolution reruns before fresh-session construction and state commit.
  • Known limitations and follow-up

    -

    The default flow asks once for each unresolved source. Apps with a specialized multi-file schema may continue to provide a custom relinking callback.

    +

    The default flow asks once for each unresolved source. Apps with a specialized multi-file schema may continue to provide a custom relinking callback.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-single-definition-contract.html b/site/history/records/2026/07/LK-20260716-single-definition-contract.html index 52a153d10..2cadcd8cb 100644 --- a/site/history/records/2026/07/LK-20260716-single-definition-contract.html +++ b/site/history/records/2026/07/LK-20260716-single-definition-contract.html @@ -18,7 +18,7 @@
    - +

    history

    Single-definition App product contract

    @@ -54,7 +54,7 @@

    Evidence

  • App Development shows the reduced static App file set.
  • Known limitations and follow-up

    -

    Existing Apps, launcher static discovery, release version guards, tutorials, and the App-builder skill still need family-by-family migration. The legacy launch bridge is removed only after those consumers no longer use it.

    +

    Existing Apps, launcher static discovery, release version guards, tutorials, and the App-builder skill still need family-by-family migration. The legacy launch bridge is removed only after those consumers no longer use it.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-single-project-migration-entry.html b/site/history/records/2026/07/LK-20260716-single-project-migration-entry.html index 3420d0ead..f1c4dbe6f 100644 --- a/site/history/records/2026/07/LK-20260716-single-project-migration-entry.html +++ b/site/history/records/2026/07/LK-20260716-single-project-migration-entry.html @@ -18,7 +18,7 @@
    - +

    history

    One project migration entry per App

    @@ -54,7 +54,7 @@

    Evidence

  • App Development describes the single project file.
  • Known limitations and follow-up

    -

    Existing Apps still need their lifecycle functions consolidated into projectSpec.m. The legacy migration-array bridge will be removed after the last family is migrated.

    +

    Existing Apps still need their lifecycle functions consolidated into projectSpec.m. The legacy migration-array bridge will be removed after the last family is migrated.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-source-adjacent-video-autosave.html b/site/history/records/2026/07/LK-20260716-source-adjacent-video-autosave.html index 2447f60c3..4a20283d3 100644 --- a/site/history/records/2026/07/LK-20260716-source-adjacent-video-autosave.html +++ b/site/history/records/2026/07/LK-20260716-source-adjacent-video-autosave.html @@ -18,7 +18,7 @@
    - +

    history

    Source-adjacent Video Marker autosave

    @@ -54,7 +54,7 @@

    Evidence

  • Runtime and lifecycle distinguishes generic recovery from an app-targeted autosave.
  • Known limitations and follow-up

    -

    The source folder must be writable. A write failure leaves the current project untouched and is shown to the user.

    +

    The source folder must be writable. A write failure leaves the current project untouched and is shown to the user.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-source-path-accessor.html b/site/history/records/2026/07/LK-20260716-source-path-accessor.html index 9b73107ae..01c9abd40 100644 --- a/site/history/records/2026/07/LK-20260716-source-path-accessor.html +++ b/site/history/records/2026/07/LK-20260716-source-path-accessor.html @@ -18,7 +18,7 @@
    - +

    history

    Runtime owns portable source-reference details

    @@ -56,7 +56,7 @@

    Evidence

  • Complete App Tutorial
  • Known limitations and follow-up

    -

    Existing App-local path loops remain until their owning App commits are migrated and behavior-tested. Once all consumers use this accessor, a contract guard will prevent production Apps from reading portable-reference fields.

    +

    Existing App-local path loops remain until their owning App commits are migrated and behavior-tested. Once all consumers use this accessor, a contract guard will prevent production Apps from reading portable-reference fields.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260716-ui7-public-runtime-boundary.html b/site/history/records/2026/07/LK-20260716-ui7-public-runtime-boundary.html index 27c51518e..c36347685 100644 --- a/site/history/records/2026/07/LK-20260716-ui7-public-runtime-boundary.html +++ b/site/history/records/2026/07/LK-20260716-ui7-public-runtime-boundary.html @@ -18,7 +18,7 @@
    - +

    history

    UI 7 public runtime boundary

    @@ -76,7 +76,7 @@

    Evidence

  • Runtime and Data Model explains semantic transactions, injected services, and portable source behavior.
  • Known limitations and follow-up

    -

    This boundary change does not redesign the lower-level declarative workbench builder. labkit.ui.runtime.create remains supported and will be judged by its own layout-only contract rather than by production App call count.

    +

    This boundary change does not redesign the lower-level declarative workbench builder. labkit.ui.runtime.create remains supported and will be judged by its own layout-only contract rather than by production App call count.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260717-binding-and-resource-defaults.html b/site/history/records/2026/07/LK-20260717-binding-and-resource-defaults.html index a7c8df893..da647792f 100644 --- a/site/history/records/2026/07/LK-20260717-binding-and-resource-defaults.html +++ b/site/history/records/2026/07/LK-20260717-binding-and-resource-defaults.html @@ -18,7 +18,7 @@
    - +

    history

    Binding-only controls and default resource cleanup

    @@ -55,7 +55,7 @@

    Evidence

  • Video Marker describes the preset and decoded-video resource behavior.
  • Known limitations and follow-up

    -

    Other bound events still require App actions when they normalize values, invalidate results, update previews, or log workflow changes. They should be reviewed by behavior rather than removed merely because their handlers are short.

    +

    Other bound events still require App actions when they normalize values, invalidate results, update previews, or log workflow changes. They should be reviewed by behavior rather than removed merely because their handlers are short.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260717-complete-public-api-help-contract.html b/site/history/records/2026/07/LK-20260717-complete-public-api-help-contract.html index 25fbcb61f..3bbc83393 100644 --- a/site/history/records/2026/07/LK-20260717-complete-public-api-help-contract.html +++ b/site/history/records/2026/07/LK-20260717-complete-public-api-help-contract.html @@ -73,7 +73,7 @@

    Evidence

  • Documentation maintenance
  • Known limitations and follow-up

    -

    Implementation-derived option discovery intentionally recognizes the repository's standard option access patterns; unusual dynamic field access still requires review. Private helpers remain outside the public-page contract, and interactive Typical Call: sketches are not executed as file-independent examples.

    +

    Implementation-derived option discovery intentionally recognizes the repository's standard option access patterns; unusual dynamic field access still requires review. Private helpers remain outside the public-page contract, and interactive Typical Call: sketches are not executed as file-independent examples.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260717-retired-runtime-compatibility.html b/site/history/records/2026/07/LK-20260717-retired-runtime-compatibility.html index dbe1036d6..1a0cabc8c 100644 --- a/site/history/records/2026/07/LK-20260717-retired-runtime-compatibility.html +++ b/site/history/records/2026/07/LK-20260717-retired-runtime-compatibility.html @@ -18,7 +18,7 @@
    - +

    history

    Runtime uses one source contract for launch, migration, and layout

    @@ -57,7 +57,7 @@

    Evidence

  • Public API reference
  • Known limitations and follow-up

    -

    Historical records retain their original descriptions of the transitional APIs. They document how the architecture evolved and are not current usage guidance.

    +

    Historical records retain their original descriptions of the transitional APIs. They document how the architecture evolved and are not current usage guidance.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260717-strict-project-session-restore.html b/site/history/records/2026/07/LK-20260717-strict-project-session-restore.html index 789e332d4..ac697a837 100644 --- a/site/history/records/2026/07/LK-20260717-strict-project-session-restore.html +++ b/site/history/records/2026/07/LK-20260717-strict-project-session-restore.html @@ -18,7 +18,7 @@
    - +

    history

    Project restore distinguishes missing sources from damaged sources

    @@ -66,7 +66,7 @@

    Evidence

  • Nerve Response Analysis
  • Known limitations and follow-up

    -

    Automated hidden GUI tests verify state rollback and semantic error delivery; they do not replace manual review of native dialog wording or damaged third-party file variants.

    +

    Automated hidden GUI tests verify state rollback and semantic error delivery; they do not replace manual review of native dialog wording or damaged third-party file variants.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.html b/site/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.html index a776c9d5b..6ece8d9e4 100644 --- a/site/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.html +++ b/site/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.html @@ -18,7 +18,7 @@
    - +

    history

    T-Test Wizard adds table selection and first-versus-each comparisons

    @@ -76,7 +76,7 @@

    Evidence

  • Runtime and Lifecycle
  • Known limitations and follow-up

    -

    Version 1.0.0 does not infer experimental units, independence, pairing, exclusions, or groups from filenames. It does not apply multiple-comparison correction, ANOVA, nonparametric tests, regression, or mixed modeling. Automatic numeric-column suggestions remain a possible convenience only after real use demonstrates a stable need.

    +

    Version 1.0.0 does not infer experimental units, independence, pairing, exclusions, or groups from filenames. It does not apply multiple-comparison correction, ANOVA, nonparametric tests, regression, or mixed modeling. Automatic numeric-column suggestions remain a possible convenience only after real use demonstrates a stable need.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260719-headless-validation-repair.html b/site/history/records/2026/07/LK-20260719-headless-validation-repair.html index 1cc40fffd..f389a492a 100644 --- a/site/history/records/2026/07/LK-20260719-headless-validation-repair.html +++ b/site/history/records/2026/07/LK-20260719-headless-validation-repair.html @@ -57,7 +57,7 @@

    Evidence

  • Testing LabKit
  • Known limitations and follow-up

    -

    Interactive native-dialog behavior remains a developer-led manual validation responsibility; hidden GUI automation does not replace it.

    +

    Interactive native-dialog behavior remains a developer-led manual validation responsibility; hidden GUI automation does not replace it.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260719-ui-explicit-contract-migration.html b/site/history/records/2026/07/LK-20260719-ui-explicit-contract-migration.html new file mode 100644 index 000000000..fd4b61e41 --- /dev/null +++ b/site/history/records/2026/07/LK-20260719-ui-explicit-contract-migration.html @@ -0,0 +1,118 @@ + + + + + + + +App SDK explicit contract replaces the retired UI runtime - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    history

    +

    App SDK explicit contract replaces the retired UI runtime

    +
    schema: 2
    +id: LK-20260719-ui-explicit-contract-migration
    +date: 2026-07-19
    +sequence: 138
    +type: refactor
    +compatibility: breaking
    +component: `labkit.app` | `new -> 1.0.0`
    +component: `labkit_DICPostprocess_app` | `1.4.7 -> 1.5.0`
    +component: `labkit_DICPreprocess_app` | `1.5.8 -> 1.6.0`
    +component: `labkit_ChronoOverlay_app` | `1.4.7 -> 1.5.0`
    +component: `labkit_CIC_app` | `1.4.7 -> 1.5.0`
    +component: `labkit_CSC_app` | `1.4.8 -> 1.5.0`
    +component: `labkit_EIS_app` | `1.4.7 -> 1.5.0`
    +component: `labkit_VTResistance_app` | `1.4.7 -> 1.5.0`
    +component: `labkit_GaitAnalysis_app` | `2.0.8 -> 2.1.0`
    +component: `labkit_BatchImageCrop_app` | `1.7.7 -> 1.8.0`
    +component: `labkit_CurvatureMeasurement_app` | `1.4.6 -> 1.5.0`
    +component: `labkit_FLIRThermal_app` | `1.4.8 -> 1.5.0`
    +component: `labkit_FocusStack_app` | `1.5.6 -> 1.6.0`
    +component: `labkit_ImageEnhance_app` | `1.6.7 -> 1.7.0`
    +component: `labkit_ImageMatch_app` | `1.6.8 -> 1.7.0`
    +component: `labkit_VideoMarker_app` | `1.5.7 -> 1.6.0`
    +component: `labkit_FigureStudio_app` | `0.2.9 -> 0.3.0`
    +component: `labkit_NerveResponseAnalysis_app` | `1.4.8 -> 1.5.0`
    +component: `labkit_ResponseReviewStats_app` | `1.4.7 -> 1.5.0`
    +component: `labkit_RHSPreview_app` | `1.4.6 -> 1.5.0`
    +component: `labkit_TTestWizard_app` | `1.0.1 -> 1.1.0`
    +component: `labkit_ECGPrint_app` | `1.4.6 -> 1.5.0`
    +scope: App Framework
    +scope: DIC
    +scope: Electrochem
    +scope: Gait
    +scope: Image Measurement
    +scope: LabKit Core
    +scope: Neurophysiology
    +scope: Statistics
    +scope: Wearable
    +scope: Project persistence
    +scope: Result provenance
    +

    Context

    +

    The retired UI runtime removed substantial per-App lifecycle code, but Apps still registered callback tables, repeated bound values in presenters, authored standard file add/remove/clear behavior, and depended on nested event/service structs. A replacement SDK kernel had already established immutable semantic values, pre-GUI validation, transactional state/presentation commits, project documents, result manifests, resources, and portable sources. The migration then had to restore the complete behavior and visual contract of every tracked App before the retired production facade could be deleted.

    +

    Decision and rationale

    +

    Create labkit.app as the stable SDK rather than misnaming the expanded contract labkit.ui or adapting it back to Runtime V2 transport structs. Keep the public root small, partition authoring by capability, and concentrate complexity in a paved path: direct-callback layout.* nodes, strict bindings, runtime-completed view.Snapshot values, standard file lifecycle, fixed CreateSession(project,context), and private native adapters.

    +

    Migrate all 21 tracked Apps through capability waves, treating their previous controls, tabs, layout proportions, interactions, project behavior, results, debug samples, and workflow wording as product contracts rather than reducing the task to launch compatibility.

    +

    Changes

    +
      +
    • Added the private native MATLAB adapter with semantic component ownership, typed RuntimeKernel callbacks, native dialog results, complete-presentation reconciliation, and rollback to the previous native view after a failed renderer commit.
    • +
    • Added Definition.launch, fixed renderer (axes,model) dispatch, semantic labels, runtime-owned file add/remove/clear and selection, and transient session rebuild after source collection changes.
    • +
    • Added strict table view options, typed complete-data edits, and distinct event.TableCellEdit, event.TableCellSelection, and event.ListSelection values; the private adapter absorbs native MATLAB table-value differences.
    • +
    • Fixed session construction to CreateSession(project,context) so Apps resolve opaque portable sources without reading their representation.
    • +
    • Migrated Chrono Overlay to one directly bound export callback, four state bindings, one directly bound two-axis renderer, and a two-operation view snapshot.
    • +
    • Partitioned the public SDK into layout, view, event, project, result, and dialog; layout nodes, option parsing, stores, adapters, and runtime execution remain hidden under internal.
    • +
    • Reduced Chrono's noncomment layout/action/presenter code from 277 lines to 86 while preserving its DTA alignment, plot options, project schema, CSV columns, and result provenance.
    • +
    • Migrated T-Test Wizard as the typed editable-table, feature-fragment, and multi-page workspace proof: table selections and edits have explicit payload classes, +workbench exposes product assembly, workflow packages own their layout/presentation/actions, and the private adapter owns concrete layout.
    • +
    • Migrated VT Resistance to direct file and analysis-setting bindings, a complete summary/table/two-axis snapshot, and an App-owned result-package export. Plot renderers and scientific choices now live with their owning analysis capabilities instead of a technical UI package.
    • +
    • Migrated Gait Analysis to capability-owned source adoption, option invalidation, deterministic analysis, step selection/navigation, three-axis rendering, CSV-set export, and result packaging.
    • +
    • Migrated DIC Preprocess to two role-bound image sources, paired-anchor registration, managed crop and mask editors, two-axis rendering, edit replay, and result-package exports owned by its analysis, mask, and result capabilities.
    • +
    • Migrated Batch Image Crop to framework-owned source selection plus App-owned duplicate crop tasks, direct crop-center and scale-reference interactions, capability snapshots, and result-package export.
    • +
    • Added labkit.app.project.sourceRecord so pure payload migrations can convert legacy paths into portable sources without constructing the framework-owned reference representation.
    • +
    • Corrected folder chooser dispatch to its one-path backend contract and applied table data before table selection during native reconciliation, so a selection may legally target rows introduced by the same snapshot.
    • +
    • Preserved cell-valued interaction payloads in the transactional event queue and kept multi-target interaction bridge specifications scalar, enabling paired-anchor gestures across two semantic axes.
    • +
    • Removed handler objects, callback tables, renderer registries, and their forwarding from the App authoring contract. Layout controls and plot areas reference concrete functions directly.
    • +
    • Migrated the remaining electrochemistry, DIC, image-measurement, neurophysiology, core, wearable, and high-state workflows, including editable tables, workspace pages, file roles, multi-axis plots, managed point/rectangle/interval/scale interactions, long-lived video resources, project recovery, result packages, and synthetic diagnostic samples.
    • +
    • Restored shared product presentation: versioned titles and dirty markers, startup progress and failure surfaces, guarded close behavior, utility menus, adjustable pane dividers, scroll and grow policies, numeric panners, adaptive action grids, usage text, plot navigation, pop-out/export tools, and viewport-preserving overlays.
    • +
    • Internalized contract compilation, runtime construction, native platform plans, target inventories, and callback-context creation. Definition is the sole author-created root; CallbackContext is a sealed runtime-injected port; optional concepts remain grouped under purpose-specific packages.
    • +
    • Deleted the complete retired labkit.ui production facade and the migration-only analyzer, prototypes, compatibility tools, and debt-only tests after source scans and focused App tests proved that no production consumer remained.
    • +
    +

    User and data impact

    +

    Chrono Overlay retains its input formats, pulse-gap alignment, plot meanings, parameter defaults, CSV table, and version-2 project payload. T-Test Wizard retains its source formats, group/test calculations, plot meaning, two CSV exports, and version-2 project payload. VT Resistance retains its pulse detection, resistance calculations, plot semantics, CSV schema, and version-1 project payload while recomputing the decoded batch under shared settings. Gait Analysis retains its Video Marker payload contract, project migrations, step segmentation, gait metrics, CSV set, and version-3 project payload. File identities and portable paths remain runtime-owned. DIC Preprocess retains its rigid registration, common crop, mask editing, image/mask exports, and version-1 project payload. Batch Image Crop retains duplicate tasks per source, fixed-pixel and physical crops, rotation/padding, scale calibration, scale-bar placement, image/CSV exports, and its version-2 payload. Existing payload migrations remain App-owned.

    +

    The other 15 Apps retain their documented source formats, scientific calculations, units, thresholds, project payload versions, export schemas, and result meanings. Their product versions advance once from the main baseline to identify the new App SDK source contract and restored complete UI behavior. No project payload version was increased merely because the UI framework changed.

    +

    Compatibility and migration

    +

    labkit.app 1 is a source-breaking replacement contract for App definitions, presenters, callbacks, events, and interactions. Every tracked App migrated before the retired labkit.ui boundary was deleted; there is no runtime adapter or dual authoring surface. Public App entrypoint commands remain stable. Project documents retain their format and App payload versions independently of the facade transition.

    +

    Validation

    +

    Focused headless tests cover strict values, transactional runtime behavior, project save/restore, authoring defaults, and Chrono calculations/exports. Hidden GUI tests cover native semantic construction, typed control and table callbacks, bound side effects, standard file lifecycle, transient session rebuild, two-axis rendering, viewport preservation, renderer rollback, Chrono export, and project restore. VT Resistance focused tests cover resistance calculations, CSV compatibility, native layout, shared batch recomputation, two-axis rendering, result packaging, and project restore. Gait focused tests cover project migration, pose decoding, scientific calculations, CSV compatibility, typed table navigation, three-axis rendering, folder selection, result packaging, and project restore. DIC Preprocess focused tests cover project state, image loading, edit replay, registration/crop/mask helpers, export manifests, native two-axis layout, paired-anchor alignment, and managed crop interaction. Framework regression tests cover cell-valued event payloads and native multi-axis interaction bridging. Batch Image Crop focused tests cover crop geometry, padding, physical scaling, project migration, duplicate tasks, output planning/writes, native semantic layout, current-center editing, and standard result manifests. Focused framework and App tests additionally cover all 21 semantic layouts, typed events, managed interactions, project migration/recovery, result writing, synthetic sample packs, diagnostics, resource cleanup, window titles, startup success/failure, close behavior, and native adapter reconciliation.

    +

    Evidence

    + +

    Known limitations and follow-up

    +

    Automated GUI evidence does not replace developer-led validation of native dialogs, editable-table feel, pointer interaction, long-lived resource use, representative exports, visual quality, or scientific workflow suitability. That interactive validation remains a release input for the exact integrated commit.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/history/records/2026/07/LK-20260720-app-action-tooltips.html b/site/history/records/2026/07/LK-20260720-app-action-tooltips.html new file mode 100644 index 000000000..98e021ba1 --- /dev/null +++ b/site/history/records/2026/07/LK-20260720-app-action-tooltips.html @@ -0,0 +1,84 @@ + + + + + + + +App actions require explanatory hover help - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    history

    +

    App actions require explanatory hover help

    +
    schema: 2
    +id: LK-20260720-app-action-tooltips
    +date: 2026-07-20
    +sequence: 140
    +type: feat
    +compatibility: compatible
    +component: `labkit.app` | `1.0.0 -> 1.1.0`
    +component: `labkit_DICPostprocess_app` | `1.5.0 -> 1.5.1`
    +component: `labkit_DICPreprocess_app` | `1.6.0 -> 1.6.1`
    +component: `labkit_ChronoOverlay_app` | `1.5.0 -> 1.5.1`
    +component: `labkit_CIC_app` | `1.5.0 -> 1.5.1`
    +component: `labkit_CSC_app` | `1.5.0 -> 1.5.1`
    +component: `labkit_EIS_app` | `1.5.0 -> 1.5.1`
    +component: `labkit_VTResistance_app` | `1.5.0 -> 1.5.1`
    +component: `labkit_GaitAnalysis_app` | `2.1.0 -> 2.1.1`
    +component: `labkit_BatchImageCrop_app` | `1.8.0 -> 1.8.1`
    +component: `labkit_CurvatureMeasurement_app` | `1.5.0 -> 1.5.1`
    +component: `labkit_FLIRThermal_app` | `1.5.0 -> 1.5.1`
    +component: `labkit_FocusStack_app` | `1.6.0 -> 1.6.1`
    +component: `labkit_ImageEnhance_app` | `1.7.0 -> 1.7.1`
    +component: `labkit_ImageMatch_app` | `1.7.0 -> 1.7.1`
    +component: `labkit_VideoMarker_app` | `1.6.0 -> 1.6.1`
    +component: `labkit_FigureStudio_app` | `0.3.0 -> 0.3.1`
    +component: `labkit_NerveResponseAnalysis_app` | `1.5.0 -> 1.5.1`
    +component: `labkit_ResponseReviewStats_app` | `1.5.0 -> 1.5.1`
    +component: `labkit_RHSPreview_app` | `1.5.0 -> 1.5.1`
    +component: `labkit_TTestWizard_app` | `1.1.0 -> 1.1.1`
    +component: `labkit_ECGPrint_app` | `1.5.0 -> 1.5.1`
    +scope: App Framework
    +scope: All tracked Apps
    +

    Context

    +

    The native text-fit adapter copied button text through a column-shaped char conversion. MATLAB therefore received one newline between every character and rendered hover help as a narrow vertical strip. The generated tooltip also only repeated the visible label, so it did not explain the scientific or workflow consequence of an action.

    +

    Decision and rationale

    +

    Make explanatory hover help part of the semantic layout contract. Every layout.button produces a nonempty Tooltip, and tracked Apps must replace the label-based framework fallback with App-owned explanatory text. File-list actions expose dedicated tooltip fields and retain framework-owned defaults for generic folder, remove, and clear mechanics. Scientific meaning stays in the owning App rather than in the native adapter.

    +

    All tracked Apps now describe what their actions consume, calculate, mutate, or export. The contract guardrail rejects App tooltips that only repeat the visible action label and also requires an explanatory input-selection tooltip.

    +

    Changes

    +
      +
    • Preserved char row vectors as one text line during native text fitting, eliminating the injected character-by-character newlines.
    • +
    • Added Tooltip support with a nonempty label fallback to layout.button and dedicated file-list tooltips for choose, folder, recursive folder, remove, and clear actions.
    • +
    • Added scientific and workflow-specific text for all 138 tracked App business buttons and all 26 App input selectors.
    • +
    • Updated each owning App manual and the framework authoring examples.
    • +
    +

    Compatibility and user impact

    +

    Existing tracked Apps and third-party layouts retain their actions and calculations. Hovering now always shows readable text; tracked Apps additionally carry domain-specific explanations.

    +

    Validation and evidence

    +
      +
    • App SDK unit coverage for required and compiled tooltip values.
    • +
    • Cross-App definition guardrail for non-label business and input tooltips.
    • +
    • Native adapter GUI coverage for exact tooltip text without injected newlines.
    • +
    • DIC Postprocess GUI coverage for scientific action and Ncorr input help.
    • +
    +

    Follow-up

    +

    Developer-led interactive checks should confirm tooltip timing, width, and line wrapping on supported MATLAB releases and desktop platforms.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.html b/site/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.html new file mode 100644 index 000000000..75791183e --- /dev/null +++ b/site/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.html @@ -0,0 +1,64 @@ + + + + + + + +Launcher adopts the App SDK diagnostic launch contract - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    history

    +

    Launcher adopts the App SDK diagnostic launch contract

    +
    schema: 2
    +id: LK-20260720-launcher-app-sdk-diagnostics
    +date: 2026-07-20
    +sequence: 139
    +type: feat
    +compatibility: compatible
    +component: `labkit_launcher` | `1.5.2 -> 1.6.0`
    +scope: LabKit Core
    +scope: App Framework
    +

    Context

    +

    The Launcher still passed the retired RequestAdapter startup seam after Apps had moved to labkit.app.Definition. Normal launch therefore failed before the selected App could create its window. Removing that argument restored startup, but temporarily also removed the user's diagnostic launch path.

    +

    Decision and rationale

    +

    Treat the App entrypoint and Definition.launch as the only Launcher-to-App runtime boundary. A normal launch uses the SDK defaults. A debug launch passes one typed labkit.app.diagnostic.Options value that requests verbose persisted events and the App-owned anonymous synthetic sample.

    +

    The Launcher continues to read catalog metadata from the single app-owned definition.m source without executing the SDK. This preserves its recovery role when an installed framework is incomplete or damaged.

    +

    Changes

    +
      +
    • Replaced retired runtime-adapter injection with direct App SDK entrypoint calls for normal launch and profiling.
    • +
    • Restored Open Debug using verbose typed diagnostics and each App's BuildDebugSample contract.
    • +
    • Added one isolated session folder per debug launch under artifacts/diagnostics/launcher/, with the event stream, manifests, and anonymous fixture artifacts.
    • +
    • Kept Launcher UI state in a Launcher-owned view record instead of the retired framework registry name.
    • +
    • Updated Definition metadata discovery for current name-value syntax while retaining the older literal form for repair-oriented catalog compatibility.
    • +
    • Excluded hidden class-folder implementation methods from public API and generated-documentation discovery.
    • +
    +

    Compatibility and user impact

    +

    Existing normal launch, profiling, packaging, update, and documentation actions keep their public behavior. Debug launch now creates explicit diagnostic artifacts and opens the App with its synthetic scenario; it no longer invokes the retired string-mode or request-adapter contracts.

    +

    Validation and evidence

    +
      +
    • Focused Launcher GUI, catalog, progress, profiling, and documentation contracts.
    • +
    • App SDK diagnostic integration through DIC Preprocess, including events.jsonl and sample-pack.json evidence.
    • +
    • Documentation source synchronization and public API discovery guardrails.
    • +
    +

    Follow-up

    +

    Developer-led interactive validation should confirm the visible debug launch, the selected App's synthetic state, and the usefulness of the generated diagnostic bundle on the deployment MATLAB version.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/libraries/dta/index.html b/site/libraries/dta/index.html index 578f2ad38..bceb3cd85 100644 --- a/site/libraries/dta/index.html +++ b/site/libraries/dta/index.html @@ -103,7 +103,7 @@
  • CIC, CSC, and EIS use DTA records in interactive workflows.
  • Contract functions explain how apps declare a compatible labkit.dta API version.
  • Project history lists parser, schema, and compatibility changes.
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/libraries/image/index.html b/site/libraries/image/index.html index 1e7174ab2..49af02732 100644 --- a/site/libraries/image/index.html +++ b/site/libraries/image/index.html @@ -67,7 +67,7 @@

    Provided Operations

    Use labkit.image.readFiles when an app needs generic source-image records. Apps may copy the returned path, name, and image fields into their own item structures. Specialized formats and result structures remain documented by the app that uses them.

    Reference Contract

    -

    The generated Image API pages linked from the public API index document exact syntax, inputs, outputs, implemented options and defaults, legal values, failure behavior, examples, and related functions. In particular, resizeToFit accepts only the documented "bilinear" and "nearest" methods and rejects any other method instead of silently selecting an interpolation policy.

    Change history

    +

    The generated Image API pages linked from the public API index document exact syntax, inputs, outputs, implemented options and defaults, legal values, failure behavior, examples, and related functions. In particular, resizeToFit accepts only the documented "bilinear" and "nearest" methods and rejects any other method instead of silently selecting an interpolation policy.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/libraries/thermal/index.html b/site/libraries/thermal/index.html index 4cbbf7c12..71a856252 100644 --- a/site/libraries/thermal/index.html +++ b/site/libraries/thermal/index.html @@ -76,7 +76,7 @@
  • Image functions cover ordinary image IO and image processing that does not use radiometric calibration.
  • FLIR Thermal app provides interactive file review, measurements, display controls, and exports.
  • Project history lists changes to thermal file support and calibration behavior.
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/batch_crop/cropGeometry/scalePlan.html b/site/reference/api/batch_crop/cropGeometry/scalePlan.html index 796c05a6a..040169d8c 100644 --- a/site/reference/api/batch_crop/cropGeometry/scalePlan.html +++ b/site/reference/api/batch_crop/cropGeometry/scalePlan.html @@ -32,13 +32,13 @@

    Syntax

    Outputs

    plan
    Scalar structure with these fields:

    Plan Fields

    mode
    "Physical".
    unit
    Effective scaleUnit.
    targetSource
    "Manual" for a positive targetPixelsPerUnit, otherwise "Auto". physicalWidth, physicalHeight - Requested dimensions in unit.
    sourcePixelsPerUnit
    Converted density for each input item.
    targetPixelsPerUnit
    Common output density.
    resampleFactor
    targetPixelsPerUnit ./ sourcePixelsPerUnit. nativeCropWidth, nativeCropHeight - Per-item native crop sizes in pixels, rounded to integers with a minimum of one. outputWidth, outputHeight - Common output size in pixels, rounded to integers with a minimum of one.
    warnings
    Per-item text. Upsampling beyond maxUpsamplePercent reports "upsample ...x"; resample factors below 0.5 report "downsample ...x".

    Errors

    labkit_BatchImageCrop_app:ScaleCalibrationMissing
    Any item lacks a valid calibration convertible to scaleUnit.
    labkit_BatchImageCrop_app:InvalidPhysicalSize
    physicalWidth or physicalHeight is missing, nonscalar, nonfinite, or nonpositive.
    labkit_BatchImageCrop_app:InvalidScaleUnit
    scaleUnit is unsupported.
    -

    Example

    cal = labkit.ui.interaction.scaleBarCalibration(20, 10, "um");
    +

    Example

    cal = labkit.app.interaction.scaleCalibration(20, 10, "um");
     items = struct("scaleCalibration", cal);
     opts = struct("physicalWidth", 5, "physicalHeight", 3, ...
     "scaleUnit", "um", "targetPixelsPerUnit", 4);
     plan = batch_crop.cropGeometry.scalePlan(items, opts);
     assert(plan.outputWidth == 20 && plan.outputHeight == 12)
    - +

    Source

    This page is generated from the MATLAB help text in apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/scalePlan.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/image_enhance/analysisRun/applyPipeline.html b/site/reference/api/image_enhance/analysisRun/applyPipeline.html index efec1ae76..94f2df995 100644 --- a/site/reference/api/image_enhance/analysisRun/applyPipeline.html +++ b/site/reference/api/image_enhance/analysisRun/applyPipeline.html @@ -29,7 +29,7 @@

    Syntax

    processed = image_enhance.analysisRun.applyPipeline(images, steps, contexts)

    Description

    Converts every input image to RGB double data, then applies each history step in order. The same step sequence is applied independently to every image. The input arrays and step records are not modified, so the function can be used outside the GUI for reproducible preview or export processing.

    Inputs

    images
    One numeric image or a cell array of numeric images. Grayscale and multichannel inputs are converted to M-by-N-by-3 RGB double images and limited to [0,1]. The returned cell order matches the input order.
    steps
    Structure array created by image_enhance.analysisRun.makeStep. Steps are reshaped to a column and executed in array order. An empty array performs normalization only.
    contexts
    Optional cell array with one entry per image. Context is used by "White ROI calibration" to find whiteRoi, either directly as context.whiteRoi or as context.item.whiteRoi. Other step kinds ignore it. Default: one empty context per image.
    -

    Step Fields

    kind
    One label returned by image_enhance.userInterface.toolKinds: "Brightness/contrast", "Local contrast", "Sharpen", "Hue/saturation", "White balance", "White ROI calibration", or "Subject-preserving enhance".
    amount
    Primary numeric control. It is brightness percentage, local- contrast strength, sharpening strength, hue rotation in degrees, white-balance strength, or protected-enhancement blend strength, depending on kind.
    secondary
    Secondary numeric control. It is contrast percentage, radius in pixels, saturation percentage, warm/cool percentage, or target background white level in percent, depending on kind.
    referenceIndex
    Preserved history metadata; applyPipeline does not use it.
    label
    Human-readable history text; applyPipeline does not branch on it.
    +

    Step Fields

    kind
    One label returned by image_enhance.imagePreview.presentationData.toolKinds: "Brightness/contrast", "Local contrast", "Sharpen", "Hue/saturation", "White balance", "White ROI calibration", or "Subject-preserving enhance".
    amount
    Primary numeric control. It is brightness percentage, local- contrast strength, sharpening strength, hue rotation in degrees, white-balance strength, or protected-enhancement blend strength, depending on kind.
    secondary
    Secondary numeric control. It is contrast percentage, radius in pixels, saturation percentage, warm/cool percentage, or target background white level in percent, depending on kind.
    referenceIndex
    Preserved history metadata; applyPipeline does not use it.
    label
    Human-readable history text; applyPipeline does not branch on it.

    Outputs

    processed
    Column cell array of M-by-N-by-3 double images in [0,1]. Each output preserves its corresponding source height and width.

    Errors

    labkit_ImageEnhance_app:UnknownEnhancementStep
    A step kind is not one of the supported labels.
    labkit_ImageEnhance_app:MissingWhiteRoi
    "White ROI calibration" is used without a valid [x y width height] ROI in that image's context. MATLAB indexing errors - A nonempty contexts array has fewer entries than images.

    Example

    imageData = repmat(linspace(0.1, 0.8, 20), 12, 1);
    diff --git a/site/reference/api/image_match/analysisRun/applyMatch.html b/site/reference/api/image_match/analysisRun/applyMatch.html
    index 7a7269e0d..431e7b2a1 100644
    --- a/site/reference/api/image_match/analysisRun/applyMatch.html
    +++ b/site/reference/api/image_match/analysisRun/applyMatch.html
    @@ -29,7 +29,7 @@ 

    Syntax

    inputImage, referenceImage, step)

    Description

    Converts the source and reference to RGB double images, transfers the selected tone and color statistics from the reference, and blends the matched result with the source. Matching never registers, crops, or resizes either image; only appearance statistics are transferred. The source geometry therefore determines the output geometry.

    Inputs

    inputImage
    Numeric or logical grayscale, RGB, or multichannel source image. It is converted to M-by-N-by-3 double data in [0,1].
    referenceImage
    Numeric or logical reference image. Its dimensions may differ from inputImage because matching uses image-wide statistics. Empty input returns the normalized source unchanged.
    step
    Scalar structure created by image_match.analysisRun.makeStep.
    -

    Step Fields

    matchMethod
    One label returned by image_match.userInterface.matchMethods. Punctuation and spaces are ignored when matching labels. An unknown or empty value uses "Balanced".
    amount
    Overall blend percentage. Zero returns the source; 100 returns the selected matched result. Values are limited to [0,100].
    secondary
    Tone-match percentage, limited to [0,100].
    colorStrength
    Color-match percentage, limited to [0,100].
    +

    Step Fields

    matchMethod
    One label returned by image_match.imagePreview.presentationData.matchMethods. Punctuation and spaces are ignored when matching labels. An unknown or empty value uses "Balanced".
    amount
    Overall blend percentage. Zero returns the source; 100 returns the selected matched result. Values are limited to [0,100].
    secondary
    Tone-match percentage, limited to [0,100].
    colorStrength
    Color-match percentage, limited to [0,100].

    Match Methods

    Balanced
    Applies robust white-balance gains, then Lab tone quantile and a/b covariance matching. White balance - Matches robust bright neutral RGB channel ratios only. Tone only - Quantile-matches Lab lightness; colorStrength is unused. Protected tone - Moves background lightness and color toward the reference while limiting changes in saturated subject regions. Lab style - Quantile-matches Lab lightness and covariance-matches the Lab a/b color channels.
    Histogram
    Quantile-matches all three Lab channels independently.

    Outputs

    outputImage
    M-by-N-by-3 double image in [0,1], with the same height and width as inputImage.

    Failure Behavior

    Empty referenceImage returns normalized inputImage unchanged. Missing step fields use the App's match defaults; unsupported image classes/channel shapes or malformed numeric step values propagate the originating LabKit image or MATLAB calculation error.

    diff --git a/site/reference/api/labkit/app/CallbackContext.html b/site/reference/api/labkit/app/CallbackContext.html new file mode 100644 index 000000000..d2b7fd2ba --- /dev/null +++ b/site/reference/api/labkit/app/CallbackContext.html @@ -0,0 +1,71 @@ + + + + + + + +labkit.app.CallbackContext - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.CallbackContext

    +

    Provide declared App-neutral runtime capabilities.

    +

    Syntax

    +
    labkit.app.CallbackContext.appendStatus(context, message) +context.appendStatus(message) +context.reportError(operation, exception) +context.diagnosticCheckpoint(id) +context.diagnosticCount(id, count) +context.alert(message, title) +result = context.chooseOption(prompt, choices, Name=Value) +result = context.chooseInputFile(filters, startPath) +result = context.chooseInputFolder(startPath) +result = context.chooseOutputFile(filters, startPath) +result = context.chooseOutputFolder(startPath) +result = context.saveProjectDocument(state, filepath) +state = context.restoreProjectDocument(filepath) +state = context.newProjectDocument() +context.saveRecoveryDocument(state, filepath) +paths = context.resolveSourcePaths(sources) +paths = context.resolveSourcePaths(sources, ids) +context.setResource(scope, id, value, cleanup) +value = context.getResource(scope, id) +context.removeResource(scope, id) +context.clearResourceScope(scope) +result = context.writeResultPackage(folder, result)
    +

    Description

    CallbackContext is the sealed callback capability boundary. Each specifically named method invokes one private runtime operation. Apps receive this value only as a callback argument and never construct it. It exposes no figure, registry, component, launch request, debug object, or nested service bag.

    +

    Inputs

    state
    Complete App-owned state value.
    message
    Scalar reader-facing text.
    operation
    Scalar diagnostic operation text.
    exception
    Scalar MException.
    id
    Stable semantic diagnostic or resource identifier.
    count
    Nonnegative integer diagnostic count.
    title
    Scalar reader-facing dialog title.
    prompt
    Scalar reader-facing choice prompt.
    choices
    Row string or cellstr array.
    Title
    Reader-facing choice-dialog title. Default: "Choose an option".
    DefaultChoice
    Choice selected by pressing Enter. Default: the first choice.
    CancelChoice
    Choice returned when the dialog is dismissed. Default: the first choice.
    filters
    Runtime-supported file-dialog filter value.
    startPath
    Scalar starting file or folder path.
    filepath
    Scalar project or recovery source or destination path.
    sources
    Runtime-owned portable source collection.
    ids
    Optional source identifiers to resolve.
    scope
    "event", "interaction", "document", or "application".
    value
    App-neutral resource value.
    cleanup
    Empty or fixed callback cleanup(value).
    folder
    Scalar result-package folder.
    result
    labkit.app.result.Package value for writeResultPackage.
    +

    Outputs

    state
    Complete restored App state prepared for the current runtime transaction.
    result
    labkit.app.dialog.Choice for dialogs, project saves, and result writing.
    paths
    Column string array of resolved source paths.
    value
    Stored resource value.
    +

    Errors

    labkit:app:contract:InvalidValue
    A public argument is malformed.
    labkit:app:runtime:InvariantFailure
    A private backend operation is unavailable.
    +

    Typical Call

    function state = runAnalysis(state,event,callbackContext)
    +arguments
    +state (1,1) struct
    +event
    +callbackContext (1,1) labkit.app.CallbackContext
    +end
    +callbackContext.appendStatus("Analysis started.");
    +end
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/CallbackContext.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/Definition.html b/site/reference/api/labkit/app/Definition.html new file mode 100644 index 000000000..2ebf36ee2 --- /dev/null +++ b/site/reference/api/labkit/app/Definition.html @@ -0,0 +1,59 @@ + + + + + + + +labkit.app.Definition - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.Definition

    +

    Compile and launch one immutable App SDK contract.

    +

    Syntax

    +
    app = labkit.app.Definition(Entrypoint=entrypoint, AppId=appId, ... +Title=title, ... +Family=family, AppVersion=version, Updated=date, ... +Requirements=requirements, Workbench=workbench, Name=Value) +fig = app.launch() +fig = app.launch(InitialProject=project) +fig = app.launch(Diagnostics=diagnosticOptions) +requirements = app.launch("requirements") +version = app.launch("version")
    +

    Description

    Definition validates product metadata, layout-owned callbacks and renderers, global IDs, callback roles, and references in one atomic constructor. The static target graph is cached once. validateViewSnapshot checks a complete view snapshot against that graph without rebuilding the layout.

    +

    Required Name-Value Arguments

    Entrypoint
    Public MATLAB launch function name as a scalar identifier.
    AppId
    Stable App identifier beginning with an ASCII letter and containing letters, digits, underscore, hyphen, or period.
    Title
    Nonempty reader-facing scalar text.
    Family
    Nonempty reader-facing scalar text.
    AppVersion
    Semantic version in X.Y.Z form.
    Updated
    Product date in YYYY-MM-DD form.
    Requirements
    Empty value or labkit.contract.requirements result.
    Workbench
    Root value returned by labkit.app.layout.workbench.
    +

    Optional Name-Value Arguments

    DisplayName
    Nonempty scalar text. Default: Title.
    ProjectSchema
    labkit.app.project.Schema or empty for a static App. Default: empty.
    CreateSession
    Fixed callback session = callback(project,context). Portable project sources remain opaque; use context.resolveSourcePaths while rebuilding transient session data. Default: empty.
    PresentWorkbench
    Fixed callback view = callback(state). Default: empty.
    OnStart
    Fixed callback state = callback(state,context), invoked after the first view commit. Default: empty.
    BuildDebugSample
    Fixed callback pack = callback(context). Default: empty.
    +

    Outputs

    app
    Immutable compiled labkit.app.Definition value.
    +

    Definition Methods

    launch()
    Build and show the native MATLAB App figure.
    launch(Diagnostics=options)
    Use one labkit.app.diagnostic.Options value for standard or verbose sanitized runtime recording.
    launch("requirements")
    Return declared facade requirements without creating a figure.
    launch("version")
    Return product version metadata without creating a figure.
    validateViewSnapshot(view)
    Validate target references, target capabilities and complete target coverage. Returns true or throws before any runtime UI mutation.
    +

    Errors

    labkit:app:contract:UnknownArgument
    A required argument is missing or an argument is unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    Metadata, requirements, workbench, callbacks, or renderers are malformed.
    labkit:app:contract:DuplicateId
    A layout ID is duplicated.
    labkit:app:contract:UnknownReference
    A view target is undeclared.
    labkit:app:contract:UnsupportedOperation
    A view operation is not legal for its target.
    labkit:app:contract:InvalidValue
    A launch request or output count is unsupported.
    +

    Typical Call

    workbench = labkit.app.layout.workbench({ ...
    +labkit.app.layout.button("run", "Run", @runAnalysis, ...
    +Tooltip="Compute the current analysis.")});
    +app = labkit.app.Definition( ...
    +Entrypoint="labkit_Example_app", AppId="example.app", ...
    +Title="Example", Family="Examples", AppVersion="1.0.0", ...
    +Updated="2026-07-19", Requirements=[], Workbench=workbench);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/Definition.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/diagnostic/Artifact.html b/site/reference/api/labkit/app/diagnostic/Artifact.html new file mode 100644 index 000000000..43beebeb4 --- /dev/null +++ b/site/reference/api/labkit/app/diagnostic/Artifact.html @@ -0,0 +1,47 @@ + + + + + + + +labkit.app.diagnostic.Artifact - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.diagnostic.Artifact

    +

    Describe one anonymous diagnostic-sample artifact.

    +

    Syntax

    +
    artifact = labkit.app.diagnostic.Artifact( ... +id,role,relativePath,Name=Value)
    +

    Description

    Identifies one synthetic input, expected export, or support file beneath a diagnostic sample root without exposing a user file path.

    +

    Inputs

    id
    Nonempty semantic identifier unique within a SamplePack.
    role
    Nonempty App-owned artifact purpose.
    relativePath
    Nonempty diagnostic-root-relative path without traversal.
    +

    Optional Name-Value Arguments

    Expectation
    "loads", "rejects", "exports", or "support". Default: "loads".
    +

    Outputs

    artifact
    Immutable diagnostic artifact value.
    +

    Errors

    labkit:app:contract:UnknownArgument
    An option is unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    Text, path, or Expectation is malformed.
    +

    Example

    artifact = labkit.app.diagnostic.Artifact( ...
    +"input","source","samples/input.csv");
    +assert(artifact.Expectation == "loads")
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+diagnostic/Artifact.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/diagnostic/Options.html b/site/reference/api/labkit/app/diagnostic/Options.html new file mode 100644 index 000000000..718b5abaf --- /dev/null +++ b/site/reference/api/labkit/app/diagnostic/Options.html @@ -0,0 +1,45 @@ + + + + + + + +labkit.app.diagnostic.Options - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.diagnostic.Options

    +

    Configure one App SDK diagnostic session.

    +

    Syntax

    +
    options = labkit.app.diagnostic.Options() +options = labkit.app.diagnostic.Options(Name=Value)
    +

    Description

    Options selects standard or verbose runtime recording and whether a Definition should build its declared anonymous synthetic sample. Omitting Diagnostics from Definition.launch is equivalent to the default standard options. This value never exposes a runtime, recorder, figure registry, or callback transport.

    +

    Optional Name-Value Arguments

    Level
    "standard" or "verbose". Standard keeps a bounded in-memory diagnostic history; verbose additionally writes structured artifacts when ArtifactFolder is nonempty. Default: "standard".
    ArtifactFolder
    Scalar diagnostic-session folder. Empty keeps recording in memory only. Default: "".
    Sample
    "none" or "synthetic". Synthetic requires the Definition's BuildDebugSample contract. Default: "none".
    +

    Outputs

    options
    Immutable diagnostic configuration.
    +

    Errors

    labkit:app:contract:UnknownArgument
    An option is unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    A supplied value is malformed or outside its documented legal set.
    +

    Example

    options = labkit.app.diagnostic.Options(Level="verbose");
    +assert(options.Level == "verbose")
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+diagnostic/Options.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/diagnostic/SampleContext.html b/site/reference/api/labkit/app/diagnostic/SampleContext.html new file mode 100644 index 000000000..b1df289f3 --- /dev/null +++ b/site/reference/api/labkit/app/diagnostic/SampleContext.html @@ -0,0 +1,48 @@ + + + + + + + +labkit.app.diagnostic.SampleContext - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.diagnostic.SampleContext

    +

    Provide bounded folders for anonymous debug samples.

    +

    Syntax

    +
    context = labkit.app.diagnostic.SampleContext(artifactFolder) +filepath = context.samplePath(relativePath) +filepath = context.outputPath(relativePath) +record = context.sourceRecord(id,role,filepath,required) +artifact = context.artifact(id,role,filepath,Name=Value)
    +

    Description

    SampleContext creates one diagnostic root with samples and outputs children. App-owned BuildDebugSample callbacks may write only anonymous synthetic files beneath these folders. The value does not expose a runtime, recorder, project store, or UI object.

    +

    Inputs

    artifactFolder
    Nonempty scalar diagnostic-session folder.
    relativePath
    Nonempty child path without an absolute root, empty segment, current segment, or parent traversal.
    id
    Stable portable-source identifier.
    role
    Stable portable-source role.
    filepath
    Path returned by samplePath.
    required
    Logical scalar source requirement.
    +

    Outputs

    context
    Immutable diagnostic sample context.
    filepath
    Absolute path bounded by SampleFolder or OutputFolder.
    record
    Portable source value from labkit.app.project.sourceRecord.
    artifact
    Typed diagnostic artifact whose relative path is derived from a filepath beneath ArtifactFolder.
    +

    Errors

    labkit:app:contract:InvalidValue
    A folder, relative path, or source argument is malformed. MATLAB filesystem errors propagate when the diagnostic folders cannot be created.
    +

    Typical Call

    context = labkit.app.diagnostic.SampleContext(tempname);
    +filepath = context.samplePath("input.csv");
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+diagnostic/SampleContext.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/diagnostic/SamplePack.html b/site/reference/api/labkit/app/diagnostic/SamplePack.html new file mode 100644 index 000000000..e49b9eb62 --- /dev/null +++ b/site/reference/api/labkit/app/diagnostic/SamplePack.html @@ -0,0 +1,49 @@ + + + + + + + +labkit.app.diagnostic.SamplePack - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.diagnostic.SamplePack

    +

    Describe one typed anonymous App reproduction scenario.

    +

    Syntax

    +
    pack = labkit.app.diagnostic.SamplePack( ... +Scenario=scenario,InitialProject=project,Artifacts=artifacts)
    +

    Description

    Couples one App-authored synthetic project with its anonymous artifact declarations so verbose diagnostics can reproduce a named scenario.

    +

    Required Name-Value Arguments

    Scenario
    Nonempty stable scenario identifier.
    InitialProject
    Scalar current App project struct.
    Artifacts
    Row cell array of labkit.app.diagnostic.Artifact values. Empty is legal for an App whose scenario needs no files.
    +

    Outputs

    pack
    Immutable diagnostic sample pack.
    +

    Errors

    labkit:app:contract:UnknownArgument
    An argument is missing, unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    Scenario, project, or Artifacts is malformed.
    labkit:app:contract:DuplicateId
    Artifact IDs or relative paths are duplicated.
    +

    Example

    artifact = labkit.app.diagnostic.Artifact( ...
    +"input","source","samples/input.csv");
    +pack = labkit.app.diagnostic.SamplePack( ...
    +Scenario="representative",InitialProject=struct(), ...
    +Artifacts={artifact});
    +assert(pack.Scenario == "representative")
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+diagnostic/SamplePack.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/dialog/Choice.html b/site/reference/api/labkit/app/dialog/Choice.html new file mode 100644 index 000000000..56762e926 --- /dev/null +++ b/site/reference/api/labkit/app/dialog/Choice.html @@ -0,0 +1,46 @@ + + + + + + + +labkit.app.dialog.Choice - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.dialog.Choice

    +

    Represent a typed dialog choice or cancellation.

    +

    Syntax

    +
    result = labkit.app.dialog.Choice(value) +result = labkit.app.dialog.Choice(value, Cancelled=cancelled)
    +

    Description

    Choice separates cancellation from the chosen value so an empty string, zero, false, or empty App value is not interpreted as cancel.

    +

    Inputs

    value
    App-facing value returned by the dialog.
    +

    Name-Value Arguments

    Cancelled
    Logical scalar. Default: false.
    +

    Outputs

    result
    Immutable labkit.app.dialog.Choice value.
    +

    Errors

    labkit:app:contract:UnknownArgument
    An option is unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    Cancelled is not logical scalar.
    +

    Example

    result = labkit.app.dialog.Choice("", Cancelled=false);
    +assert(~result.Cancelled)
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+dialog/Choice.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/event/IntervalScroll.html b/site/reference/api/labkit/app/event/IntervalScroll.html new file mode 100644 index 000000000..65d3e1266 --- /dev/null +++ b/site/reference/api/labkit/app/event/IntervalScroll.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.event.IntervalScroll - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.event.IntervalScroll

    +

    Describe one normalized interval scroll gesture.

    +

    Syntax

    +
    event = labkit.app.event.IntervalScroll(Anchor=anchor, Count=count)
    +

    Description

    IntervalScroll replaces native MATLAB scroll events and untyped structs with the horizontal data coordinate under the pointer and the signed vertical scroll count used by interval interactions.

    +

    Required Name-Value Arguments

    Anchor
    Finite scalar horizontal data coordinate.
    Count
    Finite nonzero scalar vertical scroll count.
    +

    Outputs

    event
    Immutable labkit.app.event.IntervalScroll value.
    +

    Errors

    labkit:app:contract:UnknownArgument
    An argument is missing, unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    Anchor or Count is malformed.
    +

    Example

    event = labkit.app.event.IntervalScroll(Anchor=0.25, Count=-1);
    +assert(event.Anchor == 0.25)
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+event/IntervalScroll.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/event/ListSelection.html b/site/reference/api/labkit/app/event/ListSelection.html new file mode 100644 index 000000000..9ef2c09f8 --- /dev/null +++ b/site/reference/api/labkit/app/event/ListSelection.html @@ -0,0 +1,45 @@ + + + + + + + +labkit.app.event.ListSelection - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.event.ListSelection

    +

    Describe selected file or list item identities.

    +

    Syntax

    +
    selection = labkit.app.event.ListSelection(Name=Value)
    +

    Description

    ListSelection carries stable item IDs, positive display indices, or both for file panels and list controls. When both are supplied they have equal lengths and matching order.

    +

    Optional Name-Value Arguments

    Ids
    Unique row string or cellstr array. Default: empty.
    Indices
    Unique positive integer numeric row. Default: empty.
    +

    Outputs

    selection
    Immutable labkit.app.event.ListSelection value.
    +

    Errors

    labkit:app:contract:UnknownArgument
    An option is unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    IDs or indices are malformed or have inconsistent lengths.
    +

    Example

    selection = labkit.app.event.ListSelection( ...
    +Ids=["sample-a", "sample-b"], Indices=[1 3]);
    +assert(isequal(selection.Indices, [1 3]))
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+event/ListSelection.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/event/TableCellEdit.html b/site/reference/api/labkit/app/event/TableCellEdit.html new file mode 100644 index 000000000..4815d914c --- /dev/null +++ b/site/reference/api/labkit/app/event/TableCellEdit.html @@ -0,0 +1,46 @@ + + + + + + + +labkit.app.event.TableCellEdit - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.event.TableCellEdit

    +

    Describe one validated table-cell edit signal.

    +

    Syntax

    +
    edit = labkit.app.event.TableCellEdit(Name=Value)
    +

    Description

    TableEdit replaces raw MATLAB CellEditData and event metadata with stable row/column identity, the previous and proposed values, and the complete proposed table data when the callback must interpret pasted or related rows atomically.

    +

    Required Name-Value Arguments

    RowIndex
    Positive integer row index.
    ColumnIndex
    Positive integer column index.
    PreviousValue
    App-owned value before the edit.
    NewValue
    App-owned proposed value.
    +

    Optional Name-Value Arguments

    RowId
    Empty or nonempty scalar text stable across sorting. Default: empty.
    ColumnId
    Empty or nonempty scalar text stable across display-name changes. Default: empty.
    Data
    Complete proposed table data after the edit. Default: empty.
    +

    Outputs

    edit
    Immutable labkit.app.event.TableCellEdit value.
    +

    Errors

    labkit:app:contract:UnknownArgument
    An option is missing, unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    An index or ID is malformed.
    +

    Example

    edit = labkit.app.event.TableCellEdit(RowIndex=2, ColumnIndex=3, ...
    +ColumnId="group", PreviousValue="A", NewValue="B");
    +assert(edit.ColumnId == "group")
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+event/TableCellEdit.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/event/TableCellSelection.html b/site/reference/api/labkit/app/event/TableCellSelection.html new file mode 100644 index 000000000..d23b14d9b --- /dev/null +++ b/site/reference/api/labkit/app/event/TableCellSelection.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.event.TableCellSelection - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.event.TableCellSelection

    +

    Describe selected cells in a semantic data table.

    +

    Syntax

    +
    selection = labkit.app.event.TableCellSelection(cellIndices)
    +

    Description

    TableCellSelection carries unique N-by-2 row/column index pairs. It replaces native MATLAB table-selection event shapes at the App callback boundary.

    +

    Inputs

    cellIndices
    Unique positive integer N-by-2 matrix. An empty selection is zeros(0,2).
    +

    Outputs

    selection
    Immutable TableCellSelection value.
    +

    Errors

    labkit:app:contract:InvalidValue
    cellIndices is not a unique positive integer N-by-2 matrix.
    +

    Example

    selection = labkit.app.event.TableCellSelection([1 2; 3 1]);
    +assert(isequal(selection.CellIndices, [1 2; 3 1]))
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+event/TableCellSelection.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/interaction/anchorPath.html b/site/reference/api/labkit/app/interaction/anchorPath.html new file mode 100644 index 000000000..8e8bc4400 --- /dev/null +++ b/site/reference/api/labkit/app/interaction/anchorPath.html @@ -0,0 +1,45 @@ + + + + + + + +labkit.app.interaction.anchorPath - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.interaction.anchorPath

    +

    Declare an editable open or closed path on one plot axis.

    +

    Syntax

    +
    spec = labkit.app.interaction.anchorPath(id, onChanged, Name=Value)
    +

    Description

    Creates the semantic declaration for a managed multi-anchor path editor; the runtime owns native graphics, viewport preservation, and dispatch.

    +

    Inputs

    id
    Unique MATLAB identifier for this interaction.
    onChanged
    Callback state = callback(state,points,context).
    +

    Options

    Axis
    Axis ID within the owning plotArea. Default: "main".
    Style
    Scalar struct of anchor editor visual options. Default: struct().
    Instruction
    Scalar user guidance text. Default: "".
    ViewportPolicy
    "preserve" or "fit". Default: "preserve".
    +

    Outputs

    spec
    Immutable interaction declaration accepted by layout.plotArea.
    +

    Errors

    Throws labkit:app:contract:* for invalid IDs, options, or callbacks.

    +

    Typical Call

    spec = labkit.app.interaction.anchorPath( ...
    +"curve", @changeCurve, Style=struct("closed", false));
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+interaction/anchorPath.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/interaction/interpolateAnchorPath.html b/site/reference/api/labkit/app/interaction/interpolateAnchorPath.html new file mode 100644 index 000000000..168ed19e2 --- /dev/null +++ b/site/reference/api/labkit/app/interaction/interpolateAnchorPath.html @@ -0,0 +1,49 @@ + + + + + + + +labkit.app.interaction.interpolateAnchorPath - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.interaction.interpolateAnchorPath

    +

    Build a visible path through image anchor points.

    +

    Syntax

    +
    curve = labkit.app.interaction.interpolateAnchorPath(points, imageSize) +curve = labkit.app.interaction.interpolateAnchorPath(points, imageSize, Name=Value) +[curve, owners] = labkit.app.interaction.interpolateAnchorPath(...)
    +

    Inputs

    points
    N-by-2 numeric matrix of [x y] anchor coordinates in image-pixel coordinates.
    imageSize
    Numeric vector whose first two elements are the positive finite image height and width.
    options
    Name-value argument container for Style and Closed. Supply these values by name; do not pass an options struct.
    +

    Name-Value Arguments

    Style
    "Curve" for a Catmull-Rom path or "Straight lines" for line segments joining the anchors. Default: "Curve".
    Closed
    Logical scalar. true joins the last anchor to the first and requires at least three anchors. false requires at least two. Default: false.
    +

    Outputs

    curve
    M-by-2 path samples. Coordinates are limited to pixel-edge bounds [0.5, width+0.5] and [0.5, height+0.5]. The result is empty until the selected open or closed path has enough anchors.
    owners
    (M-1)-by-1 anchor-segment indices used to associate each visible curve segment with its starting anchor. It is empty with curve.
    +

    Description

    anchorPath contains the deterministic geometry shared by managed anchor editors and app previews. Curved paths pass through the supplied anchors; two-point open curves reduce to a straight segment. The function creates no graphics and can be used independently of a LabKit app.

    +

    Errors

    MATLAB argument-validation or assertion errors are raised when points is not N-by-2, imageSize lacks positive finite height and width, Style is unsupported, or a named argument has an incompatible type or shape.

    +

    Example

    points = [10 30; 30 10; 50 30];
    +curve = labkit.app.interaction.interpolateAnchorPath( ...
    +points, [40 60], "Style", "Straight lines");
    +assert(isequal(curve, points))
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+interaction/interpolateAnchorPath.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/interaction/interval.html b/site/reference/api/labkit/app/interaction/interval.html new file mode 100644 index 000000000..1da11fb72 --- /dev/null +++ b/site/reference/api/labkit/app/interaction/interval.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.interaction.interval - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.interaction.interval

    +

    Declare an editable one-dimensional plot interval.

    +

    Syntax

    +
    spec = labkit.app.interaction.interval(id,onChanged,Name=Value)
    +

    Description

    Creates the semantic declaration for a managed interval editor and its optional typed scroll callback on one plot axis.

    +

    Inputs

    id
    Unique MATLAB identifier.
    onChanged
    Callback state = callback(state,range,context).
    +

    Options

    Axis
    Axis ID. Default: "main".
    Style
    Scalar visual-option struct. Default: struct().
    Instruction
    Scalar guidance text. Default: "".
    ViewportPolicy
    "preserve" or "fit". Default: "preserve".
    OnScrolled
    Optional callback state = callback(state,event,context), where event is labkit.app.event.IntervalScroll. Default: [].
    +

    Outputs

    spec
    Immutable interaction declaration.
    +

    Errors

    Throws labkit:app:contract:* for invalid values.

    +

    Typical Call

    spec = labkit.app.interaction.interval("window",@changeWindow);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+interaction/interval.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/interaction/pairedAnchors.html b/site/reference/api/labkit/app/interaction/pairedAnchors.html new file mode 100644 index 000000000..554e363a0 --- /dev/null +++ b/site/reference/api/labkit/app/interaction/pairedAnchors.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.interaction.pairedAnchors - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.interaction.pairedAnchors

    +

    Declare matching editable points across plot axes.

    +

    Syntax

    +
    spec = labkit.app.interaction.pairedAnchors(id,onChanged,Name=Value)
    +

    Description

    Creates the semantic declaration for corresponding editable points across two or more axes while the runtime owns their native editors.

    +

    Inputs

    id
    Unique MATLAB identifier.
    onChanged
    Callback state = callback(state,pointSets,context).
    +

    Options

    Axes
    Two or more axis IDs within the owning plotArea. Required.
    Style
    Scalar visual-option struct. Default: struct().
    Instruction
    Scalar guidance text. Default: "".
    ViewportPolicy
    "preserve" or "fit". Default: "preserve".
    +

    Outputs

    spec
    Immutable interaction declaration.
    +

    Errors

    Throws labkit:app:contract:* for invalid values.

    +

    Typical Call

    spec = labkit.app.interaction.pairedAnchors("matches",@changeMatches);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+interaction/pairedAnchors.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/interaction/pointSlots.html b/site/reference/api/labkit/app/interaction/pointSlots.html new file mode 100644 index 000000000..bc1a66942 --- /dev/null +++ b/site/reference/api/labkit/app/interaction/pointSlots.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.interaction.pointSlots - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.interaction.pointSlots

    +

    Declare a fixed set of editable labeled point positions.

    +

    Syntax

    +
    spec = labkit.app.interaction.pointSlots(id,onChanged,Name=Value)
    +

    Description

    Creates the semantic declaration for a fixed, labeled set of editable points whose structured value is committed by the runtime.

    +

    Inputs

    id
    Unique MATLAB identifier.
    onChanged
    Callback state = callback(state,value,context).
    +

    Options

    Axis
    Axis ID. Default: "main".
    Style
    Scalar visual-option struct. Default: struct().
    Instruction
    Scalar guidance text. Default: "".
    ViewportPolicy
    "preserve" or "fit". Default: "preserve".
    +

    Outputs

    spec
    Immutable interaction declaration.
    +

    Errors

    Throws labkit:app:contract:* for invalid values.

    +

    Typical Call

    spec = labkit.app.interaction.pointSlots("markers",@changeMarkers);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+interaction/pointSlots.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/interaction/rectangle.html b/site/reference/api/labkit/app/interaction/rectangle.html new file mode 100644 index 000000000..b14a5f33e --- /dev/null +++ b/site/reference/api/labkit/app/interaction/rectangle.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.interaction.rectangle - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.interaction.rectangle

    +

    Declare an editable rectangular plot region.

    +

    Syntax

    +
    spec = labkit.app.interaction.rectangle(id,onChanged,Name=Value)
    +

    Description

    Creates the semantic declaration for a persistent editable rectangle, including an optional background-point callback on the same axis.

    +

    Inputs

    id
    Unique MATLAB identifier.
    onChanged
    Callback state = callback(state,position,context).
    +

    Options

    Axis
    Axis ID. Default: "main".
    Style
    Scalar visual-option struct. Default: struct().
    Instruction
    Scalar guidance text. Default: "".
    ViewportPolicy
    "preserve" or "fit". Default: "preserve".
    OnBackgroundPressed
    Optional callback state = callback(state,point,context). Default: [].
    +

    Outputs

    spec
    Immutable interaction declaration.
    +

    Errors

    Throws labkit:app:contract:* for invalid values.

    +

    Typical Call

    spec = labkit.app.interaction.rectangle("crop",@moveCrop);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+interaction/rectangle.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/interaction/regionSelection.html b/site/reference/api/labkit/app/interaction/regionSelection.html new file mode 100644 index 000000000..d31c2a83b --- /dev/null +++ b/site/reference/api/labkit/app/interaction/regionSelection.html @@ -0,0 +1,46 @@ + + + + + + + +labkit.app.interaction.regionSelection - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.interaction.regionSelection

    +

    Declare a transient click-or-drag region gesture.

    +

    Syntax

    +
    spec = labkit.app.interaction.regionSelection(id,onSelected,Name=Value)
    +

    Description

    Creates the semantic declaration for a transient click-or-drag selection whose result is delivered without exposing native interaction objects.

    +

    Inputs

    id
    Unique MATLAB identifier.
    onSelected
    Callback state = callback(state,position,context).
    +

    Options

    Axis
    Axis ID. Default: "main".
    Style
    Scalar visual-option struct. Default: struct().
    Instruction
    Scalar guidance text. Default: "".
    ViewportPolicy
    "preserve" or "fit". Default: "preserve".
    OnBackgroundPressed
    Optional point callback. Default: [].
    +

    Outputs

    spec
    Immutable interaction declaration.
    +

    Errors

    Throws labkit:app:contract:* for invalid values.

    +

    Typical Call

    spec = labkit.app.interaction.regionSelection( ...
    +"temperatureRegion",@measureRegion, ...
    +OnBackgroundPressed=@measurePoint);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+interaction/regionSelection.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/interaction/scaleBarGeometry.html b/site/reference/api/labkit/app/interaction/scaleBarGeometry.html new file mode 100644 index 000000000..972638560 --- /dev/null +++ b/site/reference/api/labkit/app/interaction/scaleBarGeometry.html @@ -0,0 +1,48 @@ + + + + + + + +labkit.app.interaction.scaleBarGeometry - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.interaction.scaleBarGeometry

    +

    Compute serializable image scale-bar overlay geometry.

    +

    Syntax

    +
    geometry = labkit.app.interaction.scaleBarGeometry(imageSize, ... +calibration, barLength, position, colorName)
    +

    Inputs

    imageSize
    Numeric vector whose first two elements are positive finite image height and width in pixels.
    calibration
    Struct with a positive finite pixelsPerUnit field and a unit field, normally returned by scaleBarCalibration.
    barLength
    Positive finite physical length expressed in calibration.unit.
    position
    Text containing "top" or "bottom" and optionally "left", "center", or "right". Unrecognized vertical text uses bottom; unrecognized horizontal text uses center.
    colorName
    "White" selects [1 1 1]. Every other value selects black.
    +

    Outputs

    geometry
    Scalar data struct with the fields described below.
    +

    Geometry Fields

    line
    Two-by-two [x y] endpoints in image-pixel coordinates.
    label
    Display text containing barLength and calibration.unit.
    color
    RGB triplet selected by colorName.
    labelPosition
    One-by-two centered label position in image pixels.
    verticalAlignment
    "top" for a top bar or "bottom" for a bottom bar.
    pixelsPerUnit
    Calibration value copied into the geometry.
    unit
    Unit label copied into the geometry as a string scalar.
    barLength
    Requested physical length.
    position
    Supplied position text as a string scalar.
    colorName
    Supplied color text as a string scalar.
    +

    Description

    The helper places the line with an eight-percent image-edge margin and a five-pixel minimum inset. It errors when the requested bar cannot fit in the available horizontal span. Apps and renderers own drawing; this function creates no graphics and is suitable for saved project state.

    +

    Errors

    labkit:app:interaction:InvalidImageSize
    imageSize lacks positive finite height and width.
    labkit:app:interaction:InvalidScaleBar
    calibration or barLength is not a positive finite scalar.
    labkit:app:interaction:ScaleBarTooLong
    The requested bar does not fit inside the horizontal margins.
    +

    Example

    cal = labkit.app.interaction.scaleCalibration(80, 20, "mm");
    +geometry = labkit.app.interaction.scaleBarGeometry( ...
    +[600 800], cal, 10, "Bottom right", "White");
    +assert(abs(diff(geometry.line(:,1))) == 40)
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+interaction/scaleBarGeometry.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/interaction/scaleCalibration.html b/site/reference/api/labkit/app/interaction/scaleCalibration.html new file mode 100644 index 000000000..825eb2095 --- /dev/null +++ b/site/reference/api/labkit/app/interaction/scaleCalibration.html @@ -0,0 +1,49 @@ + + + + + + + +labkit.app.interaction.scaleCalibration - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.interaction.scaleCalibration

    +

    Convert a known image distance into pixels per unit.

    +

    Syntax

    +
    cal = labkit.app.interaction.scaleCalibration(referencePixels, ... +referenceLength, unitName) +cal = labkit.app.interaction.scaleCalibration(..., opts)
    +

    Inputs

    referencePixels
    Measured reference distance in image pixels. Empty, nonnumeric, nonfinite, or nonpositive values are treated as missing.
    referenceLength
    Physical reference distance expressed in unitName. Missing, nonfinite, or negative values become 0.
    unitName
    Unit label. With default options, legal values are "m", "cm", "mm", "um", and "nm". An unsupported value uses defaultUnit.
    opts
    Optional scalar struct described below. Default: struct().
    +

    Options

    units
    Allowed unit labels. Default: {'m','cm','mm','um','nm'}.
    defaultUnit
    Fallback unit. Default: the first entry in units.
    referenceLine
    N-by-2 numeric reference points stored with the result. When it contains exactly two rows and referencePixels is missing, their Euclidean distance supplies referencePixels. Default: zeros(0,2).
    +

    Outputs

    cal
    Scalar struct with the fields described below.
    +

    Calibration Fields

    referencePixels
    Positive measured pixel distance, or NaN when missing.
    referenceLength
    Nonnegative physical reference distance.
    unit
    Normalized unit label as a character vector.
    pixelsPerUnit
    referencePixels/referenceLength, or 0 when calibration is incomplete.
    isCalibrated
    true when pixelsPerUnit is positive.
    referenceLine
    Normalized N-by-2 numeric reference coordinates.
    +

    Description

    This function builds a serializable calibration value; it does not read an image or draw a scale bar. Repeating the call with identical inputs returns identical numeric fields. Divide a pixel distance by pixelsPerUnit to obtain a distance in cal.unit.

    +

    Failure Behavior

    Missing or invalid measurement values are normalized into an uncalibrated result instead of throwing. opts must be a scalar structure whose units can be converted to text and whose referenceLine can be converted to an N-by-2 numeric array; incompatible MATLAB values propagate conversion errors.

    +

    Example

    cal = labkit.app.interaction.scaleCalibration(80, 20, "mm");
    +physicalLength = 40 / cal.pixelsPerUnit;
    +assert(cal.isCalibrated && physicalLength == 10)
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+interaction/scaleCalibration.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/interaction/scaleReference.html b/site/reference/api/labkit/app/interaction/scaleReference.html new file mode 100644 index 000000000..324d020cd --- /dev/null +++ b/site/reference/api/labkit/app/interaction/scaleReference.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.interaction.scaleReference - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.interaction.scaleReference

    +

    Declare an editable two-point scale reference.

    +

    Syntax

    +
    spec = labkit.app.interaction.scaleReference(id,onChanged,Name=Value)
    +

    Description

    Creates the semantic declaration for an editable two-endpoint physical scale reference while the runtime owns its native editor lifecycle.

    +

    Inputs

    id
    Unique MATLAB identifier.
    onChanged
    Callback state = callback(state,endpoints,context).
    +

    Options

    Axis
    Axis ID. Default: "main".
    Style
    Scalar visual-option struct. Default: struct().
    Instruction
    Scalar guidance text. Default: "".
    ViewportPolicy
    "preserve" or "fit". Default: "preserve".
    +

    Outputs

    spec
    Immutable interaction declaration.
    +

    Errors

    Throws labkit:app:contract:* for invalid values.

    +

    Typical Call

    spec = labkit.app.interaction.scaleReference("scale",@changeScale);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+interaction/scaleReference.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/button.html b/site/reference/api/labkit/app/layout/button.html new file mode 100644 index 000000000..81d19d5b2 --- /dev/null +++ b/site/reference/api/labkit/app/layout/button.html @@ -0,0 +1,45 @@ + + + + + + + +labkit.app.layout.button - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.button

    +

    Add a push button with one explicit pressed callback.

    +

    Syntax

    +
    node = labkit.app.layout.button(id, label, onPressed, Name=Value)
    +

    Description

    Declares a semantic push button without creating a native component.

    +

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    label
    Nonempty text displayed on the button.
    onPressed
    Scalar function handle with the fixed callback state = onPressed(state,context).
    +

    Options

    BusyMessage
    Status text while the action runs. Default: "".
    Enabled
    Initial logical enabled state. Default: true.
    Tooltip
    Nonempty hover text explaining the action's scientific or workflow effect. Default: label.
    +

    Outputs

    node
    Immutable internal layout node accepted by layout containers.
    +

    Errors

    Throws labkit:app:contract:* for invalid IDs, options, or handlers.

    +

    Typical Call

    node = labkit.app.layout.button("run", "Run", @runAnalysis, ...
    +Tooltip="Compute the current analysis from the selected inputs.");
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/button.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/dataTable.html b/site/reference/api/labkit/app/layout/dataTable.html new file mode 100644 index 000000000..f006b977d --- /dev/null +++ b/site/reference/api/labkit/app/layout/dataTable.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.layout.dataTable - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.dataTable

    +

    Add a tabular data display with optional editing and selection.

    +

    Syntax

    +
    node = labkit.app.layout.dataTable(id, Name=Value)
    +

    Description

    Declares a semantic data table and typed cell callbacks.

    +

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    +

    Options

    Title
    Reader-facing panel title or blank. A single-table section supplies its title when this is blank. Default: blank.
    Columns
    Column-label text row. Default: strings(1,0).
    RowNames
    Row-label text row. Default: strings(1,0).
    ColumnEditable
    Logical scalar or row matching Columns. Default: false.
    OnCellEdited
    Scalar callback state = callback(state,edit,context), where edit is a labkit.app.event.TableCellEdit. Default: [].
    OnCellSelectionChanged
    Scalar callback state = callback(state,selection,context), where selection is a labkit.app.event.TableCellSelection. Default: [].
    +

    Outputs

    node
    Immutable internal layout node accepted by layout containers.
    +

    Errors

    Throws labkit:app:contract:* for invalid options or callback signatures.

    +

    Typical Call

    node = labkit.app.layout.dataTable("results", Columns=["Name" "Value"]);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/dataTable.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/field.html b/site/reference/api/labkit/app/layout/field.html new file mode 100644 index 000000000..46af39db6 --- /dev/null +++ b/site/reference/api/labkit/app/layout/field.html @@ -0,0 +1,45 @@ + + + + + + + +labkit.app.layout.field - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.field

    +

    Add a text, numeric, choice, or logical input field.

    +

    Syntax

    +
    node = labkit.app.layout.field(id, Name=Value)
    +

    Description

    Declares one bound or event-driven scalar input field.

    +

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    +

    Options

    Label
    Display text. Default: id.
    Kind
    "text", "numeric", "choice", "logical", or "readonly". Readonly fields display one labeled result value. Default: "text".
    Value
    Initial value. Default: [].
    Choices
    Text row for choice fields. Default: strings(1,0).
    Limits
    Increasing finite numeric 1-by-2 row. Default: [].
    Step
    Positive numeric scalar. Default: [].
    Bind
    Project or session field path. Default: "".
    ValueDisplayFormat
    MATLAB numeric display format. Default: "".
    ShowTicks
    Logical slider-tick preference. Default: false.
    Enabled
    Initial logical enabled state. Default: true.
    OnValueChanged
    Scalar callback state = callback(state,value,context). Default: [].
    +

    Outputs

    node
    Immutable internal layout node accepted by layout containers.
    +

    Errors

    Throws labkit:app:contract:* for invalid IDs, options, or handlers.

    +

    Typical Call

    node = labkit.app.layout.field("gain", Kind="numeric", ...
    +Bind="project.parameters.gain");
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/field.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/fileList.html b/site/reference/api/labkit/app/layout/fileList.html new file mode 100644 index 000000000..02d7011fc --- /dev/null +++ b/site/reference/api/labkit/app/layout/fileList.html @@ -0,0 +1,46 @@ + + + + + + + +labkit.app.layout.fileList - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.fileList

    +

    Add file selection and portable-source controls.

    +

    Syntax

    +
    node = labkit.app.layout.fileList(id, Name=Value)
    +

    Description

    Declares framework-owned file choosing, removal, selection, and portable source binding.

    +

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    +

    Options

    Label
    Reader-facing collection label. Default: id.
    Mode
    "files" or "folder". Default: "files".
    Filters
    File-dialog filter text row. Default: strings(1,0).
    SelectionMode
    "single" or "multiple". Default: "multiple".
    MaxFiles
    Positive scalar or Inf. Default: Inf.
    FolderWarningThreshold
    Positive scalar or Inf. Default: 500.
    ShowStatus
    Logical status visibility. Default: true.
    StartPath
    Initial folder text. Default: "".
    ChooseLabel
    File button text. Default: "Choose".
    FolderLabel
    Folder button text. Default: "Choose Folder".
    RecursiveFolderLabel
    Recursive button text. Default: "Choose Folder Recursively".
    RemoveLabel
    Remove button text. Default: "Remove".
    ClearLabel
    Clear button text. Default: "Clear".
    ChooseTooltip
    File button hover text. Default: ChooseLabel.
    FolderTooltip
    Folder button hover text. Default: FolderLabel.
    RecursiveFolderTooltip
    Recursive-folder button hover text. Default: RecursiveFolderLabel.
    RemoveTooltip
    Remove button hover text. Default: RemoveLabel.
    ClearTooltip
    Clear button hover text. Default: ClearLabel.
    EmptyText
    Empty-list text. Default: "No files selected".
    AllowDuplicatePaths
    Preserve separate portable source records that resolve to the same path. Use this when each list row is a distinct workflow task. Default: false.
    Bind
    Project source-record field path. Default: "".
    SelectionBind
    ListSelection field path. Default: "".
    OnSelectionChanged
    Optional callback applicationState = callback(applicationState,selection,callbackContext) for business effects such as lazily decoding the selected source. selection is labkit.app.event.ListSelection. Ordinary selection state needs only SelectionBind. Default: empty.
    SourceRole
    Portable source role. Default: id.
    SourceIdPrefix
    Portable source ID prefix. Default: id.
    Required
    Logical relinking requirement. Default: true.
    +

    Outputs

    node
    Immutable internal layout node accepted by layout containers.
    +

    Errors

    Throws labkit:app:contract:* for invalid options, paths, or callbacks.

    +

    Typical Call

    node = labkit.app.layout.fileList("files", ...
    +Bind="project.inputs.sources", ...
    +ChooseTooltip="Choose calibrated source images for this analysis.");
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/fileList.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/group.html b/site/reference/api/labkit/app/layout/group.html new file mode 100644 index 000000000..c8fa08df4 --- /dev/null +++ b/site/reference/api/labkit/app/layout/group.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.layout.group - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.group

    +

    Arrange related child elements without a titled boundary.

    +

    Syntax

    +
    node = labkit.app.layout.group(id, children, Name=Value)
    +

    Description

    Groups compatible control nodes into one semantic arrangement. A titled group draws a nested reader-facing boundary inside its owning section.

    +

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    children
    Row cell array of layout nodes.
    +

    Options

    Layout
    "auto", "vertical", or "horizontal". Default: "auto".
    Title
    Reader-facing nested-group title or blank. Default: blank.
    +

    Outputs

    node
    Immutable internal layout node accepted by containers.
    +

    Errors

    Throws labkit:app:contract:* for invalid IDs, children, or options.

    +

    Typical Call

    node = labkit.app.layout.group("inputs", {gainField});
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/group.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/logPanel.html b/site/reference/api/labkit/app/layout/logPanel.html new file mode 100644 index 000000000..484b8d3f5 --- /dev/null +++ b/site/reference/api/labkit/app/layout/logPanel.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.layout.logPanel - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.logPanel

    +

    Add a text display for App log messages.

    +

    Syntax

    +
    node = labkit.app.layout.logPanel(id, Name=Value)
    +

    Description

    Declares a runtime-populated multiline App log display.

    +

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    +

    Options

    Title
    Visible panel title. Default: "Log".
    +

    Outputs

    node
    Immutable internal layout node accepted by containers.
    +

    Errors

    Throws labkit:app:contract:InvalidValue for an invalid ID.

    +

    Typical Call

    node = labkit.app.layout.logPanel("appLog");
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/logPanel.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/plotArea.html b/site/reference/api/labkit/app/layout/plotArea.html new file mode 100644 index 000000000..a1732946f --- /dev/null +++ b/site/reference/api/labkit/app/layout/plotArea.html @@ -0,0 +1,45 @@ + + + + + + + +labkit.app.layout.plotArea - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.plotArea

    +

    Add one or more axes rendered by an App-owned renderer.

    +

    Syntax

    +
    node = labkit.app.layout.plotArea(id, renderer, Name=Value)
    +

    Description

    Declares axes and their one App-owned renderer. The renderer is declared here once; a view snapshot supplies only its current model.

    +

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    renderer
    Scalar function handle renderer(axesById,model) with no output. axesById is always a scalar struct with one graphics axes field per declared AxisIds value, including single-axis plots.
    +

    Options

    Title
    Reader-facing panel title or blank. Default: blank.
    Layout
    Axes arrangement: "single", "pair", or "stack". Default: "single" for one axis and "stack" for multiple axes.
    AxisIds
    Unique MATLAB identifier row. Default: "main".
    AxisTitles
    One title per axis. Default: axis IDs.
    XLabels
    One x-axis label per axis. Default: blank.
    YLabels
    One y-axis label per axis. Default: blank.
    ColumnWidths
    One positive pixel, "fit", or "1x" value per axis for pair layout. Default: equal flexible widths.
    RowHeights
    One positive pixel, "fit", or "1x" value per axis for stack layout. Default: equal flexible heights.
    ScrollZoomAxes
    One "xy", "x", or "y" value per axis. Default: "xy".
    ViewModes
    App-owned mode labels. Default: strings(1,0).
    OnValueChanged
    Scalar callback state = callback(state,value,context). Default: [].
    Interactions
    Row cell array returned by named labkit.app.interaction.* declarations. Default: {}.
    +

    Outputs

    node
    Immutable internal layout node accepted by workspace.
    +

    Errors

    Throws labkit:app:contract:* for invalid IDs, options, or handlers.

    +

    Typical Call

    node = labkit.app.layout.plotArea("preview", @drawTrace, ...
    +AxisIds="trace");
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/plotArea.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/rangeField.html b/site/reference/api/labkit/app/layout/rangeField.html new file mode 100644 index 000000000..8d9087f7e --- /dev/null +++ b/site/reference/api/labkit/app/layout/rangeField.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.layout.rangeField - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.rangeField

    +

    Add an input for a numeric lower and upper bound.

    +

    Syntax

    +
    node = labkit.app.layout.rangeField(id, Name=Value)
    +

    Description

    Declares one two-value numeric range input.

    +

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    +

    Options

    Label
    Display text. Default: id.
    Value
    Finite numeric 1-by-2 row. Default: [].
    Limits
    Increasing finite numeric 1-by-2 row. Default: [].
    Enabled
    Initial logical enabled state. Default: true.
    Bind
    Project or session field path. Default: "".
    OnValueChanged
    Scalar callback state = callback(state,value,context). Default: [].
    +

    Outputs

    node
    Immutable internal layout node accepted by layout containers.
    +

    Errors

    Throws labkit:app:contract:* for invalid IDs, options, or callbacks.

    +

    Typical Call

    node = labkit.app.layout.rangeField("window", Limits=[0 10]);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/rangeField.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/section.html b/site/reference/api/labkit/app/layout/section.html new file mode 100644 index 000000000..5b9b5b6b2 --- /dev/null +++ b/site/reference/api/labkit/app/layout/section.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.layout.section - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.section

    +

    Arrange related child elements under a visible title.

    +

    Syntax

    +
    node = labkit.app.layout.section(id, title, children, Name=Value)
    +

    Description

    Groups compatible controls under a reader-facing title.

    +

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    title
    Nonempty section title.
    children
    Row cell array of layout nodes.
    +

    Options

    Collapsible
    Logical collapse support. Default: false.
    Expanded
    Initial logical expansion state. Default: true.
    +

    Outputs

    node
    Immutable internal layout node accepted by containers.
    +

    Errors

    Throws labkit:app:contract:* for invalid IDs, children, or options.

    +

    Typical Call

    node = labkit.app.layout.section("inputs", "Inputs", {gainField});
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/section.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/slider.html b/site/reference/api/labkit/app/layout/slider.html new file mode 100644 index 000000000..a3061167e --- /dev/null +++ b/site/reference/api/labkit/app/layout/slider.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.layout.slider - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.slider

    +

    Add a bounded numeric slider.

    +

    Syntax

    +
    node = labkit.app.layout.slider(id, Name=Value)
    +

    Description

    Declares one bounded scalar slider.

    +

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    +

    Options

    Label
    Display text. Default: id.
    Value
    Finite numeric scalar. Default: lower Limits value.
    Limits
    Increasing finite numeric 1-by-2 row. Default: [0 1].
    Step
    Positive numeric scalar. Default: [].
    ValueDisplayFormat
    Optional spinner numeric format such as "%.6g". Default: "".
    ShowTicks
    Logical tick visibility. Default: false.
    Bind
    Project or session field path. Default: "".
    Enabled
    Initial logical enabled state. Default: true.
    OnValueChanged
    Scalar callback state = callback(state,value,context). Default: [].
    +

    Outputs

    node
    Immutable internal layout node accepted by layout containers.
    +

    Errors

    Throws labkit:app:contract:* for invalid IDs, options, or callbacks.

    +

    Typical Call

    node = labkit.app.layout.slider("frame", Limits=[1 100], Step=1);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/slider.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/statusPanel.html b/site/reference/api/labkit/app/layout/statusPanel.html new file mode 100644 index 000000000..f56cd8b48 --- /dev/null +++ b/site/reference/api/labkit/app/layout/statusPanel.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.layout.statusPanel - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.statusPanel

    +

    Add a text display for current App status.

    +

    Syntax

    +
    node = labkit.app.layout.statusPanel(id, Name=Value)
    +

    Description

    Declares a current-status or static instruction display.

    +

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    +

    Options

    Title
    Visible panel title. Default: "Status".
    Text
    Static text lines. When omitted, the runtime's latest status is displayed. Default: strings(1,0).
    +

    Outputs

    node
    Immutable internal layout node accepted by containers.
    +

    Errors

    Throws labkit:app:contract:InvalidValue for an invalid ID.

    +

    Typical Call

    node = labkit.app.layout.statusPanel("status");
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/statusPanel.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/tab.html b/site/reference/api/labkit/app/layout/tab.html new file mode 100644 index 000000000..982772318 --- /dev/null +++ b/site/reference/api/labkit/app/layout/tab.html @@ -0,0 +1,43 @@ + + + + + + + +labkit.app.layout.tab - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.tab

    +

    Add a named tab containing related child elements.

    +

    Syntax

    +
    node = labkit.app.layout.tab(id, title, children)
    +

    Description

    Declares one named control-side tab.

    +

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    title
    Nonempty tab title.
    children
    Row cell array of compatible layout nodes.
    +

    Outputs

    node
    Immutable internal layout node accepted by workbench.
    +

    Errors

    Throws labkit:app:contract:* for invalid IDs, titles, or children.

    +

    Typical Call

    node = labkit.app.layout.tab("settings", "Settings", {gainField});
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/tab.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/workbench.html b/site/reference/api/labkit/app/layout/workbench.html new file mode 100644 index 000000000..d56402e60 --- /dev/null +++ b/site/reference/api/labkit/app/layout/workbench.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.layout.workbench - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.workbench

    +

    Assemble controls and a central workspace into the root layout.

    +

    Syntax

    +
    node = labkit.app.layout.workbench(children, Name=Value)
    +

    Description

    Produces the root semantic layout required by labkit.app.Definition.

    +

    Inputs

    children
    Row cell array of controls, groups, sections, or tabs.
    +

    Options

    Workspace
    Value returned by layout.workspace. Default: [].
    Usage
    Static workflow instruction lines appended consistently to the first control tab. Default: strings(1,0).
    UsageTitle
    Title for generated workflow instructions. Default: "Usage".
    +

    Outputs

    node
    Immutable root layout node.
    +

    Errors

    Throws labkit:app:contract:* for invalid children or workspace values.

    +

    Typical Call

    node = labkit.app.layout.workbench({runButton});
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/workbench.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/workspace.html b/site/reference/api/labkit/app/layout/workspace.html new file mode 100644 index 000000000..0db31e7a7 --- /dev/null +++ b/site/reference/api/labkit/app/layout/workspace.html @@ -0,0 +1,45 @@ + + + + + + + +labkit.app.layout.workspace - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.layout.workspace

    +

    Define the App's central working area and optional pages.

    +

    Syntax

    +
    node = labkit.app.layout.workspace() +node = labkit.app.layout.workspace(content, Name=Value)
    +

    Description

    Declares a single-content workspace or a workspace extended with node.page(id,title,content) and node.initialPage(id). A named page accepts one workspace node or a nonempty row cell array of nodes arranged vertically; growable tables and plots share the available page height.

    +

    Inputs

    content
    Optional plotArea, dataTable, group, or section node. For node.page, content may also be a nonempty row cell array of workspace nodes.
    +

    Options

    Title
    Reader-facing workspace title. Default: "Workspace".
    OnPageChanged
    Callback state = callback(state,pageId,context). Default: [].
    +

    Outputs

    node
    Immutable workspace node accepted by layout.workbench.
    +

    Errors

    Throws labkit:app:contract:* for invalid content, pages, or callbacks.

    +

    Typical Call

    node = labkit.app.layout.workspace(plotArea);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+layout/workspace.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/plot/clampPointToAxes.html b/site/reference/api/labkit/app/plot/clampPointToAxes.html new file mode 100644 index 000000000..cc529cd10 --- /dev/null +++ b/site/reference/api/labkit/app/plot/clampPointToAxes.html @@ -0,0 +1,49 @@ + + + + + + + +labkit.app.plot.clampPointToAxes - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.plot.clampPointToAxes

    +

    Keep data coordinates inside the visible axes box.

    +

    Syntax

    +
    xyOut = labkit.app.plot.clampPointToAxes(ax, xy) +xyOut = labkit.app.plot.clampPointToAxes(ax, xy, Name=Value)
    +

    Inputs

    ax
    Valid scalar MATLAB axes or uiaxes handle. Its XLim, YLim, XScale, YScale, XDir, and YDir properties define the visible box.
    xy
    N-by-2 numeric matrix of [x y] data coordinates.
    +

    Name-Value Arguments

    Padding
    Minimum distance from each axes edge, expressed as a fraction of the visible width or height. Values are limited to [0, 0.49]. Default: 0.04.
    +

    Outputs

    xy
    N-by-2 data coordinates, shown as xyOut in the usage syntax. Each point is moved only as far as needed to satisfy Padding.
    +

    Description

    clampData is useful for labels and annotations that must remain readable near an axes boundary. Conversion through normalized axes coordinates keeps the result visually consistent on logarithmic or reversed axes.

    +

    Errors

    labkit:app:plot:InvalidAxes
    ax is not a valid scalar axes handle.
    labkit:app:plot:InvalidPointPairs
    xy is not an N-by-2 numeric array. labkit:app:plot:InvalidOptions or labkit:app:plot:InvalidOption - Name-value arguments are malformed or unsupported.
    +

    Example

    fig = figure("Visible", "off");
    +cleanup = onCleanup(@() close(fig));
    +ax = axes(fig, "XLim", [0 10], "YLim", [0 20]);
    +xy = labkit.app.plot.clampPointToAxes(ax, [-2 25], "Padding", 0.1);
    +assert(isequal(xy, [1 18]))
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+plot/clampPointToAxes.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/plot/clearAxes.html b/site/reference/api/labkit/app/plot/clearAxes.html new file mode 100644 index 000000000..8f813180b --- /dev/null +++ b/site/reference/api/labkit/app/plot/clearAxes.html @@ -0,0 +1,50 @@ + + + + + + + +labkit.app.plot.clearAxes - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.plot.clearAxes

    +

    Prepare an axes for an app-owned redraw.

    +

    Syntax

    +
    labkit.app.plot.clearAxes(ax) +labkit.app.plot.clearAxes(ax, Name=Value)
    +

    Inputs

    ax
    Valid scalar MATLAB axes or uiaxes handle to clear.
    +

    Name-Value Arguments

    ResetScale
    Logical value. true restores linear X/Y scales and automatic X/Y tick modes. false preserves those four properties. Default: false.
    ClearLegend
    Logical value. true turns the legend off. Default: true.
    +

    Outputs

    None.

    +

    Description

    clear deletes plotted children, clears LabKit's cached image home view, releases hold, and returns XLim, YLim, ZLim, and CLim to automatic mode. Labels and other axes decorations follow MATLAB cla behavior. Call it once at the start of a complete redraw, not when adding an overlay that should preserve the current zoom.

    +

    Errors

    labkit:app:plot:InvalidAxes
    ax is not a valid scalar axes handle. labkit:app:plot:InvalidOptions or labkit:app:plot:InvalidOption - Name-value arguments are malformed or unsupported. MATLAB graphics errors raised while clearing a valid axes propagate to the caller.
    +

    Example

    fig = figure("Visible", "off");
    +cleanup = onCleanup(@() close(fig));
    +ax = axes(fig);
    +plot(ax, 1:3, [2 1 3]);
    +labkit.app.plot.clearAxes(ax, "ResetScale", true);
    +assert(isempty(ax.Children))
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+plot/clearAxes.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/plot/enablePopout.html b/site/reference/api/labkit/app/plot/enablePopout.html new file mode 100644 index 000000000..29826ed61 --- /dev/null +++ b/site/reference/api/labkit/app/plot/enablePopout.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.plot.enablePopout - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.plot.enablePopout

    +

    Add a standalone-figure action to an axes context menu.

    +

    Syntax

    +
    labkit.app.plot.enablePopout(ax)
    +

    Inputs

    ax
    MATLAB axes or uiaxes handle that receives an "Open axes in new figure" context-menu item. Empty or invalid handles are ignored.
    +

    Outputs

    None.

    +

    Description

    The menu copies the current axes content, limits, labels, colors, and visible legend entries into a normal MATLAB figure with publication and export tools. Existing axes context-menu items are preserved. Children without their own context menu inherit the axes menu, including children added after this call. Repeated calls do not add duplicate menu items.

    +

    Failure Behavior

    Empty or invalid handles are ignored. Errors raised while MATLAB creates the context menu, copies graphics, or opens the standalone figure are not caught and propagate from the originating graphics operation.

    +

    Typical Call

    plot(ax, time, signal);
    +labkit.app.plot.enablePopout(ax);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+plot/enablePopout.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/plot/fitAxesToGraphics.html b/site/reference/api/labkit/app/plot/fitAxesToGraphics.html new file mode 100644 index 000000000..21376488e --- /dev/null +++ b/site/reference/api/labkit/app/plot/fitAxesToGraphics.html @@ -0,0 +1,52 @@ + + + + + + + +labkit.app.plot.fitAxesToGraphics - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.plot.fitAxesToGraphics

    +

    Fit axes limits to finite plotted X/Y data.

    +

    Syntax

    +
    limits = labkit.app.plot.fitAxesToGraphics(ax) +limits = labkit.app.plot.fitAxesToGraphics(ax, graphicsHandles) +limits = labkit.app.plot.fitAxesToGraphics(..., Name=Value)
    +

    Inputs

    ax
    Valid scalar MATLAB axes or uiaxes handle whose limits are changed.
    graphicsHandles
    Optional graphics handle array. Objects with numeric XData and YData contribute to the fitted range. When omitted, all current axes children are examined.
    +

    Name-Value Arguments

    Padding
    Nonnegative fractional padding added on each side of the data range. Default: 0.02. For logarithmic axes, padding is computed in base-10 logarithmic space.
    +

    Outputs

    limits
    Scalar struct with x and y fields. Each field contains the applied two-element limit, or [] when that dimension had no usable data and was returned to automatic limit mode.
    +

    Description

    fit ignores nonfinite XData and YData. Nonpositive values do not contribute to a logarithmic dimension. Supplying graphicsHandles lets an app exclude annotations such as reference lines from the fitted range.

    +

    Errors

    labkit:app:plot:InvalidAxes
    ax is not a valid scalar axes handle. labkit:app:plot:InvalidOptions or labkit:app:plot:InvalidOption - Name-value arguments are malformed or unsupported. Invalid supplied graphics handles are ignored when they do not expose numeric XData/YData.
    +

    Example

    fig = figure("Visible", "off");
    +cleanup = onCleanup(@() close(fig));
    +ax = axes(fig);
    +h = plot(ax, [1 2 3], [10 20 15]);
    +xline(ax, 100);
    +limits = labkit.app.plot.fitAxesToGraphics(ax, h, "Padding", 0);
    +assert(isequal(limits.x, [1 3]))
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+plot/fitAxesToGraphics.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/plot/fitCanvasToSource.html b/site/reference/api/labkit/app/plot/fitCanvasToSource.html new file mode 100644 index 000000000..ad0adf24a --- /dev/null +++ b/site/reference/api/labkit/app/plot/fitCanvasToSource.html @@ -0,0 +1,45 @@ + + + + + + + +labkit.app.plot.fitCanvasToSource - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.plot.fitCanvasToSource

    +

    Fit a preview axes into a fixed-aspect pixel frame.

    +

    Syntax

    +
    [applied, frame] = labkit.app.plot.fitCanvasToSource(ax, width, height) +[applied, frame] = labkit.app.plot.fitCanvasToSource(..., Name=Value)
    +

    Inputs

    ax
    UI axes whose parent is a uigridlayout created for a LabKit preview.
    width
    Positive finite source-canvas width in pixels.
    height
    Positive finite source-canvas height in pixels.
    +

    Name-Value Arguments

    margin
    Preferred empty margin around the frame in pixels. Default: 24.
    maxScale
    Largest allowed ratio between displayed and source dimensions. Default: 1, so the helper does not enlarge the source canvas.
    +

    Outputs

    applied
    true when the grid and axes positions were updated; false when the axes, parent grid, dimensions, or available space were unsuitable.
    frame
    Scalar struct with width, height, ratio, position, scale, and pixelPosition fields. It is an empty struct when applied is false.
    +

    Description

    fitCanvas centers the axes in the middle row and column of a three-by-three flexible grid and preserves the requested aspect ratio. The helper keeps one managed parent-position listener with the axes and reapplies the most recent canvas request after host resizing. It returns false instead of throwing when the host has not been laid out yet or is smaller than the minimum usable preview area.

    +

    Failure Behavior

    Invalid axes, host grids, source dimensions, or insufficient available space return applied=false and frame=struct(). Malformed or unsupported name-value arguments throw labkit:app:plot:InvalidOptions or labkit:app:plot:InvalidOption.

    +

    Typical Call

    [ok, frame] = labkit.app.plot.fitCanvasToSource(ax, 720, 540);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+plot/fitCanvasToSource.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/plot/offsetPointByAxesFraction.html b/site/reference/api/labkit/app/plot/offsetPointByAxesFraction.html new file mode 100644 index 000000000..38c405d99 --- /dev/null +++ b/site/reference/api/labkit/app/plot/offsetPointByAxesFraction.html @@ -0,0 +1,47 @@ + + + + + + + +labkit.app.plot.offsetPointByAxesFraction - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.plot.offsetPointByAxesFraction

    +

    Move data coordinates by normalized axes fractions.

    +

    Syntax

    +
    xyOut = labkit.app.plot.offsetPointByAxesFraction(ax, xy, offsetFraction)
    +

    Inputs

    ax
    Valid scalar MATLAB axes or uiaxes handle. Its current limits, scales, and directions define the coordinate conversion.
    xy
    N-by-2 numeric matrix of [x y] data coordinates.
    offsetFraction
    1-by-2 or N-by-2 axes-fraction offsets. One row is reused for every point; otherwise the row count must match xy. For example, [0.03 -0.04] moves a label slightly right and down in visual axes space, including on log or reversed axes.
    +

    Outputs

    xy
    N-by-2 offset coordinates in data units, shown as xyOut in the usage syntax. Values are not clamped to the visible axes box.
    +

    Description

    offsetData expresses annotation spacing in visual axes fractions instead of data units. This keeps the apparent offset stable as data ranges change and correctly handles logarithmic and reversed axes.

    +

    Errors

    labkit:app:plot:InvalidAxes
    ax is not a valid scalar axes handle.
    labkit:app:plot:InvalidPointPairs
    xy is not an N-by-2 numeric array.
    labkit:app:plot:InvalidPointOffsets
    offsetFraction is neither one row nor one row per point.
    +

    Example

    fig = figure("Visible", "off");
    +cleanup = onCleanup(@() close(fig));
    +ax = axes(fig, "XLim", [0 10], "YLim", [0 20]);
    +xy = labkit.app.plot.offsetPointByAxesFraction(ax, [5 10], [0.1 -0.1]);
    +assert(isequal(xy, [6 8]))
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+plot/offsetPointByAxesFraction.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/plot/showMessage.html b/site/reference/api/labkit/app/plot/showMessage.html new file mode 100644 index 000000000..41ed18773 --- /dev/null +++ b/site/reference/api/labkit/app/plot/showMessage.html @@ -0,0 +1,49 @@ + + + + + + + +labkit.app.plot.showMessage - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.plot.showMessage

    +

    Show a centered empty-state message in an axes.

    +

    Syntax

    +
    hText = labkit.app.plot.showMessage(ax, message) +hText = labkit.app.plot.showMessage(ax, message, Name=Value)
    +

    Inputs

    ax
    Valid scalar MATLAB axes or uiaxes handle.
    message
    User-visible text scalar displayed at the axes center.
    +

    Name-Value Arguments

    Title
    Axes title text. Default: "".
    Color
    MATLAB color value for the message text. Default: [0.30 0.30 0.30].
    +

    Outputs

    hText
    MATLAB text object containing the message.
    +

    Description

    message clears the axes, resets it to linear unit limits [0 1], removes ticks, and draws non-pickable text. Use it for an empty preview, loading prompt, or unavailable result. Because it performs a complete clear, it is not an overlay operation and does not preserve the previous zoom.

    +

    Errors

    labkit:app:plot:InvalidAxes
    ax is not a valid scalar axes handle. labkit:app:plot:InvalidOptions or labkit:app:plot:InvalidOption - Name-value arguments are malformed or unsupported. Invalid MATLAB text or color values propagate their originating graphics error.
    +

    Example

    fig = figure("Visible", "off");
    +cleanup = onCleanup(@() close(fig));
    +ax = axes(fig);
    +h = labkit.app.plot.showMessage(ax, "No data", "Title", "Preview");
    +assert(string(h.String) == "No data")
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+plot/showMessage.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/project/Schema.html b/site/reference/api/labkit/app/project/Schema.html new file mode 100644 index 000000000..2e54caef0 --- /dev/null +++ b/site/reference/api/labkit/app/project/Schema.html @@ -0,0 +1,47 @@ + + + + + + + +labkit.app.project.Schema - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.project.Schema

    +

    Declare one durable App project contract.

    +

    Syntax

    +
    contract = labkit.app.project.Schema() +contract = labkit.app.project.Schema(Name=Value)
    +

    Description

    Project Schema owns payload creation, validation, sequential migration, optional legacy import, resume, and source-relink callback signatures. App-specific fields and scientific meaning remain inside the payload and are not interpreted by this value.

    +

    Default Contract

    With no arguments, Version is 1, Create returns struct(), and Validate accepts any scalar struct. This is the standard path for a simple App with no migration or specialized project invariants.

    +

    Required Name-Value Arguments (custom contract): Version - Positive integer payload version. Create - Fixed callback project = create(). Validate - Fixed callback accepted = validate(project).

    +

    Optional Name-Value Arguments

    Migrate
    Fixed callback project = migrate(project,fromVersion). Required when Version is greater than 1. Default: empty.
    LegacyImports
    Scalar struct mapping legacy MAT variable names to fixed callbacks accepting one value and returning project or [project,resume]. Default: struct().
    CreateResume
    Fixed callback resume = createResume(session,project). Default: empty.
    ApplyResume
    Fixed callback session = applyResume(session,resume,project). Default: empty.
    RelinkSources
    Fixed callback project = relink(project,unresolved,projectFile). Returning empty cancels the load. Default: empty.
    +

    Outputs

    contract
    Immutable labkit.app.project.Schema value.
    +

    Errors

    labkit:app:contract:UnknownArgument
    An option is missing, unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    Version or LegacyImports is invalid.
    labkit:app:contract:CallbackRoleMismatch
    A callback does not have its documented fixed input and output count.
    +

    Typical Call

    contract = labkit.app.project.Schema(Version=1, ...
    +Create=@createProject, Validate=@validateProject);
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+project/Schema.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/project/emptySourceRecords.html b/site/reference/api/labkit/app/project/emptySourceRecords.html new file mode 100644 index 000000000..01dd6a211 --- /dev/null +++ b/site/reference/api/labkit/app/project/emptySourceRecords.html @@ -0,0 +1,43 @@ + + + + + + + +labkit.app.project.emptySourceRecords - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.project.emptySourceRecords

    +

    Create an empty portable source collection.

    +

    Syntax

    +
    records = labkit.app.project.emptySourceRecords()
    +

    Description

    Returns a zero-row struct collection with the same durable field shape as labkit.app.project.sourceRecord. App project factories use this value so the first file selection can assign a portable source without exposing or duplicating its field schema.

    +

    Outputs

    records
    Empty column collection of portable source records.
    +

    Failure Behavior

    None - The function accepts no inputs and returns the canonical empty collection deterministically.

    +

    Example

    project.inputs.sources = labkit.app.project.emptySourceRecords();
    +assert(isempty(project.inputs.sources))
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+project/emptySourceRecords.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/project/sourceRecord.html b/site/reference/api/labkit/app/project/sourceRecord.html new file mode 100644 index 000000000..98736414a --- /dev/null +++ b/site/reference/api/labkit/app/project/sourceRecord.html @@ -0,0 +1,46 @@ + + + + + + + +labkit.app.project.sourceRecord - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.project.sourceRecord

    +

    Create a portable source value during project migration.

    +

    Syntax

    +
    record = labkit.app.project.sourceRecord(id,role,filepath) +record = labkit.app.project.sourceRecord(id,role,filepath,required)
    +

    Description

    Creates the opaque durable source value accepted by App project schemas. Use this pure constructor only while creating or migrating payloads. Runtime callbacks resolve paths through CallbackContext.

    +

    Inputs

    id
    Nonempty scalar text stable within the project.
    role
    Nonempty scalar semantic source role.
    filepath
    Nonempty scalar source path, or an existing scalar opaque reference struct during legacy migration.
    required
    Optional logical scalar relinking requirement. Default: true.
    +

    Outputs

    record
    Scalar portable source struct owned by the App framework.
    +

    Errors

    labkit:app:contract:InvalidValue
    Text or required is malformed.
    +

    Example

    source = labkit.app.project.sourceRecord( ...
    +"image1","cropSource","image.png");
    +assert(source.id == "image1")
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+project/sourceRecord.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/result/File.html b/site/reference/api/labkit/app/result/File.html new file mode 100644 index 000000000..38ae46157 --- /dev/null +++ b/site/reference/api/labkit/app/result/File.html @@ -0,0 +1,46 @@ + + + + + + + +labkit.app.result.File - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.result.File

    +

    Declare one validated result-package output.

    +

    Syntax

    +
    output = labkit.app.result.File(id, role, relativePath, Name=Value)
    +

    Description

    File records App-owned output identity and media semantics. Runtime result writing later resolves the relative path, file size, checksum, and aggregate manifest status.

    +

    Inputs

    id
    Nonempty scalar text unique within one Result.
    role
    Nonempty scalar text describing output purpose.
    relativePath
    Nonempty package-relative path without traversal.
    +

    Optional Name-Value Arguments

    MediaType
    Nonempty media-type scalar text. Default: "application/octet-stream".
    Status
    "success", "failed", or "skipped". Default: "success".
    Message
    Scalar reader-facing status text. Default: empty.
    Warnings
    Row string or cellstr array. Default: empty.
    +

    Outputs

    output
    Immutable labkit.app.result.File value.
    +

    Errors

    labkit:app:contract:UnknownArgument
    An option is unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    Text, path, status, or warnings are malformed.
    +

    Example

    output = labkit.app.result.File( ...
    +"summary", "primary", "summary.csv", MediaType="text/csv");
    +assert(output.Status == "success")
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+result/File.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/result/Package.html b/site/reference/api/labkit/app/result/Package.html new file mode 100644 index 000000000..8f911e2c1 --- /dev/null +++ b/site/reference/api/labkit/app/result/Package.html @@ -0,0 +1,48 @@ + + + + + + + +labkit.app.result.Package - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.result.Package

    +

    Declare one App-owned result manifest request.

    +

    Syntax

    +
    result = labkit.app.result.Package(Name=Value)
    +

    Description

    Result contains validated output declarations and App-owned input, parameter, and summary data. Runtime result writing owns provenance, App and facade versions, document identity, file bytes, checksums, creation identity/time, aggregate status, and atomic replacement.

    +

    Required Name-Value Arguments

    Outputs
    Nonempty row cell array of labkit.app.result.File values with unique IDs and relative paths and at least one success.
    Inputs
    Scalar App-owned struct describing result inputs.
    Parameters
    Scalar App-owned struct describing result parameters.
    Summary
    Scalar App-owned struct describing result summary.
    +

    Optional Name-Value Arguments

    Warnings
    Row string or cellstr array. Default: empty.
    ManifestName
    Nonempty filename without folders. Default: "labkit_result.json".
    +

    Outputs

    result
    Immutable labkit.app.result.Package value.
    +

    Errors

    labkit:app:contract:UnknownArgument
    An option is missing, unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    A value or manifest name is invalid.
    labkit:app:contract:DuplicateId
    Output IDs or paths repeat.
    +

    Example

    output = labkit.app.result.File( ...
    +"summary", "primary", "summary.csv", MediaType="text/csv");
    +result = labkit.app.result.Package(Outputs={output}, Inputs=struct(), ...
    +Parameters=struct(), Summary=struct("rows", 1));
    +assert(result.ManifestName == "labkit_result.json")
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+result/Package.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/version.html b/site/reference/api/labkit/app/version.html new file mode 100644 index 000000000..339caded5 --- /dev/null +++ b/site/reference/api/labkit/app/version.html @@ -0,0 +1,44 @@ + + + + + + + +labkit.app.version - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.version

    +

    Return the LabKit App SDK facade contract version.

    +

    Syntax

    +
    info = labkit.app.version()
    +

    Description

    Reports the semantic version and compatibility range of the public LabKit App SDK. The SDK owns App definitions, semantic layout, transactions, projects, portable sources, resources, results, dialogs, and native MATLAB presentation. It is independent of App product and saved-project schema versions.

    +

    Inputs

    None.

    +

    Outputs

    info
    Scalar structure returned by labkit.contract.versionInfo with name, current, compatible, status, and notes fields.
    +

    Errors

    labkit:contract:InvalidVersionInfo
    Embedded facade metadata is invalid.
    +

    Example

    info = labkit.app.version();
    +assert(startsWith(info.current, "1."))
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/version.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/app/view/Snapshot.html b/site/reference/api/labkit/app/view/Snapshot.html new file mode 100644 index 000000000..6bb3a4879 --- /dev/null +++ b/site/reference/api/labkit/app/view/Snapshot.html @@ -0,0 +1,67 @@ + + + + + + + +labkit.app.view.Snapshot - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    reference

    +

    API ReferenceLabKit App SDK

    +

    labkit.app.view.Snapshot

    +

    Build one immutable complete visible-state snapshot.

    +

    Syntax

    +
    view = labkit.app.view.Snapshot() +view = view.value(target, value) +view = view.choices(target, choices) +view = view.limits(target, limits) +view = view.enabled(target, enabled) +view = view.visible(target, visible) +view = view.text(target, text) +view = view.filePaths(target, paths) +view = view.fileItemStatuses(target, statuses) +view = view.listSelection(target, selection) +view = view.tableCellSelection(target, selection) +view = view.tableData(target, data, Name=Value) +view = view.renderPlot(target, model) +view = view.workspacePage(target, Name=Value) +view = view.anchorPath(interaction, points, Name=Value) +view = view.pairedAnchors(interaction, pointSets, Name=Value) +view = view.pointSlots(interaction, value, Name=Value) +view = view.rectangle(interaction, position, Name=Value) +view = view.regionSelection(interaction, Name=Value) +view = view.interval(interaction, range, Name=Value) +view = view.scaleReference(interaction, endpoints, Name=Value) +view = view.include(fragment)
    +

    Description

    View snapshot is a closed immutable operation vocabulary. One value describes the complete current visible state; Definition validates targets, capabilities, renderer references, and completeness before a runtime may reconcile it. Apps do not author patches or generic target/property setters.

    +

    Inputs

    target
    Nonempty Layout target ID.
    value
    Target value owned by the App state.
    choices
    Text array or cellstr of legal choices.
    limits
    Increasing finite two-element numeric row.
    enabled
    Logical scalar availability.
    visible
    Logical scalar visibility.
    text
    Scalar text.
    paths
    String or cell array of file paths.
    statuses
    Empty or one reader-facing status per file-list row.
    selection
    Selection value accepted by the target.
    data
    App-owned table, numeric array, or cell array.
    model
    App-owned model passed to the renderer declared by plotArea.
    +

    Outputs

    view
    New immutable labkit.app.view.Snapshot snapshot.
    fragment
    Another Snapshot whose non-duplicate operations should be included in this snapshot.
    +

    Errors

    labkit:app:contract:InvalidValue
    An operation argument is malformed.
    labkit:app:contract:DuplicateId
    The same operation is supplied twice for one target.
    +

    Example

    view = labkit.app.view.Snapshot();
    +view = view.choices("group", ["A", "B"]);
    +view = view.value("group", "A");
    +assert(isa(view, "labkit.app.view.Snapshot"))
    + +

    Source

    +

    This page is generated from the MATLAB help text in +labkit/+app/+view/Snapshot.m.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/reference/api/labkit/contract/assertRequirements.html b/site/reference/api/labkit/contract/assertRequirements.html index 12bb7475e..6adecd809 100644 --- a/site/reference/api/labkit/contract/assertRequirements.html +++ b/site/reference/api/labkit/contract/assertRequirements.html @@ -31,7 +31,7 @@

    Syntax

    Inputs

    appName
    Text scalar used as the error-identifier prefix. It should be a valid MATLAB identifier, for example "labkit_Example_app".
    req
    Scalar structure returned by labkit.contract.requirements.
    versions
    Optional version structure array accepted by checkRequirements.

    Outputs

    None
    Returns no value.

    Errors

    Throws <appName>:IncompatibleLabKit when compatibility checks fail. Input and range validation errors from checkRequirements are passed through.

    -

    Example

    req = labkit.contract.requirements("ui", ">=7 <8");
    +

    Example

    req = labkit.contract.requirements("app", ">=1 <2");
     labkit.contract.assertRequirements("labkit_Example_app", req)

    Source

    diff --git a/site/reference/api/labkit/contract/checkRequirements.html b/site/reference/api/labkit/contract/checkRequirements.html index f59b92f22..c1e84b02b 100644 --- a/site/reference/api/labkit/contract/checkRequirements.html +++ b/site/reference/api/labkit/contract/checkRequirements.html @@ -27,7 +27,7 @@

    labkit.contract.checkRequirements

    Syntax

    report = labkit.contract.checkRequirements(req) report = labkit.contract.checkRequirements(req, versions)
    -

    Description

    Checks each requested facade in two ways: its current version must satisfy the caller's range, and that range must overlap at least one compatibility range advertised by the facade. When versions is omitted, the current ui, dta, rhs, biosignal, image, and thermal version functions are queried.

    +

    Description

    Checks each requested facade in two ways: its current version must satisfy the caller's range, and that range must overlap at least one compatibility range advertised by the facade. When versions is omitted, the current app, dta, rhs, biosignal, image, and thermal version functions are queried.

    Inputs

    req
    Scalar structure returned by labkit.contract.requirements.
    versions
    Optional structure array returned by facade version functions or versionInfo. Every element must contain name, current, compatible, status, and notes. An optional facade field overrides the name used for matching. This argument is mainly useful for diagnostics and tests.

    Outputs

    report
    Scalar structure with ok, failures, and message fields. ok is true when every requirement passes. message is a success sentence or newline-separated failure text.

    Output Fields

    failures
    Structure array with one element per failed requirement.
    failures.facade
    Normalized facade name.
    failures.required
    Range requested by the caller.
    failures.available
    Compatibility ranges advertised by the facade, or an empty string array when the facade is unknown.
    failures.message
    Readable incompatibility explanation.
    diff --git a/site/reference/api/labkit/contract/requirements.html b/site/reference/api/labkit/contract/requirements.html index 68458384f..5bd7cbbff 100644 --- a/site/reference/api/labkit/contract/requirements.html +++ b/site/reference/api/labkit/contract/requirements.html @@ -25,13 +25,13 @@

    labkit.contract.requirements

    Describe the LabKit API versions required by a caller.

    Syntax

    -
    req = labkit.contract.requirements("ui", ">=2.0 <3", ...)
    -

    Description

    Builds a normalized requirement structure from facade/range pairs. A facade name may be written as "ui" or "labkit.ui". Names are converted to lowercase and the optional "labkit." prefix is removed. No compatibility check is performed until checkRequirements or assertRequirements is called.

    +
    req = labkit.contract.requirements("app", ">=1.0 <2", ...)
    +

    Description

    Builds a normalized requirement structure from facade/range pairs. A facade name may be written as "app" or "labkit.app". Names are converted to lowercase and the optional "labkit." prefix is removed. No compatibility check is performed until checkRequirements or assertRequirements is called.

    Inputs

    varargin
    Alternating facade names and version ranges. Facade names are text scalars containing letters, digits, or underscores. A range is a nonempty text scalar containing whitespace-separated constraints such as ">=2.0 <3". Supported operators are >, >=, <, <=, =, and ==.

    Outputs

    req
    Scalar structure with type="labkit.requirements" and a facades structure array. Each facades element has normalized facade and range fields. Calling requirements() with no pairs returns an empty list.

    Errors

    Throws labkit:contract:InvalidRequirements for an odd number of arguments, labkit:contract:InvalidFacadeName for an invalid name, labkit:contract:InvalidVersionRange for an empty or nontext range, and labkit:contract:DuplicateRequirement when a facade appears more than once.

    Example

    req = labkit.contract.requirements( ...
    -"ui", ">=7 <8", ...
    +"app", ">=1 <2", ...
     "image", ">=4.0 <5");
     report = labkit.contract.checkRequirements(req);
    diff --git a/site/reference/api/labkit/ui/debug/context.html b/site/reference/api/labkit/ui/debug/context.html deleted file mode 100644 index 8da84022f..000000000 --- a/site/reference/api/labkit/ui/debug/context.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - -labkit.ui.debug.context - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.debug.context

    -

    Create an app-neutral callback tracing and diagnostic context.

    -

    Syntax

    -
    debug = labkit.ui.debug.context(appName) -debug = labkit.ui.debug.context(appName, opts)
    -

    Inputs

    appName
    Text scalar identifying the app in every structured trace line and diagnostic report.
    opts
    Optional scalar struct described below. Default: struct().
    -

    Options

    enabled
    Logical master switch. A disabled context ignores captured messages and does not create artifact folders. Default: true.
    traceEnabled
    Logical switch for trace and callback instrumentation. Default: enabled.
    logFile
    Text file path. Existing content is replaced when the context is created, then every captured line is appended. Default: "".
    logCallback
    Function handle called as logCallback(line) for every captured append or trace line. Default: [].
    traceCallback
    Function handle called as traceCallback(line) only for structured trace lines. Default: [].
    stallTimeoutSeconds
    Nonnegative callback duration in seconds before an instrumented operation writes a stalled-operation report. Default: 30.
    crashReportFile
    Diagnostic report path. By default, uses the logFile folder and base name with a _crash_report.txt suffix.
    activeOperationFile
    Path written at callback start and removed after successful or failed completion. A MATLAB process failure can therefore leave the last active callback on disk. The default is derived from logFile with an _active_operation.txt suffix.
    artifactFolder
    Root folder for debug sample packs. Default: the logFile folder.
    sampleFolder
    Folder for copied or synthetic input samples. Default: fullfile(artifactFolder,"samples").
    outputFolder
    Folder for debug outputs. Default: fullfile(artifactFolder,"outputs").
    manifestFile
    JSON manifest path. Default: fullfile(artifactFolder,"manifest.json").
    callbackProperties
    Cell array or string array of graphics callback property names used only by instrumentFigure(fig,opts). Each name is ignored on handles that do not expose that property. Default: the standard button, value, selection, cell, close, size, keyboard, and tree callback properties listed by the runtime.
    -

    Outputs

    debugContext
    Scalar struct, shown as debug in the usage syntax, containing configuration fields and the methods below.
    -

    Context Fields

    appName
    Normalized app name as a string scalar.
    enabled
    Effective master switch.
    traceEnabled
    Effective trace switch.
    logFile
    Effective log path.
    crashReportFile
    Effective caught-error and stall-report path.
    activeOperationFile
    Effective active-operation marker path.
    artifactFolder
    Effective artifact root.
    sampleFolder
    Effective sample folder.
    outputFolder
    Effective output folder.
    manifestFile
    Effective manifest path.
    -

    Context Methods

    append(message)
    Add one timestamped human-readable log line.
    trace(event)
    Add a structured app-level trace with reason "internal".
    trace(component,event,reason)
    Add a trace containing stable app, component, event, and reason fields.
    reportException(event,exception)
    Trace and report an app-level exception.
    reportException(component,event,exception)
    Trace and report a component exception. Non-MException values are recorded as caught error text.
    setTraceCallback(callback)
    Replace the trace-only callback.
    attachTextLog(textArea)
    Send future trace lines to a LabKit log text area.
    wrapCallback(name,callback)
    Return a callback that records BEGIN, END, elapsed operation state, stalls, and errors while preserving outputs.
    instrumentFigure(fig)
    Wrap supported callbacks already installed on a figure and its descendants; return the number wrapped.
    instrumentFigure(fig,opts)
    Use opts.callbackProperties instead of the standard callback-property list.
    recordArtifacts(manifest)
    Write manifest plus effective debug paths to manifestFile when that path is configured.
    getLog()
    Return captured lines as a column cell array of character rows.
    -

    Description

    context provides opt-in diagnostics without coupling an app to a particular log panel. Instrumented callbacks are rethrown after reporting, so debugging does not change error control flow. File-chooser trace events suppress stall reports while a known modal dialog is active. With no file paths configured, messages remain in memory and no files or folders are created.

    -

    Errors

    labkit:ui:DebugLogFileOpenFailed
    A configured log or report file cannot be opened for writing. Instrumented callback exceptions are recorded and then rethrown unchanged. Invalid option types, callback properties, graphics handles, or artifact paths may propagate the originating MATLAB conversion, graphics, or file-system error.
    -

    Example

    debug = labkit.ui.debug.context("example_app");
    -debug.trace("loader", "file parsed", "user");
    -lines = debug.getLog();
    -assert(contains(lines{end}, "component=loader"))
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+debug/context.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/interaction/anchorPath.html b/site/reference/api/labkit/ui/interaction/anchorPath.html deleted file mode 100644 index 4f5f918ba..000000000 --- a/site/reference/api/labkit/ui/interaction/anchorPath.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - -labkit.ui.interaction.anchorPath - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.interaction.anchorPath

    -

    Build a visible path through image anchor points.

    -

    Syntax

    -
    curve = labkit.ui.interaction.anchorPath(points, imageSize) -curve = labkit.ui.interaction.anchorPath(points, imageSize, Name=Value) -[curve, owners] = labkit.ui.interaction.anchorPath(...)
    -

    Inputs

    points
    N-by-2 numeric matrix of [x y] anchor coordinates in image-pixel coordinates.
    imageSize
    Numeric vector whose first two elements are the positive finite image height and width.
    options
    Name-value argument container for Style and Closed. Supply these values by name; do not pass an options struct.
    -

    Name-Value Arguments

    Style
    "Curve" for a Catmull-Rom path or "Straight lines" for line segments joining the anchors. Default: "Curve".
    Closed
    Logical scalar. true joins the last anchor to the first and requires at least three anchors. false requires at least two. Default: false.
    -

    Outputs

    curve
    M-by-2 path samples. Coordinates are limited to pixel-edge bounds [0.5, width+0.5] and [0.5, height+0.5]. The result is empty until the selected open or closed path has enough anchors.
    owners
    (M-1)-by-1 anchor-segment indices used to associate each visible curve segment with its starting anchor. It is empty with curve.
    -

    Description

    anchorPath contains the deterministic geometry shared by managed anchor editors and app previews. Curved paths pass through the supplied anchors; two-point open curves reduce to a straight segment. The function creates no graphics and can be used independently of a LabKit app.

    -

    Errors

    MATLAB argument-validation or assertion errors are raised when points is not N-by-2, imageSize lacks positive finite height and width, Style is unsupported, or a named argument has an incompatible type or shape.

    -

    Example

    points = [10 30; 30 10; 50 30];
    -curve = labkit.ui.interaction.anchorPath( ...
    -points, [40 60], "Style", "Straight lines");
    -assert(isequal(curve, points))
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+interaction/anchorPath.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/interaction/enablePopout.html b/site/reference/api/labkit/ui/interaction/enablePopout.html deleted file mode 100644 index 7508deea5..000000000 --- a/site/reference/api/labkit/ui/interaction/enablePopout.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - -labkit.ui.interaction.enablePopout - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.interaction.enablePopout

    -

    Add a standalone-figure action to an axes context menu.

    -

    Syntax

    -
    labkit.ui.interaction.enablePopout(ax)
    -

    Inputs

    ax
    MATLAB axes or uiaxes handle that receives an "Open axes in new figure" context-menu item. Empty or invalid handles are ignored.
    -

    Outputs

    None.

    -

    Description

    The menu copies the current axes content, limits, labels, colors, and visible legend entries into a normal MATLAB figure with publication and export tools. Existing axes context-menu items are preserved. Children without their own context menu inherit the axes menu, including children added after this call. Repeated calls do not add duplicate menu items.

    -

    Failure Behavior

    Empty or invalid handles are ignored. Errors raised while MATLAB creates the context menu, copies graphics, or opens the standalone figure are not caught and propagate from the originating graphics operation.

    -

    Typical Call

    plot(ax, time, signal);
    -labkit.ui.interaction.enablePopout(ax);
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+interaction/enablePopout.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/interaction/scaleBarCalibration.html b/site/reference/api/labkit/ui/interaction/scaleBarCalibration.html deleted file mode 100644 index 20a650946..000000000 --- a/site/reference/api/labkit/ui/interaction/scaleBarCalibration.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - -labkit.ui.interaction.scaleBarCalibration - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.interaction.scaleBarCalibration

    -

    Convert a known image distance into pixels per unit.

    -

    Syntax

    -
    cal = labkit.ui.interaction.scaleBarCalibration(referencePixels, ... -referenceLength, unitName) -cal = labkit.ui.interaction.scaleBarCalibration(..., opts)
    -

    Inputs

    referencePixels
    Measured reference distance in image pixels. Empty, nonnumeric, nonfinite, or nonpositive values are treated as missing.
    referenceLength
    Physical reference distance expressed in unitName. Missing, nonfinite, or negative values become 0.
    unitName
    Unit label. With default options, legal values are "m", "cm", "mm", "um", and "nm". An unsupported value uses defaultUnit.
    opts
    Optional scalar struct described below. Default: struct().
    -

    Options

    units
    Allowed unit labels. Default: {'m','cm','mm','um','nm'}.
    defaultUnit
    Fallback unit. Default: the first entry in units.
    referenceLine
    N-by-2 numeric reference points stored with the result. When it contains exactly two rows and referencePixels is missing, their Euclidean distance supplies referencePixels. Default: zeros(0,2).
    -

    Outputs

    cal
    Scalar struct with the fields described below.
    -

    Calibration Fields

    referencePixels
    Positive measured pixel distance, or NaN when missing.
    referenceLength
    Nonnegative physical reference distance.
    unit
    Normalized unit label as a character vector.
    pixelsPerUnit
    referencePixels/referenceLength, or 0 when calibration is incomplete.
    isCalibrated
    true when pixelsPerUnit is positive.
    referenceLine
    Normalized N-by-2 numeric reference coordinates.
    -

    Description

    This function builds a serializable calibration value; it does not read an image or draw a scale bar. Repeating the call with identical inputs returns identical numeric fields. Divide a pixel distance by pixelsPerUnit to obtain a distance in cal.unit.

    -

    Failure Behavior

    Missing or invalid measurement values are normalized into an uncalibrated result instead of throwing. opts must be a scalar structure whose units can be converted to text and whose referenceLine can be converted to an N-by-2 numeric array; incompatible MATLAB values propagate conversion errors.

    -

    Example

    cal = labkit.ui.interaction.scaleBarCalibration(80, 20, "mm");
    -physicalLength = 40 / cal.pixelsPerUnit;
    -assert(cal.isCalibrated && physicalLength == 10)
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+interaction/scaleBarCalibration.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/interaction/scaleBarGeometry.html b/site/reference/api/labkit/ui/interaction/scaleBarGeometry.html deleted file mode 100644 index 5b5581906..000000000 --- a/site/reference/api/labkit/ui/interaction/scaleBarGeometry.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - -labkit.ui.interaction.scaleBarGeometry - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.interaction.scaleBarGeometry

    -

    Compute serializable image scale-bar overlay geometry.

    -

    Syntax

    -
    geometry = labkit.ui.interaction.scaleBarGeometry(imageSize, ... -calibration, barLength, position, colorName)
    -

    Inputs

    imageSize
    Numeric vector whose first two elements are positive finite image height and width in pixels.
    calibration
    Struct with a positive finite pixelsPerUnit field and a unit field, normally returned by scaleBarCalibration.
    barLength
    Positive finite physical length expressed in calibration.unit.
    position
    Text containing "top" or "bottom" and optionally "left", "center", or "right". Unrecognized vertical text uses bottom; unrecognized horizontal text uses center.
    colorName
    "White" selects [1 1 1]. Every other value selects black.
    -

    Outputs

    geometry
    Scalar data struct with the fields described below.
    -

    Geometry Fields

    line
    Two-by-two [x y] endpoints in image-pixel coordinates.
    label
    Display text containing barLength and calibration.unit.
    color
    RGB triplet selected by colorName.
    labelPosition
    One-by-two centered label position in image pixels.
    verticalAlignment
    "top" for a top bar or "bottom" for a bottom bar.
    pixelsPerUnit
    Calibration value copied into the geometry.
    unit
    Unit label copied into the geometry as a string scalar.
    barLength
    Requested physical length.
    position
    Supplied position text as a string scalar.
    colorName
    Supplied color text as a string scalar.
    -

    Description

    The helper places the line with an eight-percent image-edge margin and a five-pixel minimum inset. It errors when the requested bar cannot fit in the available horizontal span. Apps and renderers own drawing; this function creates no graphics and is suitable for saved project state.

    -

    Errors

    labkit:ui:interaction:InvalidImageSize
    imageSize lacks positive finite height and width.
    labkit:ui:interaction:InvalidScaleBar
    calibration or barLength is not a positive finite scalar.
    labkit:ui:interaction:ScaleBarTooLong
    The requested bar does not fit inside the horizontal margins.
    -

    Example

    cal = labkit.ui.interaction.scaleBarCalibration(80, 20, "mm");
    -geometry = labkit.ui.interaction.scaleBarGeometry( ...
    -[600 800], cal, 10, "Bottom right", "White");
    -assert(abs(diff(geometry.line(:,1))) == 40)
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+interaction/scaleBarGeometry.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/action.html b/site/reference/api/labkit/ui/layout/action.html deleted file mode 100644 index 8411aa684..000000000 --- a/site/reference/api/labkit/ui/layout/action.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - -labkit.ui.layout.action - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.action

    -

    Create an app-command layout node.

    -

    Syntax

    -
    layout = labkit.ui.layout.action(id, labelText, onInvoke) -layout = labkit.ui.layout.action(id, labelText, onInvoke, Name=Value)
    -

    Inputs

    id
    Text scalar used to identify the action. It must be a valid MATLAB variable name and unique within the workbench.
    labelText
    Text displayed on the push button.
    onInvoke
    Function handle called as onInvoke(control,event) after the button is pressed. Use [] when a runtime binding will supply behavior. The default is [].
    -

    Name-Value Arguments

    enabled
    Logical value controlling whether the button can be pressed. Default: true.
    busyMessage
    Text appended to the app title while onInvoke runs. Default: labelText.
    -

    Outputs

    layout
    Scalar action node with kind, id, props, children, and slots fields. The node creates no graphics until the workbench is launched.
    -

    Description

    action represents one app command. A section may contain an action directly, or several actions may be placed in an action group. The runtime ignores a press while the figure is already busy and restores the normal title after the callback completes or throws.

    -

    Errors

    labkit:ui:layout:InvalidId
    id is not a valid MATLAB field name. labkit:ui:layout:InvalidOptions or InvalidOptionName - Name-value arguments are unpaired or an option name is not valid scalar text. Callback value compatibility is checked when the runtime builds the node.
    -

    Example

    runAction = labkit.ui.layout.action( ...
    -"runAnalysis", "Run analysis", @(~,~) disp("Running"));
    -assert(runAction.kind == "action")
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/action.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/field.html b/site/reference/api/labkit/ui/layout/field.html deleted file mode 100644 index 71706c718..000000000 --- a/site/reference/api/labkit/ui/layout/field.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - -labkit.ui.layout.field - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.field

    -

    Create a labeled scalar field layout node.

    -

    Syntax

    -
    layout = labkit.ui.layout.field(id, labelText) -layout = labkit.ui.layout.field(id, labelText, Name=Value)
    -

    Inputs

    id
    Text scalar used to identify the field. It must be a valid MATLAB variable name and unique within the workbench.
    labelText
    Text displayed beside the field. A checkbox displays it as the checkbox label.
    -

    Name-Value Arguments

    kind
    Control type: "text", "number", "spinner", "dropdown", "slider", "checkbox", or "readonly". Default: "text".
    value
    Initial control value. When omitted, the selected MATLAB control's default value is used.
    items
    Dropdown choices as text, a string array, or cellstr. Used only by kind="dropdown".
    limits
    Two-element numeric limits for number, spinner, or slider fields.
    step
    Spinner step size.
    valueDisplayFormat
    MATLAB numeric display format such as "%.3f".
    showTicks
    Logical value controlling slider tick marks. MATLAB's slider ticks are used when omitted; false hides both major and minor ticks.
    enabled
    Logical value controlling whether the field accepts input. Default: true.
    onChange
    Function handle called as onChange(control,event). event.value contains the current field value.
    debounceMs
    Delay before onChange runs, in milliseconds. A later change restarts the delay. Default: 500. Use 0 for immediate dispatch.
    Bind
    Runtime V2 state path such as "project.parameters.gain" or "session.selection.channel". The path must exist in initial state.
    Event
    Action ID dispatched after a bound value is written. Event requires Bind; omit Event when the binding alone is sufficient.
    -

    Outputs

    layout
    Scalar field node with kind, id, props, children, and slots fields.
    -

    Description

    field covers common single-value controls. In a Runtime V2 app, Bind makes state the source of truth: the runtime initializes the control from that path, writes user changes back, and then dispatches Event when supplied. Without Bind, onChange is the low-level callback used by runtime.create.

    -

    Errors

    labkit:ui:layout:InvalidFieldKind
    kind is not one of the documented control types. labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed. Control-specific values are validated when the runtime builds the node.
    -

    Example

    gain = labkit.ui.layout.field("gain", "Gain", ...
    -"kind", "spinner", "value", 2, "limits", [0 10], "step", 0.5);
    -assert(gain.props.value == 2)
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/field.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/filePanel.html b/site/reference/api/labkit/ui/layout/filePanel.html deleted file mode 100644 index e73f1416d..000000000 --- a/site/reference/api/labkit/ui/layout/filePanel.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - -labkit.ui.layout.filePanel - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.filePanel

    -

    Create a file input panel layout node.

    -

    Syntax

    -
    layout = labkit.ui.layout.filePanel(id, labelText) -layout = labkit.ui.layout.filePanel(id, labelText, Name=Value)
    -

    Inputs

    id
    Text scalar used to identify the panel. It must be a valid MATLAB variable name and unique within the workbench.
    labelText
    Text displayed in the panel title.
    -

    Name-Value Arguments

    mode
    "multi" accumulates files and provides file, folder, recursive folder, remove, and clear actions. "single" shows one chooser and replaces the previous file. Default: "multi".
    filters
    uigetfile-style filter cell array used by file selection and folder scans. Default: {'*.*','All files'}.
    selectionMode
    List selection mode in a multi panel: "single" or "multiple". Default: "single".
    maxFiles
    Positive maximum number of retained entries. Inf has no limit. Single mode always retains one file. Default: Inf.
    folderWarningThreshold
    Nonnegative match count above which a folder scan asks for confirmation. Default: 500.
    showStatus
    Logical value controlling the count/status box in multi mode. Default: true.
    startPath
    Preferred initial file-dialog folder. A missing or unsafe path falls back to the last LabKit input folder or the user's home folder.
    chooseLabel
    File button text. Default: "Add..." in multi mode and "Choose..." in single mode.
    folderLabel
    Direct-folder button text. Default: "Add folder".
    recursiveFolderLabel
    Recursive-folder button text. Default: "Add folder tree".
    removeLabel
    Remove button text. Default: "Remove selected".
    clearLabel
    Clear button text. Default: "Clear".
    emptyText
    Text shown when no files are loaded. Default: "No files loaded".
    onChoose
    Callback called as onChoose(control,event) after files are added.
    onRemove
    Callback called after the selected entries are removed.
    onClear
    Callback called after every entry is removed.
    onSelectionChange
    Callback called when list selection changes.
    -

    Callback Event Fields

    action
    "choose", "remove", "clear", or "select".
    files
    Complete file-entry array after the action.
    selectedFiles
    Currently selected entries after the action.
    value
    Same value as selectedFiles.
    addedFiles
    Entries added by a choose action.
    removedFiles
    Entries removed by a remove or clear action.
    -

    File Entry Fields

    id
    Stable entry ID generated by the panel unless supplied by the app.
    index
    One-based position in the current file list.
    path
    Full selected file path.
    name
    File name and extension.
    displayName
    User-facing label; defaults to name.
    status
    Optional app-authored status text shown with the file label.
    -

    Outputs

    layout
    Scalar filePanel node with kind, id, props, children, and slots fields.
    -

    Description

    Multi-file selection uses one native file dialog and therefore returns files from one folder. Folder actions cover the other common cases: direct children only or all matching files recursively. Cancelling any chooser leaves the current list unchanged. Files beyond maxFiles are discarded in selection order.

    -

    Errors

    labkit:ui:layout:InvalidFilePanelMode or

    -
    InvalidFilePanelSelectionMode
    A mode label is unsupported. labkit:ui:layout:InvalidFilePanelMaxFiles or
    InvalidFilePanelWarningThreshold
    A numeric limit is invalid. labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed.
    -

    Typical Call

    files = labkit.ui.layout.filePanel("images", "Images", ...
    -"filters", {'*.png;*.tif','Images'}, "selectionMode", "multiple");
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/filePanel.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/group.html b/site/reference/api/labkit/ui/layout/group.html deleted file mode 100644 index 9f50cfad9..000000000 --- a/site/reference/api/labkit/ui/layout/group.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - -labkit.ui.layout.group - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.group

    -

    Create a grouped UI layout.

    -

    Syntax

    -
    layout = labkit.ui.layout.group(id, titleText, children) -layout = labkit.ui.layout.group(id, titleText, children, "layout", mode)
    -

    Inputs

    id
    Text scalar used to identify the group. It must be a valid MATLAB variable name and unique within the workbench.
    titleText
    Optional group title. Use "" for an untitled group. Default: "".
    children
    Nonempty cell row vector containing field, rangeField, panner, action, or nested group nodes. Default: {}. An empty group can be constructed but is rejected when the workbench is launched.
    -

    Name-Value Arguments

    layout
    Group presentation mode: "auto", "actions", "form", "inline", or "grid". "auto" uses an action-button layout when every child is an action and a form layout otherwise. "actions" requires every child to be an action. The other modes currently use the standard form presentation. Default: "auto".
    -

    Outputs

    layout
    Scalar group node with kind, id, props, children, and slots fields.
    -

    Description

    group keeps related controls together inside one section. It is useful for a row or block of commands and for a labeled subsection of fields. Concrete row heights, spacing, and column widths are selected by the workbench.

    -

    Errors

    labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed.

    -
    labkit:ui:layout:InvalidChildren
    children is not a cell row of scalar layout nodes. Child kinds and layout mode compatibility are validated at workbench launch.
    -

    Example

    commands = labkit.ui.layout.group("commands", "Commands", { ...
    -labkit.ui.layout.action("run", "Run", []), ...
    -labkit.ui.layout.action("reset", "Reset", [])});
    -assert(numel(commands.children) == 2)
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/group.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/logPanel.html b/site/reference/api/labkit/ui/layout/logPanel.html deleted file mode 100644 index dd8ac21e5..000000000 --- a/site/reference/api/labkit/ui/layout/logPanel.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - -labkit.ui.layout.logPanel - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.logPanel

    -

    Create a read-only log panel layout node.

    -

    Syntax

    -
    layout = labkit.ui.layout.logPanel(id, titleText) -layout = labkit.ui.layout.logPanel(id, titleText, "value", lines)
    -

    Inputs

    id
    Text scalar used to identify the log. It must be a valid MATLAB variable name and unique within the workbench.
    titleText
    Text displayed in the panel title.
    -

    Name-Value Arguments

    value
    Initial log lines as text, a string array, or cellstr. Default: {'Ready.'}.
    -

    Outputs

    layout
    Scalar logPanel node with kind, id, props, children, and slots fields.
    -

    Description

    logPanel displays changing workflow or diagnostic messages in a read-only text area. It follows the newest line by default. Users can pause or resume automatic scrolling with the visible button or the context menu. Use the workbench usage option for static instructions that should always remain visible.

    -

    Errors

    labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed. Text conversion and graphics compatibility of value are validated when the runtime builds the panel.

    -

    Example

    logView = labkit.ui.layout.logPanel( ...
    -"workflowLog", "Log", "value", ["Ready."; "Waiting for files."]);
    -assert(logView.kind == "logPanel")
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/logPanel.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/panner.html b/site/reference/api/labkit/ui/layout/panner.html deleted file mode 100644 index 65acdb188..000000000 --- a/site/reference/api/labkit/ui/layout/panner.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - -labkit.ui.layout.panner - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.panner

    -

    Create a numeric spinner with a linked slider.

    -

    Syntax

    -
    layout = labkit.ui.layout.panner(id, labelText) -layout = labkit.ui.layout.panner(id, labelText, Name=Value)
    -

    Inputs

    id
    Text scalar used to identify the panner. It must be a valid MATLAB variable name and unique within the workbench.
    labelText
    Text displayed beside the numeric control.
    -

    Name-Value Arguments

    limits
    Increasing two-element numeric vector. Finite limits create a spinner linked to a slider; an infinite endpoint creates a spinner only. Default: [0 1].
    value
    Initial numeric value. Values outside limits are clamped when the control is built. Default: limits(1).
    step
    Positive absolute spinner step. When omitted, it is inferred from stepFraction for finite limits.
    stepFraction
    Fraction of the finite limit span used for the inferred step. Default: 0.002.
    minStep
    Optional lower bound for the inferred step.
    maxStep
    Optional upper bound for the inferred step.
    valueDisplayFormat
    Numeric display format such as "%.2f".
    showTicks
    Logical value controlling slider ticks. Default: false.
    enabled
    Logical value controlling both spinner and slider. Default: true.
    onChange
    Function handle called as onChange(control,event). event.value is the synchronized value; event.action is "edit" or "slide".
    debounceMs
    Delay before onChange runs, in milliseconds. Default: 500. Use 0 for immediate dispatch.
    Bind
    Runtime V2 path to a scalar project or session value.
    Event
    Action ID dispatched after a bound value is written. Requires Bind.
    -

    Outputs

    layout
    Scalar panner node with kind, id, props, children, and slots fields.
    -

    Description

    panner gives precise numeric entry and, when both limits are finite, quick slider navigation. Editing either control updates the other before the callback runs. Reassigning the current value does not fire the callback.

    -

    Errors

    labkit:ui:layout:InvalidPannerLimits
    limits is not an increasing two-element numeric vector. labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed. Remaining numeric/control values are validated when the runtime builds the node.
    -

    Example

    frame = labkit.ui.layout.panner("frame", "Frame", ...
    -"limits", [1 100], "value", 1, "step", 1);
    -assert(frame.props.step == 1)
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/panner.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/previewArea.html b/site/reference/api/labkit/ui/layout/previewArea.html deleted file mode 100644 index 44184c34d..000000000 --- a/site/reference/api/labkit/ui/layout/previewArea.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - -labkit.ui.layout.previewArea - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.previewArea

    -

    Create a workspace preview/axes area layout node.

    -

    Syntax

    -
    layout = labkit.ui.layout.previewArea(id, titleText) -layout = labkit.ui.layout.previewArea(id, titleText, Name=Value)
    -

    Inputs

    id
    Text scalar used to identify the preview. It must be a valid MATLAB variable name and unique within the workbench.
    titleText
    Text displayed in the preview panel title.
    -

    Name-Value Arguments

    layout
    Axes arrangement: "single", "pair", or "stack". Default: "single".
    count
    Positive integer number of axes. The default is 1 for single and 2 for pair or stack. axisIds, when supplied, determines the count.
    axisIds
    Cell array of valid MATLAB field names used to address each axes from presenters and services. Defaults to axis1, axis2, and so on.
    axisTitles
    Cell array of axes titles in axis order. A single axes defaults to titleText; multiple axes default to their axis IDs.
    xLabels
    Cell array of x-axis labels in axis order. Default: blank.
    yLabels
    Cell array of y-axis labels in axis order. Default: blank.
    columnWidths
    uigridlayout ColumnWidth cell array with one entry per axes in pair layout. Default: equal flexible widths.
    rowHeights
    uigridlayout RowHeight cell array with one entry per axes in stack layout. Default: equal flexible heights.
    scrollZoomAxes
    Cell array selecting wheel-zoom dimensions for each axes: "xy", "x", or "y". Invalid or missing entries use "xy".
    viewModes
    Nonempty list of user-facing modes. When supplied, a dropdown is shown above the axes.
    onModeChange
    Function handle called as onModeChange(control,event) when the mode changes. event.mode and event.value contain the selected text.
    -

    Outputs

    layout
    Scalar previewArea node with kind, id, props, children, and slots fields.
    -

    Description

    previewArea reserves one or more managed uiaxes in the workspace. Runtime renderers address the panel by preview ID and, when needed, an axis ID. Each axes receives the standard LabKit pop-out menu and wheel navigation.

    -

    Errors

    labkit:ui:layout:InvalidPreviewLayout or InvalidPreviewCount - layout or count is unsupported. labkit:ui:runtime:InvalidAxisId or DuplicateAxisId - axisIds contains an invalid or repeated identifier. labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed.

    -

    Example

    preview = labkit.ui.layout.previewArea("signals", "Signals", ...
    -"layout", "pair", "axisIds", {"input","output"}, ...
    -"xLabels", {"Time (s)","Time (s)"});
    -assert(numel(preview.props.axisIds) == 2)
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/previewArea.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/rangeField.html b/site/reference/api/labkit/ui/layout/rangeField.html deleted file mode 100644 index 51858cf9a..000000000 --- a/site/reference/api/labkit/ui/layout/rangeField.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - -labkit.ui.layout.rangeField - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.rangeField

    -

    Create a paired start/end or min/max field layout node.

    -

    Syntax

    -
    layout = labkit.ui.layout.rangeField(id, labelText) -layout = labkit.ui.layout.rangeField(id, labelText, Name=Value)
    -

    Inputs

    id
    Text scalar used to identify the range. It must be a valid MATLAB variable name and unique within the workbench.
    labelText
    Text displayed beside the two numeric fields.
    -

    Name-Value Arguments

    value
    Two-element numeric value [first second]. Default: [0 0].
    limits
    Two-element limits applied independently to both numeric fields.
    onChange
    Function handle called as onChange(control,event). event.value contains the current two-element range.
    debounceMs
    Delay before onChange runs, in milliseconds. Default: 500. Use 0 for immediate dispatch.
    Bind
    Runtime V2 path to a two-element project or session value.
    Event
    Action ID dispatched after a bound value is written. Requires Bind.
    -

    Outputs

    layout
    Scalar rangeField node with kind, id, props, children, and slots fields.
    -

    Description

    rangeField renders two adjacent numeric edit fields as one semantic value. Both edits report the complete two-element value. Runtime construction rejects values that do not contain exactly two elements.

    -

    Errors

    labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed. The constructor stores option values as data; invalid value/limits/callback combinations are rejected when the runtime builds the control.

    -

    Example

    window = labkit.ui.layout.rangeField( ...
    -"timeWindow", "Time window (s)", "value", [0 5], "limits", [0 Inf]);
    -assert(isequal(window.props.value, [0 5]))
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/rangeField.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/resultTable.html b/site/reference/api/labkit/ui/layout/resultTable.html deleted file mode 100644 index 34e0953c2..000000000 --- a/site/reference/api/labkit/ui/layout/resultTable.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - -labkit.ui.layout.resultTable - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.resultTable

    -

    Create a titled result table layout node.

    -

    Syntax

    -
    layout = labkit.ui.layout.resultTable(id, titleText) -layout = labkit.ui.layout.resultTable(id, titleText, Name=Value)
    -

    Inputs

    id
    Text scalar used to identify the table. It must be a valid MATLAB variable name and unique within the workbench.
    titleText
    Text displayed in the table panel title.
    -

    Name-Value Arguments

    columns
    Column names as a cell array. Default: {}.
    data
    Initial table, numeric/logical array, or cell data. Default: an empty cell array with one column per entry in columns.
    columnEditable
    Logical scalar or row vector passed to uitable.
    columnFormat
    Cell array of MATLAB uitable column formats.
    rowName
    Row labels. Default: {}, which hides MATLAB row numbers.
    onCellEdit
    Function handle called as onCellEdit(control,event) after an edit. event.value is the complete table data; event.indices, previousData, newData, and editData describe the edited cell.
    onSelectionChange
    Function handle called after a completed cell selection change. event.value is the complete data and event.indices identifies cells. Rapid intermediate selection changes are coalesced so the callback receives the latest completed range. A presenter may update the displayed Data, ColumnName, and RowName properties through the table control's presentation spec.
    -

    Outputs

    layout
    Scalar resultTable node with kind, id, props, children, and slots fields.
    -

    Description

    resultTable creates a titled uitable that can be placed in a section or the workspace. String and other displayable cell values are converted to text when the table is built. Programmatic data updates do not call onCellEdit.

    -

    Errors

    labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed. Table data, formats, editability, and callback values are validated by MATLAB when the runtime builds the table.

    -

    Example

    results = labkit.ui.layout.resultTable("summary", "Summary", ...
    -"columns", {"Metric","Value"}, ...
    -"data", {"Mean", 2.5});
    -assert(numel(results.props.columns) == 2)
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/resultTable.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/section.html b/site/reference/api/labkit/ui/layout/section.html deleted file mode 100644 index 7092f1a07..000000000 --- a/site/reference/api/labkit/ui/layout/section.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - -labkit.ui.layout.section - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.section

    -

    Create a titled control-section layout node.

    -

    Syntax

    -
    layout = labkit.ui.layout.section(id, titleText, children)
    -

    Inputs

    id
    Text scalar used to identify the section. It must be a valid MATLAB variable name and unique within the workbench.
    titleText
    Text displayed in the section header.
    children
    Cell row vector of field, rangeField, panner, action, group, filePanel, resultTable, statusPanel, or logPanel nodes. Default: {}. An empty section can be constructed but is rejected at launch.
    -

    Outputs

    layout
    Scalar section node with kind, id, props, children, and slots fields.
    -

    Description

    A section is the ordered block of controls shown within a control tab. Apps choose the title, child controls, and their order; the workbench chooses the concrete height, spacing, padding, and border presentation.

    -

    Errors

    labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed.

    -
    labkit:ui:layout:InvalidChildren
    children is not a cell row of scalar layout nodes. Empty or unsupported child sets are rejected at launch.
    -

    Example

    inputs = labkit.ui.layout.section("inputs", "Inputs", { ...
    -labkit.ui.layout.field("sampleName", "Sample name")});
    -assert(inputs.kind == "section")
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/section.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/statusPanel.html b/site/reference/api/labkit/ui/layout/statusPanel.html deleted file mode 100644 index b6c3d9c72..000000000 --- a/site/reference/api/labkit/ui/layout/statusPanel.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - -labkit.ui.layout.statusPanel - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.statusPanel

    -

    Create a read-only status/details panel layout node.

    -

    Syntax

    -
    layout = labkit.ui.layout.statusPanel(id, titleText) -layout = labkit.ui.layout.statusPanel(id, titleText, "value", lines)
    -

    Inputs

    id
    Text scalar used to identify the panel. It must be a valid MATLAB variable name and unique within the workbench.
    titleText
    Text displayed in the panel title.
    -

    Name-Value Arguments

    value
    Initial details as text, a string array, or cellstr. Default: "".
    -

    Outputs

    layout
    Scalar statusPanel node with kind, id, props, children, and slots fields.
    -

    Description

    statusPanel shows read-only summaries or details that the presenter may replace as app state changes. Unlike logPanel, it has no follow-latest controls. Use the workbench usage option for static workflow instructions.

    -

    Errors

    labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed. Text conversion and graphics compatibility of value are validated when the runtime builds the panel.

    -

    Example

    status = labkit.ui.layout.statusPanel( ...
    -"selectionStatus", "Selection", "value", "No file selected");
    -assert(status.kind == "statusPanel")
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/statusPanel.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/tab.html b/site/reference/api/labkit/ui/layout/tab.html deleted file mode 100644 index b804f2e52..000000000 --- a/site/reference/api/labkit/ui/layout/tab.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - -labkit.ui.layout.tab - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.tab

    -

    Create a LabKit control or workspace tab layout node.

    -

    Syntax

    -
    layout = labkit.ui.layout.tab(id, titleText, children)
    -

    Inputs

    id
    Text scalar used to identify the tab. It must be a valid MATLAB variable name and unique within the workbench.
    titleText
    Text displayed on the tab.
    children
    Cell row vector in display order. A control tab contains section nodes. A tab placed directly in workspace contains previewArea, resultTable, statusPanel, or logPanel nodes. Default: {}.
    -

    Outputs

    layout
    Scalar tab node with kind, id, props, children, and slots fields.
    -

    Description

    tab defines one selectable page. Add it to the controlTabs cell array for a left-side control page, or place two or more tab nodes directly in a workspace for user-selectable right-side pages. Runtime owns the native tab group, selection behavior, and page geometry.

    -

    Errors

    labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed.

    -
    labkit:ui:layout:InvalidChildren
    children is not a cell row of scalar layout nodes. Child kinds and empty-section rules are checked at launch.
    -

    Example

    setupTab = labkit.ui.layout.tab("setup", "Setup", { ...
    -labkit.ui.layout.section("inputs", "Inputs", { ...
    -labkit.ui.layout.field("name", "Name")})});
    -assert(setupTab.kind == "tab")
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/tab.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/workbench.html b/site/reference/api/labkit/ui/layout/workbench.html deleted file mode 100644 index c43c7565a..000000000 --- a/site/reference/api/labkit/ui/layout/workbench.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - -labkit.ui.layout.workbench - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.workbench

    -

    Create a declarative LabKit workbench layout.

    -

    Syntax

    -
    layout = labkit.ui.layout.workbench(id, titleText, "controlTabs", tabs, ... -"workspace", workspace) -layout = labkit.ui.layout.workbench(..., Name=Value)
    -

    Inputs

    id
    Text scalar used to identify the app layout. It must be a valid MATLAB variable name and unique within the layout tree.
    titleText
    Text used for the app figure title.
    -

    Required Name-Value Arguments

    controlTabs
    Nonempty cell row vector of tab nodes for the left control pane.
    workspace
    One workspace node for the right content pane.
    -

    Optional Name-Value Arguments

    usage
    Static workflow instructions as text, a string array, or cellstr. When nonempty, a read-only Usage section is appended to the first tab. Default: no usage section.
    usageTitle
    Character vector or scalar string used as the title of the generated usage section. Default: "Usage".
    -

    Outputs

    layout
    Scalar app node consumed by labkit.ui.runtime.create or by the Layout callback of labkit.ui.runtime.define.
    -

    Description

    workbench is the root of a declarative LabKit UI tree. Every node ID in the tree must be unique. Launch validation rejects missing tabs or workspace, invalid child types, empty sections, and app-owned pixel geometry such as position, height, padding, or pane width. The function constructs data only; it does not open a figure.

    -

    Errors

    labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed. Missing required slots, duplicate IDs, invalid child kinds, empty sections, and forbidden concrete geometry are rejected when runtime.define or runtime.create validates the full tree.

    -

    Example

    controls = labkit.ui.layout.tab("main", "Main", { ...
    -labkit.ui.layout.section("commands", "Commands", { ...
    -labkit.ui.layout.action("run", "Run", [])})});
    -content = labkit.ui.layout.workspace("workspace", "Preview", {});
    -appLayout = labkit.ui.layout.workbench("exampleApp", "Example App", ...
    -"controlTabs", {controls}, "workspace", content);
    -assert(appLayout.kind == "app")
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/workbench.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/layout/workspace.html b/site/reference/api/labkit/ui/layout/workspace.html deleted file mode 100644 index 8f99266ba..000000000 --- a/site/reference/api/labkit/ui/layout/workspace.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - -labkit.ui.layout.workspace - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.layout.workspace

    -

    Create a right-side LabKit workbench workspace layout node.

    -

    Syntax

    -
    layout = labkit.ui.layout.workspace(id, titleText, children)
    -

    Inputs

    id
    Text scalar used to identify the workspace. It must be a valid MATLAB variable name and unique within the workbench.
    titleText
    Text displayed above the right-side workspace.
    children
    Cell row vector of previewArea, resultTable, statusPanel, or logPanel nodes in display order. To expose multiple user-selectable workspace pages, supply two or more tab nodes instead. Default: {}.
    -

    Outputs

    layout
    Scalar workspace node with kind, id, props, children, and slots fields.
    -

    Description

    workspace defines the app's right-hand content area. Direct panel children form one vertical workspace. Two or more tab children form user-selectable workspace pages. The workbench assigns rows, page selection, and sizing; apps choose only semantic content and order.

    -

    Errors

    labkit:ui:layout:InvalidId, InvalidOptions, or InvalidOptionName - id or Name-value syntax is malformed.

    -
    labkit:ui:layout:InvalidChildren
    children is not a cell row of scalar layout nodes. Unsupported workspace child kinds are rejected at launch.
    -

    Example

    preview = labkit.ui.layout.workspace("workspace", "Preview", { ...
    -labkit.ui.layout.previewArea("image", "Image")});
    -assert(preview.kind == "workspace")
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+layout/workspace.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/plot/clampData.html b/site/reference/api/labkit/ui/plot/clampData.html deleted file mode 100644 index 464074b4c..000000000 --- a/site/reference/api/labkit/ui/plot/clampData.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - -labkit.ui.plot.clampData - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.plot.clampData

    -

    Keep data coordinates inside the visible axes box.

    -

    Syntax

    -
    xyOut = labkit.ui.plot.clampData(ax, xy) -xyOut = labkit.ui.plot.clampData(ax, xy, Name=Value)
    -

    Inputs

    ax
    Valid scalar MATLAB axes or uiaxes handle. Its XLim, YLim, XScale, YScale, XDir, and YDir properties define the visible box.
    xy
    N-by-2 numeric matrix of [x y] data coordinates.
    -

    Name-Value Arguments

    Padding
    Minimum distance from each axes edge, expressed as a fraction of the visible width or height. Values are limited to [0, 0.49]. Default: 0.04.
    -

    Outputs

    xy
    N-by-2 data coordinates, shown as xyOut in the usage syntax. Each point is moved only as far as needed to satisfy Padding.
    -

    Description

    clampData is useful for labels and annotations that must remain readable near an axes boundary. Conversion through normalized axes coordinates keeps the result visually consistent on logarithmic or reversed axes.

    -

    Errors

    labkit:ui:plot:InvalidAxes
    ax is not a valid scalar axes handle.
    labkit:ui:plot:InvalidPointPairs
    xy is not an N-by-2 numeric array. labkit:ui:plot:InvalidOptions or labkit:ui:plot:InvalidOption - Name-value arguments are malformed or unsupported.
    -

    Example

    fig = figure("Visible", "off");
    -cleanup = onCleanup(@() close(fig));
    -ax = axes(fig, "XLim", [0 10], "YLim", [0 20]);
    -xy = labkit.ui.plot.clampData(ax, [-2 25], "Padding", 0.1);
    -assert(isequal(xy, [1 18]))
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+plot/clampData.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/plot/clear.html b/site/reference/api/labkit/ui/plot/clear.html deleted file mode 100644 index 0851f6c65..000000000 --- a/site/reference/api/labkit/ui/plot/clear.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - -labkit.ui.plot.clear - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.plot.clear

    -

    Prepare an axes for an app-owned redraw.

    -

    Syntax

    -
    labkit.ui.plot.clear(ax) -labkit.ui.plot.clear(ax, Name=Value)
    -

    Inputs

    ax
    Valid scalar MATLAB axes or uiaxes handle to clear.
    -

    Name-Value Arguments

    ResetScale
    Logical value. true restores linear X/Y scales and automatic X/Y tick modes. false preserves those four properties. Default: false.
    ClearLegend
    Logical value. true turns the legend off. Default: true.
    -

    Outputs

    None.

    -

    Description

    clear deletes plotted children, clears LabKit's cached image home view, releases hold, and returns XLim, YLim, ZLim, and CLim to automatic mode. Labels and other axes decorations follow MATLAB cla behavior. Call it once at the start of a complete redraw, not when adding an overlay that should preserve the current zoom.

    -

    Errors

    labkit:ui:plot:InvalidAxes
    ax is not a valid scalar axes handle. labkit:ui:plot:InvalidOptions or labkit:ui:plot:InvalidOption - Name-value arguments are malformed or unsupported. MATLAB graphics errors raised while clearing a valid axes propagate to the caller.
    -

    Example

    fig = figure("Visible", "off");
    -cleanup = onCleanup(@() close(fig));
    -ax = axes(fig);
    -plot(ax, 1:3, [2 1 3]);
    -labkit.ui.plot.clear(ax, "ResetScale", true);
    -assert(isempty(ax.Children))
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+plot/clear.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/plot/fit.html b/site/reference/api/labkit/ui/plot/fit.html deleted file mode 100644 index c34803b84..000000000 --- a/site/reference/api/labkit/ui/plot/fit.html +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - -labkit.ui.plot.fit - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.plot.fit

    -

    Fit axes limits to finite plotted X/Y data.

    -

    Syntax

    -
    limits = labkit.ui.plot.fit(ax) -limits = labkit.ui.plot.fit(ax, graphicsHandles) -limits = labkit.ui.plot.fit(..., Name=Value)
    -

    Inputs

    ax
    Valid scalar MATLAB axes or uiaxes handle whose limits are changed.
    graphicsHandles
    Optional graphics handle array. Objects with numeric XData and YData contribute to the fitted range. When omitted, all current axes children are examined.
    -

    Name-Value Arguments

    Padding
    Nonnegative fractional padding added on each side of the data range. Default: 0.02. For logarithmic axes, padding is computed in base-10 logarithmic space.
    -

    Outputs

    limits
    Scalar struct with x and y fields. Each field contains the applied two-element limit, or [] when that dimension had no usable data and was returned to automatic limit mode.
    -

    Description

    fit ignores nonfinite XData and YData. Nonpositive values do not contribute to a logarithmic dimension. Supplying graphicsHandles lets an app exclude annotations such as reference lines from the fitted range.

    -

    Errors

    labkit:ui:plot:InvalidAxes
    ax is not a valid scalar axes handle. labkit:ui:plot:InvalidOptions or labkit:ui:plot:InvalidOption - Name-value arguments are malformed or unsupported. Invalid supplied graphics handles are ignored when they do not expose numeric XData/YData.
    -

    Example

    fig = figure("Visible", "off");
    -cleanup = onCleanup(@() close(fig));
    -ax = axes(fig);
    -h = plot(ax, [1 2 3], [10 20 15]);
    -xline(ax, 100);
    -limits = labkit.ui.plot.fit(ax, h, "Padding", 0);
    -assert(isequal(limits.x, [1 3]))
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+plot/fit.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/plot/fitCanvas.html b/site/reference/api/labkit/ui/plot/fitCanvas.html deleted file mode 100644 index 2c7917f74..000000000 --- a/site/reference/api/labkit/ui/plot/fitCanvas.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - -labkit.ui.plot.fitCanvas - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.plot.fitCanvas

    -

    Fit a preview axes into a fixed-aspect pixel frame.

    -

    Syntax

    -
    [applied, frame] = labkit.ui.plot.fitCanvas(ax, width, height) -[applied, frame] = labkit.ui.plot.fitCanvas(..., Name=Value)
    -

    Inputs

    ax
    UI axes whose parent is a uigridlayout created for a LabKit preview.
    width
    Positive finite source-canvas width in pixels.
    height
    Positive finite source-canvas height in pixels.
    -

    Name-Value Arguments

    margin
    Preferred empty margin around the frame in pixels. Default: 24.
    maxScale
    Largest allowed ratio between displayed and source dimensions. Default: 1, so the helper does not enlarge the source canvas.
    -

    Outputs

    applied
    true when the grid and axes positions were updated; false when the axes, parent grid, dimensions, or available space were unsuitable.
    frame
    Scalar struct with width, height, ratio, position, scale, and pixelPosition fields. It is an empty struct when applied is false.
    -

    Description

    fitCanvas centers the axes in the middle row and column of a three-by-three flexible grid and preserves the requested aspect ratio. It returns false instead of throwing when the host has not been laid out yet or is smaller than the minimum usable preview area.

    -

    Failure Behavior

    Invalid axes, host grids, source dimensions, or insufficient available space return applied=false and frame=struct(). Malformed or unsupported name-value arguments throw labkit:ui:plot:InvalidOptions or labkit:ui:plot:InvalidOption.

    -

    Typical Call

    [ok, frame] = labkit.ui.plot.fitCanvas(ax, 720, 540);
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+plot/fitCanvas.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/plot/message.html b/site/reference/api/labkit/ui/plot/message.html deleted file mode 100644 index e6f11a7a3..000000000 --- a/site/reference/api/labkit/ui/plot/message.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - -labkit.ui.plot.message - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.plot.message

    -

    Show a centered empty-state message in an axes.

    -

    Syntax

    -
    hText = labkit.ui.plot.message(ax, message) -hText = labkit.ui.plot.message(ax, message, Name=Value)
    -

    Inputs

    ax
    Valid scalar MATLAB axes or uiaxes handle.
    message
    User-visible text scalar displayed at the axes center.
    -

    Name-Value Arguments

    Title
    Axes title text. Default: "".
    Color
    MATLAB color value for the message text. Default: [0.30 0.30 0.30].
    -

    Outputs

    hText
    MATLAB text object containing the message.
    -

    Description

    message clears the axes, resets it to linear unit limits [0 1], removes ticks, and draws non-pickable text. Use it for an empty preview, loading prompt, or unavailable result. Because it performs a complete clear, it is not an overlay operation and does not preserve the previous zoom.

    -

    Errors

    labkit:ui:plot:InvalidAxes
    ax is not a valid scalar axes handle. labkit:ui:plot:InvalidOptions or labkit:ui:plot:InvalidOption - Name-value arguments are malformed or unsupported. Invalid MATLAB text or color values propagate their originating graphics error.
    -

    Example

    fig = figure("Visible", "off");
    -cleanup = onCleanup(@() close(fig));
    -ax = axes(fig);
    -h = labkit.ui.plot.message(ax, "No data", "Title", "Preview");
    -assert(string(h.String) == "No data")
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+plot/message.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/plot/offsetData.html b/site/reference/api/labkit/ui/plot/offsetData.html deleted file mode 100644 index 320676b28..000000000 --- a/site/reference/api/labkit/ui/plot/offsetData.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - -labkit.ui.plot.offsetData - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.plot.offsetData

    -

    Move data coordinates by normalized axes fractions.

    -

    Syntax

    -
    xyOut = labkit.ui.plot.offsetData(ax, xy, offsetFraction)
    -

    Inputs

    ax
    Valid scalar MATLAB axes or uiaxes handle. Its current limits, scales, and directions define the coordinate conversion.
    xy
    N-by-2 numeric matrix of [x y] data coordinates.
    offsetFraction
    1-by-2 or N-by-2 axes-fraction offsets. One row is reused for every point; otherwise the row count must match xy. For example, [0.03 -0.04] moves a label slightly right and down in visual axes space, including on log or reversed axes.
    -

    Outputs

    xy
    N-by-2 offset coordinates in data units, shown as xyOut in the usage syntax. Values are not clamped to the visible axes box.
    -

    Description

    offsetData expresses annotation spacing in visual axes fractions instead of data units. This keeps the apparent offset stable as data ranges change and correctly handles logarithmic and reversed axes.

    -

    Errors

    labkit:ui:plot:InvalidAxes
    ax is not a valid scalar axes handle.
    labkit:ui:plot:InvalidPointPairs
    xy is not an N-by-2 numeric array.
    labkit:ui:plot:InvalidPointOffsets
    offsetFraction is neither one row nor one row per point.
    -

    Example

    fig = figure("Visible", "off");
    -cleanup = onCleanup(@() close(fig));
    -ax = axes(fig, "XLim", [0 10], "YLim", [0 20]);
    -xy = labkit.ui.plot.offsetData(ax, [5 10], [0.1 -0.1]);
    -assert(isequal(xy, [6 8]))
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+plot/offsetData.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/runtime/create.html b/site/reference/api/labkit/ui/runtime/create.html deleted file mode 100644 index 2eadc0729..000000000 --- a/site/reference/api/labkit/ui/runtime/create.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - -labkit.ui.runtime.create - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceRuntime And Lifecycle

    -

    labkit.ui.runtime.create

    -

    Build a LabKit workbench from a declarative layout.

    -

    Syntax

    -
    ui = labkit.ui.runtime.create(layout) -ui = labkit.ui.runtime.create(layout, "debug", debugContext)
    -

    Inputs

    layout
    Scalar layout tree returned by labkit.ui.layout.workbench. Control IDs must be unique throughout the tree.
    -

    Name-Value Arguments

    debug
    labkit.ui.debug context used to instrument the new figure and show trace messages in the first log panel. The default is no debug context.
    -

    Outputs

    ui
    Struct containing the figure, shell panels, controls, tabs, workspace, source layout, and debug context. Controls and sections are indexed by their layout IDs.
    -

    Description

    create builds the complete workbench immediately and returns its handle registry. Use this lower-level function when code already has a finished layout tree. Most apps should use labkit.ui.runtime.launch, which also owns project state, actions, presentation, startup, and persistence.

    -

    Errors

    labkit:ui:runtime:InvalidOptions
    Name-value arguments are not paired. Layout-tree validation errors identify invalid IDs, duplicate IDs, missing workbench slots, unsupported child kinds, empty sections, or forbidden concrete geometry. MATLAB graphics construction errors propagate after validation; a partially created figure is cleaned up by the runtime.
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+runtime/create.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/runtime/defaultOutputFolder.html b/site/reference/api/labkit/ui/runtime/defaultOutputFolder.html deleted file mode 100644 index c2f899276..000000000 --- a/site/reference/api/labkit/ui/runtime/defaultOutputFolder.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - -labkit.ui.runtime.defaultOutputFolder - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceRuntime And Lifecycle

    -

    labkit.ui.runtime.defaultOutputFolder

    -

    Return a source-adjacent app output folder.

    -

    Syntax

    -
    folder = labkit.ui.runtime.defaultOutputFolder(sourcePaths, subfolderName) -folder = labkit.ui.runtime.defaultOutputFolder(sourcePaths, subfolderName, fallbackFolder)
    -

    Inputs

    sourcePaths
    File path, folder path, string array, or cell array of paths. The first nonempty path selects the base folder. The default is empty.
    subfolderName
    Name of the output folder to create. Characters that are illegal in common file systems are replaced with underscores. The default is "labkit_output".
    fallbackFolder
    Existing folder to use when sourcePaths does not identify an existing source location. The default is LabKit's remembered output location or the current user's home folder.
    -

    Outputs

    folder
    Character vector naming an existing output folder.
    -

    Description

    When the first path is a file, the new subfolder is created beside that file. When it is a folder, the subfolder is created inside it. If the source cannot be resolved or the new folder cannot be created, the function returns a safe existing fallback folder instead of failing because the requested output folder could not be created.

    -

    Failure Behavior

    Missing source locations, unsafe names, and mkdir failures fall back to an existing remembered output folder or user home directory. Input values must be convertible to text; incompatible MATLAB containers may raise the originating string conversion error before fallback selection.

    -

    Typical Call

    outputFolder = labkit.ui.runtime.defaultOutputFolder( ...
    -importedFiles, "Processed Results", pwd);
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+runtime/defaultOutputFolder.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/runtime/define.html b/site/reference/api/labkit/ui/runtime/define.html deleted file mode 100644 index 107aaa1ee..000000000 --- a/site/reference/api/labkit/ui/runtime/define.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - -labkit.ui.runtime.define - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceRuntime And Lifecycle

    -

    labkit.ui.runtime.define

    -

    Create a LabKit declarative app runtime definition.

    -

    Syntax

    -
    def = labkit.ui.runtime.define("Command", command, "Id", id, ... -"Title", title, "Family", family, "AppVersion", version, ... -"Updated", date, "Requirements", requirements, ... -"Layout", layoutFcn) -def = labkit.ui.runtime.define(..., Name=Value)
    -

    Outputs

    def
    Validated scalar Runtime V2 definition accepted by labkit.ui.runtime.launch.
    -

    Description

    define describes an app without creating a figure. The definition connects durable project data, temporary session data, a declarative layout, event actions, and view presentation. Invalid definitions fail here, before the app begins startup.

    -

    Required Name-Value Arguments

    Command
    Public MATLAB function used to launch the App, for example "labkit_Example_app". Single-definition launch uses this value for request errors and version metadata.
    Id
    Stable scalar text identifier for saved projects, recovery storage, results, and diagnostics. It starts with an ASCII letter and contains only letters, digits, underscore, hyphen, or period. Treat it as a permanent compatibility identifier after projects have been saved.
    Title
    Text shown in the app window title.
    Family
    Nonempty character vector or scalar string naming the reader-facing App family used by the launcher and documentation.
    AppVersion
    Semantic App version in X.Y.Z form. This versions the App product, not the Runtime V2 project payload.
    Updated
    Last product-change date in YYYY-MM-DD form.
    Requirements
    labkit.contract.requirements result declaring compatible reusable LabKit facades.
    Layout
    Function handle returning a labkit.ui.layout.workbench tree. It may accept no inputs, callbacks, or callbacks and initial state: layoutFcn(), layoutFcn(callbacks), or layoutFcn(callbacks,state).
    -

    Optional Name-Value Arguments

    DisplayName
    Short product name shown by the launcher. Default: Title.
    Project
    Scalar struct that owns a durable project schema. Omit it for an empty version-1 project with no App validator or migrations. Supplied project fields are listed under Project Fields.
    CreateSession
    Function handle returning transient session state. It may accept no inputs or the newly created project. Missing selection, workflow, view, and cache fields are added automatically. Default: an empty session struct.
    Actions
    Scalar struct whose field names are event IDs and whose values are functions of the form state = action(state,event,services). Default: struct(), which is valid for a static App.
    Present
    Function handle of the form view = present(state). The returned presenter model supplies control values, lists, tables, text, plots, and interaction models for one committed view. Default: an empty presenter model, which preserves values declared by a static layout.
    Renderers
    Scalar struct of renderer functions keyed by renderer ID. A renderer may accept no inputs, the presented model, or the target axes and model. Default: struct().
    Start
    Action ID or action function queued after the first view appears. A function uses the normal action signature. Default: no startup action.
    DebugSample
    Function called as pack = writer(debugContext) after a debug launch. It is not called during normal launch. Default: none.
    Utilities
    Scalar struct controlling framework menus. Fields are Visible, Plot, Screenshot, and State. Visible defaults to true. Plot defaults to true when the layout contains a preview area and false otherwise. Screenshot defaults to true. State accepts "on" or "off" and defaults to "on".
    -

    Project Fields

    Version
    Positive integer payload version. Version 1 has no migrations.
    Create
    Function handle project = createProject(). Missing inputs, parameters, annotations, results, and extensions fields are added.
    Validate
    Function that checks a complete project. It may throw on invalid data or return a logical scalar.
    Migrate
    Function project = migrate(project,fromVersion). The runtime calls it once for each missing version and validates every returned payload. Required when Version is greater than 1. Default: none.
    LegacyImports
    Scalar struct mapping MAT-file variable names to import functions. An importer is called as project = import(value) or [project,resume] = import(value), where value is the named MAT-file variable. Imported formats are read-only; new saves use labkitProject.
    CreateResume
    Function resume = createResume(session,project) for optional lightweight view/workflow state saved with the project.
    ApplyResume
    Function session = applyResume(session,resume,project) used after a fresh session is created during load.
    RelinkSources
    Function project = relink(project,unresolved,projectFile) used when required external files cannot be found. Returning [] cancels the load.
    -

    Action State And Event Fields

    state.project
    Durable project data. Changes mark the document dirty and are included in the next project save.
    state.session
    Transient selection, workflow, view, and cache data. It is recreated when a project opens unless resume callbacks preserve a small part of it.
    event.id
    Action ID that selected the handler.
    event.source
    Event origin, such as "user", "service", "startup", or "interaction".
    event.target
    ID of the control, interaction, or runtime target.
    event.value
    Value supplied by the control, interaction, or dispatch call.
    event.meta
    Scalar struct containing source-specific details. For UI events, meta.original contains the layout event without runtime handles.
    -

    Service Fields

    services.figure
    Owning app figure, for example as the parent of a custom dialog.
    services.debug
    Debug context for trace messages and diagnostic reports.
    services.request
    Read-only launch request returned by RequestAdapter.
    services.dispatch
    Queues another action. Call dispatch(id,value) or pass a scalar event struct. Nested actions run after the current action.
    services.workflow
    workflow.log(state,message) appends a visible workflow message and returns the updated state.
    services.diagnostics
    diagnostics.report(context,exception) records a caught exception in the runtime debug report.
    services.events
    Helpers entries(event,field), paths(event,field), and indices(event,field,count) decode values from UI event metadata.
    services.dialogs
    App-parented alert, choice, inputFile, inputFolder, outputFile, and outputFolder dialogs, plus defaultFolder and defaultOutputFolder path helpers. File and folder selectors return the selected string path and a logical cancelled flag.
    services.project
    newState creates a fresh canonical project and session through the current definition. sourceRecord, upsertSource, and reconcileSources create external-file records understood by project save/load. Apps read current paths with labkit.ui.runtime.sourcePaths. Each save rebases their relative paths from its actual destination; saveState saves a named project, while saveAutosave(state) immediately writes the framework-managed recovery copy. saveAutosave(state,filepath) writes the same recovery envelope to an app-determined path. Neither form prompts for a path or changes named-project ownership.
    services.previews
    previews.axes(previewId,axisId) returns axes owned by a declared preview area.
    services.resources
    set, get, remove, and clearScope manage resources with optional cleanup functions at event, interaction, or figure scope. set replaces and disposes an existing resource with the same scope and id; choose distinct ids for resources that must coexist.
    services.results
    results.emptyOutputs creates the canonical empty output array; results.output creates one validated manifest output; results.writeManifest writes the app's result manifest.
    -

    Action Processing

    Actions are processed in FIFO order. After an action returns, the runtime validates the complete state and presents the new view as one transaction. If the action, validation, or presentation throws, the previous state and view are restored and the error is rethrown. An action with no output is allowed for side effects, but it cannot change value-based project or session state.

    -

    Errors

    labkit:ui:runtime:InvalidDefinitionOptions
    Name-value arguments are not paired.
    labkit:ui:runtime:MissingDefinitionField
    A required definition field is absent.
    labkit:ui:runtime:InvalidDefinition
    Product metadata, requirements, project specification, callbacks, actions, utilities, or startup IDs do not satisfy the Runtime V2 contract. Layout callback output is validated later when launch or create builds the workbench.
    -

    Typical Call

    def = labkit.ui.runtime.define( ...
    -"Command", "labkit_ExampleViewer_app", ...
    -"Id", "org.example.viewer", "Title", "Example Viewer", ...
    -"Family", "Examples", "AppVersion", "1.0.0", ...
    -"Updated", "2026-07-16", ...
    -"Requirements", labkit.contract.requirements("ui", ">=7 <8"), ...
    -"Layout", @buildStaticLayout);
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+runtime/define.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/runtime/emptySourceRecords.html b/site/reference/api/labkit/ui/runtime/emptySourceRecords.html deleted file mode 100644 index be97b4375..000000000 --- a/site/reference/api/labkit/ui/runtime/emptySourceRecords.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - -labkit.ui.runtime.emptySourceRecords - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceRuntime And Lifecycle

    -

    labkit.ui.runtime.emptySourceRecords

    -

    Create an empty Runtime V2 source array.

    -

    Syntax

    -
    sources = labkit.ui.runtime.emptySourceRecords()
    -

    Outputs

    sources
    0-by-1 struct array with id, required, role, and a runtime-owned portable reference.
    -

    Source Record Fields

    id
    Stable source identifier chosen by the app.
    required
    Logical value indicating whether project load must resolve the file before committing the project.
    role
    App-defined description of how the source is used.
    reference
    Runtime-owned portable reference. Apps obtain populated records from injected services.project operations and read resolved paths through labkit.ui.runtime.sourcePaths rather than its fields.
    -

    Description

    Use this value to initialize project.inputs.sources when a new project has no external files. Starting with the expected empty shape avoids struct assignment errors when source records are added later.

    -

    Failure Behavior

    The function accepts no caller input and performs no file-system access. It returns the fixed Runtime V2 empty source schema without a recoverable failure state.

    -

    Example

    project.inputs = struct( ...
    -"sources", labkit.ui.runtime.emptySourceRecords());
    -assert(isempty(project.inputs.sources))
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+runtime/emptySourceRecords.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/runtime/launch.html b/site/reference/api/labkit/ui/runtime/launch.html deleted file mode 100644 index 85a410a20..000000000 --- a/site/reference/api/labkit/ui/runtime/launch.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - -labkit.ui.runtime.launch - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceRuntime And Lifecycle

    -

    labkit.ui.runtime.launch

    -

    Dispatch requests and launch a LabKit runtime definition.

    -

    Syntax

    -
    fig = labkit.ui.runtime.launch(definitionFcn, varargin{:}) -[fig, debug] = labkit.ui.runtime.launch(..., "debug") -requirements = labkit.ui.runtime.launch(..., "requirements") -version = labkit.ui.runtime.launch(..., "version") -fig = labkit.ui.runtime.launch(..., "RequestAdapter", adapter, args{:})
    -

    Inputs

    definitionFcn
    Function handle returning a definition created by define.
    -

    Outputs

    fig
    App figure for normal and debug launches.
    debug
    Debug context returned only when the request is "debug".
    requirements
    Requirements metadata for a "requirements" request.
    version
    Version metadata for a "version" request.
    -

    Description

    launch checks product requirements, creates project and session state, builds the layout, presents the first view, and then queues the definition's Start action. The startup window reports these phases until the app is ready. A debug launch additionally enables tracing and queues DebugSample. The "requirements" and "version" requests return metadata without building a GUI.

    -

    Request Adapter

    Apps that accept typed entry-point arguments can pass "RequestAdapter", adapter before those arguments. MATLAB calls [request,dispatchArgs] = adapter(args), where args is a cell array. request must be a scalar struct and is available to actions as services.request; dispatchArgs must be a cell array containing a normal runtime request such as {} or {"debug"}.

    -

    Errors

    labkit:ui:runtime:InvalidLaunchFactory
    definitionFcn is not a function handle, or retired requirements/version factories were passed after the definition factory.
    labkit:ui:runtime:MissingProductMetadata
    The definition does not provide its complete product metadata and Requirements.
    labkit:ui:runtime:InvalidProductMetadata
    AppVersion or Updated does not use the documented semantic-version or ISO-date form.
    -

    Typical Call

    fig = labkit.ui.runtime.launch(@appDefinition);
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+runtime/launch.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/runtime/loadState.html b/site/reference/api/labkit/ui/runtime/loadState.html deleted file mode 100644 index b35fc5b58..000000000 --- a/site/reference/api/labkit/ui/runtime/loadState.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - -labkit.ui.runtime.loadState - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceRuntime And Lifecycle

    -

    labkit.ui.runtime.loadState

    -

    Load a compatible Runtime V2 project or declared legacy import.

    -

    Syntax

    -
    filepath = labkit.ui.runtime.loadState(fig) -filepath = labkit.ui.runtime.loadState(fig, filepath)
    -

    Inputs

    fig
    Live app figure created by labkit.ui.runtime.launch.
    filepath
    MAT-file to load. When omitted, MATLAB opens a file-selection dialog.
    -

    Outputs

    filepath
    Selected or supplied path as a string scalar, or "" when the user cancels the dialog.
    -

    Description

    loadState accepts a current labkitProject envelope, an older Runtime V2 snapshot, or a MAT-file variable named in Project.LegacyImports. Current payloads are migrated one version at a time, validated, and checked for required source files. When automatic source resolution fails, the runtime identifies each missing file and lets the user locate it or cancel. A fresh session is then created and optional resume data is applied. The live app changes only after the complete candidate and its first presentation succeed; an error or cancellation leaves the previous project and view intact. Relinked or migrated documents open as unsaved work. Saving them writes the current labkitProject format rather than the imported old format.

    -

    Errors

    labkit:ui:runtime:ProjectLoadCancelled
    Required-source relinking was cancelled. The live project remains unchanged. Project-format, validation, migration, source-decoding, and presentation errors are rethrown after the Runtime records diagnostic context. The live project remains unchanged.
    -

    Typical Call

    loadedFile = labkit.ui.runtime.loadState(fig, "analysis.project.mat");
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+runtime/loadState.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/runtime/saveState.html b/site/reference/api/labkit/ui/runtime/saveState.html deleted file mode 100644 index 629557e72..000000000 --- a/site/reference/api/labkit/ui/runtime/saveState.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - -labkit.ui.runtime.saveState - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceRuntime And Lifecycle

    -

    labkit.ui.runtime.saveState

    -

    Save a Runtime V2 project to a MAT file.

    -

    Syntax

    -
    filepath = labkit.ui.runtime.saveState(fig) -filepath = labkit.ui.runtime.saveState(fig, filepath)
    -

    Inputs

    fig
    Live app figure created by labkit.ui.runtime.launch.
    filepath
    Destination MAT-file. When omitted for an opened project, its current path is reused. When omitted for a new project, MATLAB opens a save dialog. The parent folder must already exist.
    -

    Outputs

    filepath
    Selected or supplied path as a string scalar, or "" when the user cancels the dialog.
    -

    Description

    saveState writes one variable named labkitProject. The envelope contains durable project data, schema and producer versions, source references, document identity and revision information, and optional resume data. Session caches and live graphics handles are not saved. Unknown additive envelope fields from a previously loaded project are preserved.

    -

    The file is first written and verified at a temporary path in the same folder. It replaces the destination only after the read-back comparison succeeds, so a failure before replacement leaves an existing project file unchanged. Save without an explicit filepath reuses an opened document's path, including a migrated old project, and asks for a path only for a new unsaved document. Source relativePath fields are recalculated from the actual destination before serialization; additive reference fields are preserved. After a successful save the app records the path, clears its dirty flag, and updates the window title.

    -

    Failure Behavior

    Cancelling the save dialog returns "". labkit:ui:runtime:MissingRuntime is thrown when fig is not a live Runtime V2 figure. labkit:ui:runtime:ProjectWriteFailed is thrown when temporary writing, verification, the pre-replace hook, or atomic replacement fails. The previous destination and in-memory document ownership remain unchanged. Project canonicalization or App resume callbacks may propagate their validation error before any destination is replaced.

    -

    Typical Call

    savedFile = labkit.ui.runtime.saveState(fig, "analysis.project.mat");
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+runtime/saveState.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/runtime/sourcePaths.html b/site/reference/api/labkit/ui/runtime/sourcePaths.html deleted file mode 100644 index 49c5efa9e..000000000 --- a/site/reference/api/labkit/ui/runtime/sourcePaths.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - -labkit.ui.runtime.sourcePaths - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceRuntime And Lifecycle

    -

    labkit.ui.runtime.sourcePaths

    -

    Read resolved paths from Runtime V2 source records.

    -

    Syntax

    -
    paths = labkit.ui.runtime.sourcePaths(sources) -paths = labkit.ui.runtime.sourcePaths(sources, ids)
    -

    Inputs

    sources
    Runtime V2 source struct array. Apps normally initialize an empty collection with labkit.ui.runtime.emptySourceRecords and add records through labkit.ui.runtime.sourceRecord or the equivalent injected project services.
    ids
    Optional source ID or collection of source IDs. Requested IDs are returned in the supplied order. An ID that has not been added yet returns an empty string in that position. When omitted, paths follow source-record order.
    -

    Outputs

    paths
    Column string array containing each source's current resolved path. An empty source collection or empty ids input returns a 0-by-1 string array.
    -

    Description

    Source IDs, roles, and required flags are App-facing project data. The nested portable-reference representation is owned by Runtime V2 and may evolve independently. Use sourcePaths wherever App code needs to read a file, compare selected files, build a session cache, or present a path.

    -

    Errors

    labkit:ui:runtime:InvalidSourceRecords
    A source record, requested ID, or runtime-owned reference is malformed.
    -

    Example

    sources = labkit.ui.runtime.emptySourceRecords();
    -assert(isempty(labkit.ui.runtime.sourcePaths(sources)))
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+runtime/sourcePaths.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/runtime/sourceRecord.html b/site/reference/api/labkit/ui/runtime/sourceRecord.html deleted file mode 100644 index 779c44326..000000000 --- a/site/reference/api/labkit/ui/runtime/sourceRecord.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - -labkit.ui.runtime.sourceRecord - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceRuntime And Lifecycle

    -

    labkit.ui.runtime.sourceRecord

    -

    Create one canonical Runtime V2 external-source record.

    -

    Syntax

    -
    source = labkit.ui.runtime.sourceRecord(id, role, filepath) -source = labkit.ui.runtime.sourceRecord(id, role, reference) -source = labkit.ui.runtime.sourceRecord(id, role, sourceValue, required)
    -

    Inputs

    id
    Nonempty scalar text identifying this source within one App project. The ID is App-owned, stable across saves, and unique in the project's source collection. It is not derived from the filename.
    role
    Nonempty scalar text describing the source's App-owned semantic role, such as "referenceImage" or "numericTrace".
    sourceValue
    Either a nonempty scalar file path as char or string, or an existing portable-reference scalar struct supplied by a legacy project importer. Runtime V2 validates and owns the resulting reference shape. App code must preserve an existing reference as an opaque value and must not read or construct its nested fields.
    required
    Optional scalar logical flag. True means project loading must resolve this source before committing the loaded state. Default: true.
    -

    Outputs

    source
    Scalar struct with stable App-facing id, required, and role fields plus a runtime-owned reference field. Preserve reference but do not read or construct its nested fields; use sourcePaths to obtain the current resolved path.
    -

    Description

    Use the path form in project creation, migration, and tests. The reference form lets a legacy importer preserve a portable reference without copying Runtime-private field construction into the App. Runtime action handlers may equivalently call the injected services.project.sourceRecord service. Both entry points produce the same canonical record.

    -

    Errors

    labkit:ui:runtime:InvalidSourceRecords
    An ID, role, source value, or required value is empty, nonscalar, malformed, or has the wrong type.
    -

    Example

    source = labkit.ui.runtime.sourceRecord( ...
    -"reference", "referenceImage", "reference.tif");
    -assert(labkit.ui.runtime.sourcePaths(source) == "reference.tif")
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/+runtime/sourceRecord.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/ui/version.html b/site/reference/api/labkit/ui/version.html deleted file mode 100644 index 416a630ff..000000000 --- a/site/reference/api/labkit/ui/version.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - -labkit.ui.version - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    API ReferenceLabKit App Framework

    -

    labkit.ui.version

    -

    Return the LabKit UI facade contract version.

    -

    Syntax

    -
    info = labkit.ui.version()
    -

    Description

    Reports the semantic version and compatibility range of the public labkit.ui framework contract. Apps declare this range through labkit.contract.requirements; it is independent of any App product or saved-project schema version.

    -

    Inputs

    None.

    -

    Outputs

    info
    Scalar structure returned by labkit.contract.versionInfo with name, facade, current, compatible, status, and notes fields.
    -

    Failure Behavior

    The function accepts no caller input. Invalid embedded facade metadata raises labkit:contract:InvalidVersionInfo; released metadata is validated by the contract test suite.

    -

    Example

    info = labkit.ui.version();
    -assert(startsWith(info.current, "7."))
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+ui/version.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/index.html b/site/reference/index.html index 9cf670f02..8bfbfe601 100644 --- a/site/reference/index.html +++ b/site/reference/index.html @@ -18,7 +18,7 @@
    - +

    reference

    API Reference

    @@ -28,18 +28,22 @@

    Find The Right Layer

    Module guides explain scope, data models, normal call sequences, algorithms, and representative examples. Function pages are the source for exact callable contracts. Files in private/ directories and App helpers without a complete public help contract are not supported entry points.

    Compatibility

    Each reusable library publishes a version() result. Apps declare compatible library ranges in the Requirements field of their single definition.m contract. A function documented here is public within that library version; compatibility across a breaking version is not implied.

    -

    The tables below are generated from current MATLAB source. Each entry links to the current function help.

    Browse By Module

    Choose a function to open its syntax, arguments, outputs, behavior, source, and related APIs.

    labkit.biosignal

    Recording import, channel access, filtering, events, templates, and measurements.

    FunctionPurpose
    labkit.biosignal.buildTemplateBuild a representative segment template.
    labkit.biosignal.compareGroupsSummarize groups and compute pairwise Welch comparisons.
    labkit.biosignal.cropSignalReturn a signal clipped to a time range in seconds.
    labkit.biosignal.defaultEcgPeakOptionsReturn documented defaults for ECG/QRS peak detection.
    labkit.biosignal.detectEcgPeaksDetect ECG/QRS peaks as event anchors.
    labkit.biosignal.filterSignalApply a zero-phase FFT-domain filter to a biosignal.
    labkit.biosignal.getChannelReturn one signal from a recording by display name or index.
    labkit.biosignal.listChannelsReturn display names for all channels in a recording.
    labkit.biosignal.measureSegmentsMeasure generic template-residual segment quality.
    labkit.biosignal.readRecordingRead a biosignal file into a standard recording struct.
    labkit.biosignal.segmentByEventsExtract fixed windows around event anchors.
    labkit.biosignal.versionReturn the LabKit biosignal facade contract version.
    +

    The tables below are generated from current MATLAB source. Each entry links to the current function help.

    Browse By Module

    Choose a function to open its syntax, arguments, outputs, behavior, source, and related APIs.

    App SDK Core (labkit.app)

    App identity, launch, callback capabilities, and facade version metadata.

    FunctionPurpose
    labkit.app.CallbackContextProvide declared App-neutral runtime capabilities.
    labkit.app.DefinitionCompile and launch one immutable App SDK contract.
    labkit.app.versionReturn the LabKit App SDK facade contract version.
    +

    App SDK Diagnostics (labkit.app.diagnostic)

    Sanitized standard and verbose runtime diagnostic contracts.

    FunctionPurpose
    labkit.app.diagnostic.ArtifactDescribe one anonymous diagnostic-sample artifact.
    labkit.app.diagnostic.OptionsConfigure one App SDK diagnostic session.
    labkit.app.diagnostic.SampleContextProvide bounded folders for anonymous debug samples.
    labkit.app.diagnostic.SamplePackDescribe one typed anonymous App reproduction scenario.
    +

    App SDK Dialogs (labkit.app.dialog)

    Typed outcomes for native user-choice and path dialogs.

    FunctionPurpose
    labkit.app.dialog.ChoiceRepresent a typed dialog choice or cancellation.
    +

    App SDK Events (labkit.app.event)

    Typed semantic callback payloads independent of native controls.

    FunctionPurpose
    labkit.app.event.IntervalScrollDescribe one normalized interval scroll gesture.
    labkit.app.event.ListSelectionDescribe selected file or list item identities.
    labkit.app.event.TableCellEditDescribe one validated table-cell edit signal.
    labkit.app.event.TableCellSelectionDescribe selected cells in a semantic data table.
    +

    App SDK Interactions (labkit.app.interaction)

    Managed plot gestures, editors, and typed interaction payloads.

    FunctionPurpose
    labkit.app.interaction.anchorPathDeclare an editable open or closed path on one plot axis.
    labkit.app.interaction.interpolateAnchorPathBuild a visible path through image anchor points.
    labkit.app.interaction.intervalDeclare an editable one-dimensional plot interval.
    labkit.app.interaction.pairedAnchorsDeclare matching editable points across plot axes.
    labkit.app.interaction.pointSlotsDeclare a fixed set of editable labeled point positions.
    labkit.app.interaction.rectangleDeclare an editable rectangular plot region.
    labkit.app.interaction.regionSelectionDeclare a transient click-or-drag region gesture.
    labkit.app.interaction.scaleBarGeometryCompute serializable image scale-bar overlay geometry.
    labkit.app.interaction.scaleCalibrationConvert a known image distance into pixels per unit.
    labkit.app.interaction.scaleReferenceDeclare an editable two-point scale reference.
    +

    App SDK Layout (labkit.app.layout)

    Semantic controls, containers, workspaces, callbacks, and renderers.

    FunctionPurpose
    labkit.app.layout.buttonAdd a push button with one explicit pressed callback.
    labkit.app.layout.dataTableAdd a tabular data display with optional editing and selection.
    labkit.app.layout.fieldAdd a text, numeric, choice, or logical input field.
    labkit.app.layout.fileListAdd file selection and portable-source controls.
    labkit.app.layout.groupArrange related child elements without a titled boundary.
    labkit.app.layout.logPanelAdd a text display for App log messages.
    labkit.app.layout.plotAreaAdd one or more axes rendered by an App-owned renderer.
    labkit.app.layout.rangeFieldAdd an input for a numeric lower and upper bound.
    labkit.app.layout.sectionArrange related child elements under a visible title.
    labkit.app.layout.sliderAdd a bounded numeric slider.
    labkit.app.layout.statusPanelAdd a text display for current App status.
    labkit.app.layout.tabAdd a named tab containing related child elements.
    labkit.app.layout.workbenchAssemble controls and a central workspace into the root layout.
    labkit.app.layout.workspaceDefine the App's central working area and optional pages.
    +

    App SDK Plot Mechanics (labkit.app.plot)

    Domain-neutral axes clearing, fitting, messages, and annotation mechanics.

    FunctionPurpose
    labkit.app.plot.clampPointToAxesKeep data coordinates inside the visible axes box.
    labkit.app.plot.clearAxesPrepare an axes for an app-owned redraw.
    labkit.app.plot.enablePopoutAdd a standalone-figure action to an axes context menu.
    labkit.app.plot.fitAxesToGraphicsFit axes limits to finite plotted X/Y data.
    labkit.app.plot.fitCanvasToSourceFit a preview axes into a fixed-aspect pixel frame.
    labkit.app.plot.offsetPointByAxesFractionMove data coordinates by normalized axes fractions.
    labkit.app.plot.showMessageShow a centered empty-state message in an axes.
    +

    App SDK Projects (labkit.app.project)

    Durable project schemas, portable sources, and synthetic sample contracts.

    FunctionPurpose
    labkit.app.project.SchemaDeclare one durable App project contract.
    labkit.app.project.emptySourceRecordsCreate an empty portable source collection.
    labkit.app.project.sourceRecordCreate a portable source value during project migration.
    +

    App SDK Results (labkit.app.result)

    Validated result files and package manifest requests.

    FunctionPurpose
    labkit.app.result.FileDeclare one validated result-package output.
    labkit.app.result.PackageDeclare one App-owned result manifest request.
    +

    App SDK Views (labkit.app.view)

    Complete immutable visible-state snapshots.

    FunctionPurpose
    labkit.app.view.SnapshotBuild one immutable complete visible-state snapshot.
    +

    labkit.biosignal

    Recording import, channel access, filtering, events, templates, and measurements.

    FunctionPurpose
    labkit.biosignal.buildTemplateBuild a representative segment template.
    labkit.biosignal.compareGroupsSummarize groups and compute pairwise Welch comparisons.
    labkit.biosignal.cropSignalReturn a signal clipped to a time range in seconds.
    labkit.biosignal.defaultEcgPeakOptionsReturn documented defaults for ECG/QRS peak detection.
    labkit.biosignal.detectEcgPeaksDetect ECG/QRS peaks as event anchors.
    labkit.biosignal.filterSignalApply a zero-phase FFT-domain filter to a biosignal.
    labkit.biosignal.getChannelReturn one signal from a recording by display name or index.
    labkit.biosignal.listChannelsReturn display names for all channels in a recording.
    labkit.biosignal.measureSegmentsMeasure generic template-residual segment quality.
    labkit.biosignal.readRecordingRead a biosignal file into a standard recording struct.
    labkit.biosignal.segmentByEventsExtract fixed windows around event anchors.
    labkit.biosignal.versionReturn the LabKit biosignal facade contract version.

    Framework Compatibility (labkit.contract)

    Version and MathWorks-product requirement contracts.

    FunctionPurpose
    labkit.contract.assertRequirementsThrow an error when LabKit API requirements are not met.
    labkit.contract.checkRequirementsCompare required API ranges with available LabKit versions.
    labkit.contract.requirementsDescribe the LabKit API versions required by a caller.
    labkit.contract.versionInfoDescribe one LabKit API version and its compatibility range.

    labkit.dta

    Gamry DTA discovery, parsing, curve access, and pulse detection.

    FunctionPurpose
    labkit.dta.detectPulsesLocate cathodic and anodic pulse windows in chrono data.
    labkit.dta.detectTypeIdentify the supported Gamry DTA data family in a file.
    labkit.dta.findFilesFind DTA files in a folder and its subfolders.
    labkit.dta.getColumnExtract a named column from a parsed DTA table.
    labkit.dta.getCurveXYExtract paired X and Y data from a parsed CV/CT curve.
    labkit.dta.getMainCurveSelect the transient table containing T, Vf, and Im data.
    labkit.dta.getZCurveSelect the impedance table from parsed EIS data.
    labkit.dta.loadFileRead one supported Gamry DTA file.
    labkit.dta.loadFilesRead a list of Gamry DTA files.
    labkit.dta.loadFolderRead every DTA file below a folder.
    labkit.dta.versionReturn version information for the DTA API.

    labkit.image

    Generic image file IO, normalization, resizing, filtering, and enhancement primitives.

    FunctionPurpose
    labkit.image.adjustBrightnessContrastApply simple brightness and contrast adjustment.
    labkit.image.adjustHueSaturationApply HSV hue and saturation adjustment.
    labkit.image.assertSupportedPathsThrow when any path has an unsupported image extension.
    labkit.image.displayNameReturn a short image-file display name.
    labkit.image.ensureRgbReturn image data with exactly three color channels.
    labkit.image.fileDialogFilterReturn a file-chooser-compatible image filter.
    labkit.image.grayWorldWhiteBalanceApply generic gray-world white balance.
    labkit.image.im2doubleConvert image data to double using MATLAB's im2double contract.
    labkit.image.isSupportedPathReturn true when a path has a supported image extension.
    labkit.image.localContrastEnhance local value-channel contrast with a mean-filter mask.
    labkit.image.meanFilter2Apply normalized 2-D mean filtering with edge correction.
    labkit.image.normalizePathsNormalize image file path inputs to a string column.
    labkit.image.previewBudgetDownsample image data to fit a display pixel budget.
    labkit.image.readFilesRead image files into path/name/image records.
    labkit.image.resizeToFitResize an image to fit within maximum row/column limits.
    labkit.image.rgb2grayConvert RGB data using MATLAB's rgb2gray call contract.
    labkit.image.sharpenApply generic unsharp-mask sharpening.
    labkit.image.supportedExtensionsReturn extensions supported by LabKit image file inputs.
    labkit.image.versionReturn the LabKit image facade contract version.
    labkit.image.writeFileWrite one image file, creating the parent folder when needed.

    labkit.rhs

    Intan RHS discovery, indexing, inspection, and bounded waveform reads.

    FunctionPurpose
    labkit.rhs.findFilesFind Intan RHS files in a folder and its subfolders.
    labkit.rhs.indexFileBuild a block index for reading selected RHS time windows.
    labkit.rhs.inspectFileRead metadata and data-layout information from an RHS file.
    labkit.rhs.readWindowRead selected channels and times from an Intan RHS file.
    labkit.rhs.versionReturn version information for the RHS API.

    labkit.thermal

    Radiometric image inspection, conversion, reading, and rendering.

    FunctionPurpose
    labkit.thermal.fileDialogFilterCreate a file-selection filter for thermal images.
    labkit.thermal.inspectFileCheck whether a file contains supported radiometric data.
    labkit.thermal.isSupportedPathTest whether a path has a supported thermal extension.
    labkit.thermal.rawToTemperatureCConvert FLIR raw sensor values to degrees Celsius.
    labkit.thermal.readFileRead radiometric image data and convert it to degrees Celsius.
    labkit.thermal.readFilesRead several radiometric image files.
    labkit.thermal.renderImageMap a thermal matrix to an RGB image.
    labkit.thermal.supportedExtensionsList the supported thermal filename extensions.
    labkit.thermal.versionReturn version information for the thermal API.
    -

    Framework Debug (labkit.ui.debug)

    Runtime debug context exposed to app callbacks.

    FunctionPurpose
    labkit.ui.debug.contextCreate an app-neutral callback tracing and diagnostic context.
    -

    Framework Interaction (labkit.ui.interaction)

    Managed anchors, popouts, and scale-bar geometry.

    FunctionPurpose
    labkit.ui.interaction.anchorPathBuild a visible path through image anchor points.
    labkit.ui.interaction.enablePopoutAdd a standalone-figure action to an axes context menu.
    labkit.ui.interaction.scaleBarCalibrationConvert a known image distance into pixels per unit.
    labkit.ui.interaction.scaleBarGeometryCompute serializable image scale-bar overlay geometry.
    -

    Framework Layout (labkit.ui.layout)

    Data-only workbench, panel, field, action, and results specifications.

    FunctionPurpose
    labkit.ui.layout.actionCreate an app-command layout node.
    labkit.ui.layout.fieldCreate a labeled scalar field layout node.
    labkit.ui.layout.filePanelCreate a file input panel layout node.
    labkit.ui.layout.groupCreate a grouped UI layout.
    labkit.ui.layout.logPanelCreate a read-only log panel layout node.
    labkit.ui.layout.pannerCreate a numeric spinner with a linked slider.
    labkit.ui.layout.previewAreaCreate a workspace preview/axes area layout node.
    labkit.ui.layout.rangeFieldCreate a paired start/end or min/max field layout node.
    labkit.ui.layout.resultTableCreate a titled result table layout node.
    labkit.ui.layout.sectionCreate a titled control-section layout node.
    labkit.ui.layout.statusPanelCreate a read-only status/details panel layout node.
    labkit.ui.layout.tabCreate a LabKit control or workspace tab layout node.
    labkit.ui.layout.workbenchCreate a declarative LabKit workbench layout.
    labkit.ui.layout.workspaceCreate a right-side LabKit workbench workspace layout node.
    -

    Framework Plot (labkit.ui.plot)

    Axes presentation, fit, clearing, messages, and coordinate helpers.

    FunctionPurpose
    labkit.ui.plot.clampDataKeep data coordinates inside the visible axes box.
    labkit.ui.plot.clearPrepare an axes for an app-owned redraw.
    labkit.ui.plot.fitFit axes limits to finite plotted X/Y data.
    labkit.ui.plot.fitCanvasFit a preview axes into a fixed-aspect pixel frame.
    labkit.ui.plot.messageShow a centered empty-state message in an axes.
    labkit.ui.plot.offsetDataMove data coordinates by normalized axes fractions.
    -

    Framework Runtime (labkit.ui.runtime)

    App definition, launch, lifecycle, busy work, persistence, and portable file references.

    FunctionPurpose
    labkit.ui.runtime.createBuild a LabKit workbench from a declarative layout.
    labkit.ui.runtime.defaultOutputFolderReturn a source-adjacent app output folder.
    labkit.ui.runtime.defineCreate a LabKit declarative app runtime definition.
    labkit.ui.runtime.emptySourceRecordsCreate an empty Runtime V2 source array.
    labkit.ui.runtime.launchDispatch requests and launch a LabKit runtime definition.
    labkit.ui.runtime.loadStateLoad a compatible Runtime V2 project or declared legacy import.
    labkit.ui.runtime.saveStateSave a Runtime V2 project to a MAT file.
    labkit.ui.runtime.sourcePathsRead resolved paths from Runtime V2 source records.
    labkit.ui.runtime.sourceRecordCreate one canonical Runtime V2 external-source record.
    -

    Framework Version (labkit.ui)

    UI facade version metadata.

    FunctionPurpose
    labkit.ui.versionReturn the LabKit UI facade contract version.

    Batch Crop App API

    Supported GUI-free operations owned by the Batch Crop app in the Image Measurement family.

    FunctionPurpose
    batch_crop.cropGeometry.cropImageRotate and crop an image at a fixed pixel size.
    batch_crop.cropGeometry.cropScaledImageCrop one calibrated image to a common physical output scale.
    batch_crop.cropGeometry.scalePlanPlan equal-size physical crops across calibrated images.

    Cic App API

    Supported GUI-free operations owned by the Cic app in the Electrochemistry family.

    FunctionPurpose
    cic.analysisRun.computeCICCalculate charge-injection and voltage-transient metrics.

    Csc App API

    Supported GUI-free operations owned by the Csc app in the Electrochemistry family.

    FunctionPurpose
    csc.analysisRun.chargeDensityConvert charge in coulombs to density in mC/cm^2.
    csc.analysisRun.computeCSCCompare time-integrated and scan-rate-derived electrode charge.
    diff --git a/tests/cases/contract/apps/AppIsolatedPathContractTest.m b/tests/cases/contract/apps/AppIsolatedPathContractTest.m index c3ff04f72..c8b54c569 100644 --- a/tests/cases/contract/apps/AppIsolatedPathContractTest.m +++ b/tests/cases/contract/apps/AppIsolatedPathContractTest.m @@ -24,24 +24,26 @@ function publicAppsLoadContractsAndDebugSamplesOnOwningPath(testCase) apps(k).slug + ".definition"); definition = definitionFactory(); - testCase.verifyEqual(definition.product.command, apps(k).command); - testCase.verifyEqual(definition.id, apps(k).slug); - testCase.verifyNotEmpty(regexp(definition.product.version, ... + testCase.verifyEqual(definition.Entrypoint, apps(k).command); + testCase.verifyEqual(definition.AppId, apps(k).slug); + testCase.verifyNotEmpty(regexp(definition.AppVersion, ... '^\d+\.\d+\.\d+$', "once")); report = labkit.contract.checkRequirements( ... - definition.requirements); + definition.Requirements); testCase.verifyTrue(report.ok, ... apps(k).command + ": " + report.message); - testCase.verifyClass(definition.debugSample, ... + testCase.verifyClass(definition.BuildDebugSample, ... "function_handle"); - writer = definition.debugSample; - debugLog = labkit.ui.debug.context(apps(k).command, struct( ... - "artifactFolder", fullfile(scratchRoot, apps(k).slug))); - pack = writer(debugLog); - testCase.verifyTrue(isstruct(pack) && isscalar(pack), ... + writer = definition.BuildDebugSample; + sampleContext = labkit.app.diagnostic.SampleContext( ... + fullfile(scratchRoot, apps(k).slug)); + pack = writer(sampleContext); + testCase.verifyClass(pack, ... + "labkit.app.diagnostic.SamplePack", ... apps(k).command + ... - " DebugSample must return a scalar pack structure."); + " DebugSample must return a typed SamplePack."); + testCase.verifyTrue(isscalar(pack)); end clear scratchCleanup pathCleanup end diff --git a/tests/cases/contract/apps/AppLibraryCompatibilityTest.m b/tests/cases/contract/apps/AppLibraryCompatibilityTest.m index 55ff2fe06..009653cf7 100644 --- a/tests/cases/contract/apps/AppLibraryCompatibilityTest.m +++ b/tests/cases/contract/apps/AppLibraryCompatibilityTest.m @@ -222,7 +222,8 @@ function appDialogsUseRuntimeServices(testCase) end end testCase.verifyEmpty(findings, ... - "Apps should use filePanel and handler services.dialogs instead " + ... + "Apps should use layout file selection and CallbackContext " + ... + "dialog operations instead " + ... "of creating MATLAB dialogs directly: " + ... strjoin(findings, "; ")); end @@ -240,7 +241,7 @@ function appAlertsUseRuntimeServices(testCase) end testCase.verifyEmpty(findings, ... - "Apps should route alerts through handler services.dialogs.alert so " + ... + "Apps should route alerts through CallbackContext.alert so " + ... "hidden GUI tests can record error paths without modal stalls: " + ... strjoin(findings, "; ")); end @@ -272,23 +273,6 @@ function appRunnersReportCaughtCallbackExceptions(testCase) strjoin(findings, "; ")); end - function appPackagesDoNotOwnCloseGuardState(testCase) - root = setupLabKitTestPath(); - appFiles = collectAppMFiles(root); - findings = strings(0, 1); - - for k = 1:numel(appFiles) - content = string(fileread(appFiles(k))); - if contains(content, "labkit.ui.runtime.setCloseGuard") - findings(end+1, 1) = string(localRelativePath(root, appFiles(k))); - end - end - - testCase.verifyEmpty(findings, ... - "Close confirmation is framework-owned; app files must not " + ... - "maintain close guard dirty state through removed runtime APIs: " + ... - strjoin(findings, "; ")); - end end end diff --git a/tests/cases/contract/apps/AppPackageStructureGuardrailTest.m b/tests/cases/contract/apps/AppPackageStructureGuardrailTest.m index 1cb8221d1..cefcca05e 100644 --- a/tests/cases/contract/apps/AppPackageStructureGuardrailTest.m +++ b/tests/cases/contract/apps/AppPackageStructureGuardrailTest.m @@ -1,437 +1,322 @@ classdef AppPackageStructureGuardrailTest < matlab.unittest.TestCase - %APPPACKAGESTRUCTUREGUARDRAILTEST Guardrails for app package layout. + %APPPACKAGESTRUCTUREGUARDRAILTEST Enforce the final App SDK package shape. methods (Test, TestTags = {'Integration', 'Style'}) function supportedAppsUseCanonicalAppPackageStructure(testCase) root = setupLabKitTestPath(); - layouts = discoveredAppLayouts(root); - testCase.assertFalse(isempty(layouts), ... - 'App package structure guardrail should discover app entrypoints.'); + apps = discoveredApps(root); + testCase.assertFalse(isempty(apps), ... + "App package guardrail should discover entrypoints."); - for k = 1:size(layouts, 1) - assertCanonicalAppPackageStructure(testCase, root, ... - layouts{k, 1}, layouts{k, 2}, layouts{k, 3}); + for k = 1:size(apps, 1) + assertCanonicalShape(testCase, root, ... + apps{k, 1}, apps{k, 2}, apps{k, 3}); end end - function appFoldersDoNotMixFileAndFolderForms(testCase) - root = setupLabKitTestPath(); - layouts = discoveredAppLayouts(root); - for k = 1:size(layouts, 1) - appDir = fullfile(root, layouts{k, 1}); - assertNoSameStemFileFolder(testCase, root, appDir); - packageName = layouts{k, 2}; - if strlength(string(packageName)) > 0 - assertNoSameStemFileFolder(testCase, root, ... - fullfile(appDir, ['+' packageName])); - end + function definitionsCompileAsExplicitSdkContracts(testCase) + setupLabKitTestPath(); + apps = discoveredApps(pwd); + for k = 1:size(apps, 1) + packageName = string(apps{k, 2}); + definition = feval(packageName + ".definition"); + testCase.verifyClass(definition, "labkit.app.Definition", ... + packageName + ... + ".definition must return labkit.app.Definition."); end end - function appLayoutsResolveOnlyRegisteredActions(testCase) + function appFoldersDoNotMixFileAndFolderForms(testCase) root = setupLabKitTestPath(); - layouts = discoveredAppLayouts(root); - for k = 1:size(layouts, 1) - packageName = string(layouts{k, 2}); - definitionFcn = str2func(packageName + ".definition"); - definition = definitionFcn(); - callbacks = runtimeCallbackStubs(definition.actions); - - layout = invokeDefinitionLayout(definition, callbacks); - - testCase.verifyTrue(isstruct(layout), ... - [char(packageName) ... - ' layout should resolve every Runtime callback.']); + apps = discoveredApps(root); + for k = 1:size(apps, 1) + appDir = fullfile(root, apps{k, 1}); + assertNoSameStemFileFolder(testCase, root, appDir); + assertNoSameStemFileFolder(testCase, root, ... + fullfile(appDir, ['+' apps{k, 2}])); end end function appCodeDoesNotCallSiblingAppPackages(testCase) root = setupLabKitTestPath(); - layouts = discoveredAppLayouts(root); - packageNames = string(layouts(:, 2)); - for k = 1:size(layouts, 1) - appDir = fullfile(root, layouts{k, 1}); + apps = discoveredApps(root); + packageNames = string(apps(:, 2)); + for k = 1:size(apps, 1) + appDir = fullfile(root, apps{k, 1}); owner = packageNames(k); - siblings = packageNames(packageNames ~= owner); - assertNoSiblingAppCalls( ... - testCase, root, appDir, owner, siblings); + assertNoSiblingAppCalls(testCase, root, appDir, owner, ... + packageNames(packageNames ~= owner)); end end function sessionFactoriesDoNotSwallowRestoreFailures(testCase) root = setupLabKitTestPath(); - layouts = discoveredAppLayouts(root); - for k = 1:size(layouts, 1) - filepath = fullfile(root, layouts{k, 1}, ... - ['+' layouts{k, 2}], 'createSession.m'); + apps = discoveredApps(root); + for k = 1:size(apps, 1) + filepath = fullfile(root, apps{k, 1}, ... + ['+' apps{k, 2}], 'createSession.m'); if ~isfile(filepath) continue; end source = string(fileread(filepath)); testCase.verifyEmpty(regexp(source, ... '(?m)^\s*catch(?:\s+\w+)?\s*$', 'once'), ... - [relativePath(root, filepath) ... - ' must let project reconstruction failures reach Runtime.']); + relativePath(root, filepath) + ... + " must let reconstruction failures reach Runtime."); end end - end -end - -function assertNoSiblingAppCalls(testCase, root, appDir, owner, siblings) - files = dir(fullfile(appDir, "**", "*.m")); - for k = 1:numel(files) - filepath = fullfile(files(k).folder, files(k).name); - source = string(fileread(filepath)); - code = regexprep(source, "(?m)^\s*%[^\n]*", ""); - for sibling = siblings.' - pattern = "(? 0), ... "Changed validation plan steps should explain why each scope was selected."); appGuiSelectors = [ ... "image_enhance_workflow_applies_tool_and_exports", ... - "batch_crop_workflow_exports_synthetic_crop"]; + "cropTasksCenterAndExportSyntheticImages"]; testCase.verifyTrue(any(contains(tests, appGuiSelectors(1)) & ... contains(tests, appGuiSelectors(2))), ... "Fast changed UI validation should use test-name selectors to avoid the full reusable UI GUI suite."); @@ -81,12 +82,12 @@ function changedFastValidationPlanUsesRepresentativeAppGuiCoverage(testCase) ["gui/apps/image_measurement/image_enhance", ... "gui/apps/image_measurement/batch_crop"]); verifySelectorsMatchTests(testCase, [ ... - "test_gui_layout_ui_declarative_app", ... - "controlled_interactions_suppress_programmatic_events", ... - "test_gui_layout_ui_debug_trace"], ... + "reconcilesChronoLikeSemanticTree", ... + "nativeCallbacksUseTypedRuntimeEntrypoints", ... + "replacesChoicesWhenCurrentValueDisappears"], ... "gui/labkit_framework/ui"); verifySelectorsMatchTests(testCase, ... - "controlled_region_selection_registers_transient_gesture", ... + "reconcilesManagedRectangleAndDispatchesDirectCallback", ... "gui/labkit_framework/ui"); end @@ -178,12 +179,12 @@ function changedValidationPlanRoutesFrameworkGuiTestsByOwner(testCase) root = setupLabKitTestPath(); steps = labkitValidationPlanForChangedPaths(root, ... - "tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2InteractionHubTest.m"); + "tests/cases/gui/labkit_framework/ui/UiMatlabPlatformAdapterTest.m"); signatures = validationStepSignatures(steps); testCase.verifyEqual(signatures, "|true"); testCase.verifyEqual(validationStepFileSelectors(steps), ... - "tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2InteractionHubTest.m", ... + "tests/cases/gui/labkit_framework/ui/UiMatlabPlatformAdapterTest.m", ... "A changed framework GUI test should rerun exactly itself."); end @@ -204,15 +205,6 @@ function changedValidationPlanTargetsSharedHelperConsumers(testCase) testCase.verifyTrue(any(guiSelectors == "GuiLayoutDicPreprocessTest"), ... "Shared GUI helper routing should include real app consumers."); - workflowSteps = labkitValidationPlanForChangedPaths(root, ... - "tests/shared/labkitWorkflowDriver.m"); - workflowSelectors = split( ... - validationStepTestSelectors(workflowSteps), ","); - testCase.verifyLessThan(numel(workflowSelectors), ... - numel(guiSelectors), ... - "Workflow-driver changes should run only direct consumers."); - testCase.verifyTrue(any(workflowSelectors == "GuiLayoutBatchCropTest"), ... - "Workflow-driver routing should include app workflow consumers."); end function testCasePathsUseExplicitOwners(testCase) diff --git a/tests/cases/contract/project/build/BuildTaskFrameworkGuardrailTest.m b/tests/cases/contract/project/build/BuildTaskFrameworkGuardrailTest.m index 82af98cc9..a83eb74c1 100644 --- a/tests/cases/contract/project/build/BuildTaskFrameworkGuardrailTest.m +++ b/tests/cases/contract/project/build/BuildTaskFrameworkGuardrailTest.m @@ -111,7 +111,7 @@ function kindPrefixedSuiteDiscoversFocusedMethodSelector(testCase) function runnerFilesRequiresGuiModeForGuiFiles(testCase) root = setupLabKitTestPath(); guiFile = fullfile(root, "tests", "cases", "gui", ... - "labkit_framework", "ui", "GuiStartupLifecycleTest.m"); + "labkit_framework", "ui", "UiMatlabPlatformAdapterTest.m"); testCase.verifyError(@() runLabKitTests( ... "Files", guiFile, "ListOnly", true), ... "LabKit:Tests:GuiFileRequiresIncludeGui"); @@ -191,7 +191,7 @@ function affectedValidationMapperCoversSharedUiAndAppChanges(testCase) root = setupLabKitTestPath(); steps = labkitValidationPlanForChangedPaths(root, [ ... - "+labkit/+ui/+runtime/private/runAppBusyCallback.m", ... + "+labkit/+app/+internal/@MatlabPlatformAdapter/applyView.m", ... "apps/image_measurement/batch_crop/+batch_crop/definition.m"]); signatures = validationStepSignatures(steps); @@ -317,7 +317,7 @@ function changedValidationPlanCompressesCoveredGuiTargets(testCase) root = setupLabKitTestPath(); steps = labkitValidationPlanForChangedPaths(root, [ ... - "+labkit/+ui/+runtime/private/runAppBusyCallback.m", ... + "+labkit/+app/+internal/RuntimeKernel.m", ... "tests/cases/gui/apps/image_measurement/batch_crop/GuiLayoutBatchCropTest.m"]); signatures = validationStepSignatures(steps); diff --git a/tests/cases/contract/project/docs/DocumentationRendererRegressionTest.m b/tests/cases/contract/project/docs/DocumentationRendererRegressionTest.m index d33ce42a8..5a7604312 100644 --- a/tests/cases/contract/project/docs/DocumentationRendererRegressionTest.m +++ b/tests/cases/contract/project/docs/DocumentationRendererRegressionTest.m @@ -103,6 +103,23 @@ function apiProseLinksKnownPublicSymbols(testCase) "Executable examples must remain literal code, not links."); end + function narrativeApiTablesFollowSourceMentions(testCase) + root = setupLabKitTestPath(); + framework = string(fileread(fullfile(root, "site", ... + "framework", "index.html"))); + history = string(fileread(fullfile(root, "site", "history", ... + "records", "2026", "07", ... + "LK-20260718-ttest-wizard-and-table-workspace.html"))); + + testCase.verifyTrue(contains(framework, ... + "labkit.app.Definition"), ... + "A symbol named by the framework source should be linked."); + testCase.verifyFalse(contains(history, ... + "labkit.app.Definition"), ... + ["Adding a public API must not inject it into unrelated " ... + "historical pages through broad component membership."]); + end + function inlineRenderingDoesNotCaptureTheFullModelPerFragment(testCase) root = setupLabKitTestPath(); renderer = string(fileread(fullfile(root, "tools", "docs", ... @@ -247,15 +264,15 @@ function apiSidebarLabelsSiblingFunctionGroup(testCase) 'detectPulses')); end - function frameworkOwnsUiAndCompatibilityDocumentation(testCase) + function frameworkOwnsAppSdkAndCompatibilityDocumentation(testCase) root = setupLabKitTestPath(); framework = string(fileread(fullfile(root, "site", ... "framework", "index.html"))); contracts = string(fileread(fullfile(root, "site", ... "framework", "compatibility", "contracts.html"))); - runtimeApi = string(fileread(fullfile(root, "site", ... - "reference", "api", "labkit", "ui", "runtime", ... - "launch.html"))); + definitionApi = string(fileread(fullfile(root, "site", ... + "reference", "api", "labkit", "app", ... + "Definition.html"))); testCase.verifyTrue(contains(framework, ... '

    Framework Compatibility

    ')); @@ -265,10 +282,10 @@ function frameworkOwnsUiAndCompatibilityDocumentation(testCase) testCase.verifyTrue(contains(contracts, ... ['class="product-nav-link active" ' ... 'href="../index.html">Framework'])); - testCase.verifyTrue(contains(runtimeApi, ... - '

    Framework Runtime

    ')); - testCase.verifyTrue(contains(runtimeApi, ... - 'class="product-nav-link active" href="../../../../index.html">Functions')); + testCase.verifyTrue(contains(definitionApi, ... + '

    App SDK Core

    ')); + testCase.verifyTrue(contains(definitionApi, ... + 'class="product-nav-link active" href="../../../index.html">Functions')); end end end diff --git a/tests/cases/contract/project/docs/ProjectDocumentationGuardrailTest.m b/tests/cases/contract/project/docs/ProjectDocumentationGuardrailTest.m index f2bf6f601..10d8034e3 100644 --- a/tests/cases/contract/project/docs/ProjectDocumentationGuardrailTest.m +++ b/tests/cases/contract/project/docs/ProjectDocumentationGuardrailTest.m @@ -526,12 +526,21 @@ function executeDocumentationExample(code) files = strings(1, 0); for k = 1:numel(allFiles) filepath = fullfile(allFiles(k).folder, allFiles(k).name); - if ~contains(filepath, [filesep 'private' filesep]) + if ~contains(filepath, [filesep 'private' filesep]) && ... + ~contains(filepath, [filesep '@']) && ... + ~isHiddenClassFile(filepath) files(end+1) = string(filepath); end end end +function tf = isHiddenClassFile(filepath) + lines = strip(readlines(filepath, "EmptyLineRule", "skip")); + lines = lines(~startsWith(lines, "%")); + tf = ~isempty(lines) && startsWith(lines(1), "classdef") && ... + contains(lines(1), "Hidden"); +end + function symbol = publicApiSymbol(root, filepath) rel = string(relativePath(root, filepath)); parts = split(rel, "/"); @@ -541,10 +550,20 @@ function executeDocumentationExample(code) end function tf = hasFunctionContractComment(filepath) + if isClassFile(filepath) + tf = true; + return; + end lines = leadingFunctionBlock(filepath); tf = numel(lines) >= 2 && startsWith(strtrim(lines(2)), "%"); end +function tf = isClassFile(filepath) + lines = strip(readlines(filepath, "EmptyLineRule", "skip")); + lines = lines(~startsWith(lines, "%")); + tf = ~isempty(lines) && startsWith(lines(1), "classdef"); +end + function actual = collectPrivateHelpersMissingContracts(root) privateDirs = [ ... collectPrivateDirs(fullfile(root, '+labkit')), ... diff --git a/tests/cases/contract/project/docs/ProjectSurfaceDocumentationTest.m b/tests/cases/contract/project/docs/ProjectSurfaceDocumentationTest.m index dcce8aad0..0f0e95570 100644 --- a/tests/cases/contract/project/docs/ProjectSurfaceDocumentationTest.m +++ b/tests/cases/contract/project/docs/ProjectSurfaceDocumentationTest.m @@ -54,7 +54,7 @@ function launcherManualCoversPrimaryActions(testCase) source = string(fileread(fullfile(root, "labkit_launcher.m"))); manual = string(fileread(fullfile(root, "docs", "apps", ... "labkit-core", "launcher", "README.md"))); - labels = ["Open Selected App", "Open Debug", "Refresh App List", ... + labels = ["Open Selected App", "Refresh App List", ... "Documentation and History", "Latest", "Release", "Versions", ... "Update Documentation", "Run Code Analyzer", ... "Profile Selected App", "Clean Artifacts", "Package Checked", ... @@ -99,12 +99,12 @@ function runtimeManualExplainsTheMinimalDefinitionContract(testCase) root = setupLabKitTestPath(); manual = string(fileread(fullfile(root, "docs", ... "framework", "guides", "runtime.md"))); - required = ["definition.m", "Definition Component Contract", ... - "Static Apps may omit", "Project", "CreateSession", ... - "Actions", "Present", ... - "Project Declaration And Durable Payload", ... - "inputs", "parameters", "annotations", "results", ... - "extensions", "selection", "workflow", "view", "cache"]; + required = ["definition.m", "Definition And Launch", ... + "CreateSession", "PresentWorkbench", "OnStart", ... + "BuildDebugSample", "Static Workbench Contract", ... + "State And Transactions", "Portable Sources", ... + "Typed Events", "Complete View Snapshots", ... + "CallbackContext", "Persistence, Results, And Cleanup"]; for item = required testCase.verifyTrue(contains(manual, item), ... "Runtime manual omits the minimal definition contract: " + item); @@ -119,16 +119,13 @@ function completeAppTutorialCoversEveryRequiredComponent(testCase) tutorial = string(fileread(fullfile(root, "docs", ... "development", "build-apps", "complete-app.md"))); required = ["labkit_TraceViewer_app.m", "definition.m", ... - "The Three-File Starting Point", "definitionActions.m", ... + "+workbench", "+sourceTrace", "+resultFiles", ... "projectSpec.m", "createProject", "createSession.m", ... - "validateProject", "migrateProject", ... - "readTrace.m", "applyGain.m", "buildWorkbenchLayout.m", ... - "presentWorkbench.m", "renderTrace.m", ... - "labkit.ui.runtime.launch", "labkit.ui.runtime.define", ... - "services.project.upsertSource", ... - "labkit.ui.runtime.sourcePaths", ... - "inputs", "parameters", "annotations", "results", ... - "extensions", "selection", "workflow", "view", "cache"]; + "validateProject", "buildLayout.m", "present.m", ... + "labkit.app.Definition", "labkit.app.layout.fileList", ... + "labkit.app.view.Snapshot", "Snapshot.include", ... + "callbackContext.resolveSourcePaths", ... + "exportTrace", "draw", "applicationState"]; for item = required testCase.verifyTrue(contains(tutorial, item), ... "Complete-app tutorial omits component: " + item); @@ -149,7 +146,7 @@ function agentGuidanceUsesProgressiveRuntimeContract(testCase) skill = string(fileread(fullfile(root, ".agents", "skills", ... "labkit-app-builder", "SKILL.md"))); required = ["smallest complete shape", "definition.m", ... - "buildWorkbenchLayout.m", "projectSpec.m", ... + "buildLayout.m", "projectSpec.m", ... "createSession.m", "Runtime owns the", "migration loop"]; for item = required testCase.verifyTrue(contains(skill, item), ... diff --git a/tests/cases/contract/project/docs/PublicApiDocumentationContractTest.m b/tests/cases/contract/project/docs/PublicApiDocumentationContractTest.m index c073bec0f..0b2a94bd5 100644 --- a/tests/cases/contract/project/docs/PublicApiDocumentationContractTest.m +++ b/tests/cases/contract/project/docs/PublicApiDocumentationContractTest.m @@ -91,7 +91,7 @@ function generatedApiCodeBlocksExcludeSeeAlsoText(testCase) function generatedNameValueSectionsUseDefinitionLists(testCase) root = setupLabKitTestPath(); filepath = fullfile(root, "site", "reference", "api", ... - "labkit", "ui", "runtime", "define.html"); + "labkit", "app", "Definition.html"); html = string(fileread(filepath)); for id = ["required-name-value-arguments", ... "optional-name-value-arguments"] @@ -108,10 +108,10 @@ function generatedNameValueSectionsUseDefinitionLists(testCase) function generatedMethodSectionsUseDefinitionLists(testCase) root = setupLabKitTestPath(); filepath = fullfile(root, "site", "reference", "api", ... - "labkit", "ui", "debug", "context.html"); + "labkit", "app", "Definition.html"); html = string(fileread(filepath)); section = extractAfter(html, ... - '

    '); + '

    '); section = extractBefore(section, "

    "); testCase.verifyTrue(contains(section, ... '
    '), ... @@ -191,10 +191,19 @@ function executeExample(code) files = strings(0, 1); for k = 1:numel(entries) filepath = string(fullfile(entries(k).folder, entries(k).name)); - if ~contains(filepath, filesep + "private" + filesep) + if ~contains(filepath, filesep + "private" + filesep) && ... + ~contains(filepath, filesep + "@") && ... + ~isHiddenClassFile(filepath) files(end + 1, 1) = filepath; end end files = sort(files); files = [files; discoverLabKitAppApiFiles(root)]; end + +function tf = isHiddenClassFile(filepath) + lines = strip(readlines(filepath, "EmptyLineRule", "skip")); + lines = lines(~startsWith(lines, "%")); + tf = ~isempty(lines) && startsWith(lines(1), "classdef") && ... + contains(lines(1), "Hidden"); +end diff --git a/tests/cases/contract/project/hygiene/MagicNumberGovernanceTest.m b/tests/cases/contract/project/hygiene/MagicNumberGovernanceTest.m index 6643f5b7c..2df8b6856 100644 --- a/tests/cases/contract/project/hygiene/MagicNumberGovernanceTest.m +++ b/tests/cases/contract/project/hygiene/MagicNumberGovernanceTest.m @@ -35,7 +35,7 @@ function scannerRejectsUnexplainedHighPrecisionConstants(testCase) productionSource = startsWith(slashFiles, "+labkit/") | ... startsWith(slashFiles, "apps/"); excludedSource = contains(slashFiles, "/+debug/") | ... - endsWith(slashFiles, "/buildWorkbenchLayout.m") | ... + endsWith(slashFiles, "/buildLayout.m") | ... endsWith(slashFiles, "/version.m") | ... endsWith(slashFiles, "/requirements.m"); existsNow = arrayfun(@(file) isfile(fullfile(root, file)), files); @@ -72,6 +72,7 @@ function scannerRejectsUnexplainedHighPrecisionConstants(testCase) function tf = isExplicitUiColor(file, line) normalizedFile = replace(string(file), "\", "/"); - tf = contains(normalizedFile, "/+userInterface/") && ... + tf = ~isempty(regexp(normalizedFile, ... + '/\+[^/]+/(?:draw|render|present)[^/]*[.]m$', 'once')) && ... (contains(line, "Color") || contains(line, "color")); end diff --git a/tests/cases/contract/project/hygiene/RectangleInteractionGovernanceTest.m b/tests/cases/contract/project/hygiene/RectangleInteractionGovernanceTest.m index eb700e035..76bf8c826 100644 --- a/tests/cases/contract/project/hygiene/RectangleInteractionGovernanceTest.m +++ b/tests/cases/contract/project/hygiene/RectangleInteractionGovernanceTest.m @@ -1,65 +1,72 @@ classdef RectangleInteractionGovernanceTest < matlab.unittest.TestCase - %RECTANGLEINTERACTIONGOVERNANCETEST Guard draggable rectangle behavior. + %RECTANGLEINTERACTIONGOVERNANCETEST Guard managed plot-region behavior. methods (Test, TestTags = {'Integration', 'Style'}) - function appRectanglesUseSharedInteractionContracts(testCase) + function directRectangleCallsStayInsideReviewedBoundaries(testCase) root = setupLabKitTestPath(); files = sourceFiles(root); directFiles = filesWithDirectRectangleCalls(root, files); expected = [ - "+labkit/+ui/+runtime/private/createRectangleEditor.m" - "+labkit/+ui/+runtime/private/reconcileV2Interactions.m" - "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/renderPreviewImage.m" - "apps/image_measurement/batch_crop/+batch_crop/+userInterface/renderCropPreview.m" - "apps/image_measurement/image_enhance/+image_enhance/+userInterface/renderImagePreview.m" - "apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/drawTemperatureReadings.m" + "+labkit/+app/+interaction/rectangle.m" + "+labkit/+app/+internal/private/createRectangleEditor.m" + "+labkit/+app/+internal/private/reconcileInteractions.m" + "+labkit/+app/+view/Snapshot.m" + "apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m" + "apps/image_measurement/batch_crop/+batch_crop/+cropPreview/draw.m" + "apps/image_measurement/flir_thermal/+flir_thermal/+thermalPreview/+presentationData/drawTemperatureReadings.m" + "apps/image_measurement/image_enhance/+image_enhance/+imagePreview/draw.m" ]; testCase.verifyEqual(sort(directFiles), sort(expected), ... - ['Interactive app rectangles must use rectangleEditor or an ' ... - 'interaction-runtime drag session. Direct rectangle primitives ' ... - 'require an explicit governance review.']); + ['Editable rectangles must use labkit.app.interaction.rectangle. ' ... + 'Direct graphics primitives require an explicit boundary review.']); appDirectFiles = directFiles(startsWith(directFiles, "apps/")); for k = 1:numel(appDirectFiles) source = fileread(fullfile(root, char(appDirectFiles(k)))); - testCase.verifyTrue(contains(source, "'HitTest', 'off'") && ... - contains(source, "'PickableParts', 'none'"), ... + hitTestOff = contains(source, "'HitTest', 'off'") || ... + contains(source, 'HitTest="off"'); + pickingOff = contains(source, "'PickableParts', 'none'") || ... + contains(source, 'PickableParts="none"'); + testCase.verifyTrue(hitTestOff && pickingOff, ... appDirectFiles(k) + ... " must keep direct rectangle overlays non-pickable."); end end - function knownInteractiveWorkflowsUseEditorOrDragSession(testCase) + function knownInteractiveWorkflowsDeclareSemanticInteractions(testCase) root = setupLabKitTestPath(); - controlledUsers = [ - "apps/dic/dic_preprocess/+dic_preprocess/+userInterface/presentWorkbench.m" - "apps/image_measurement/image_enhance/+image_enhance/+userInterface/presentWorkbench.m" + rectangleLayouts = [ + "apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m" + "apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m" ]; - for k = 1:numel(controlledUsers) - source = fileread(fullfile(root, char(controlledUsers(k)))); - testCase.verifyTrue(contains(source, '"Kind", "rectangle"'), ... - controlledUsers(k) + ... - " must declare a Runtime V2 controlled rectangle."); + for k = 1:numel(rectangleLayouts) + source = fileread(fullfile(root, char(rectangleLayouts(k)))); + testCase.verifyTrue(contains(source, ... + "labkit.app.interaction.rectangle("), ... + rectangleLayouts(k) + ... + " must declare an App SDK managed rectangle."); end - batchCropPresenter = fileread(fullfile(root, ... + batchCropLayout = fileread(fullfile(root, ... 'apps/image_measurement/batch_crop/+batch_crop', ... - '+userInterface/presentWorkbench.m')); - testCase.verifyTrue(contains(batchCropPresenter, ... - '"Kind", "pointSlots"') && ... - contains(batchCropPresenter, ... + '+workbench/buildLayout.m')); + testCase.verifyTrue(contains(batchCropLayout, ... + 'labkit.app.interaction.pointSlots("cropCenter"') && ... + contains(batchCropLayout, ... '"placeSelectedOnBackground", true'), ... ['Batch Crop uses a fixed-size ROI, so its Imager-style ' ... 'interaction must expose a draggable center plus one-click ' ... 'placement instead of a misleading resizable rectangle.']); - flirPresenter = fileread(fullfile(root, ... + flirLayout = fileread(fullfile(root, ... 'apps/image_measurement/flir_thermal/+flir_thermal', ... - '+userInterface/presentWorkbench.m')); - testCase.verifyTrue(contains(flirPresenter, ... - '"Kind", "regionSelection"'), ... - ['FLIR point and ROI reads must use the Runtime V2 ' ... - 'controlled region-selection contract.']); + '+workbench/buildLayout.m')); + testCase.verifyTrue(contains(flirLayout, ... + 'labkit.app.interaction.regionSelection(') && ... + contains(flirLayout, ... + 'OnBackgroundPressed=@flir_thermal.temperatureReadings.changePoint'), ... + ['FLIR point and ROI reads must share one App SDK managed ' ... + 'region-selection gesture with explicit point dispatch.']); end end end diff --git a/tests/cases/contract/project/packages/AppIdentityContractTest.m b/tests/cases/contract/project/packages/AppIdentityContractTest.m index d62e87265..65d8b367b 100644 --- a/tests/cases/contract/project/packages/AppIdentityContractTest.m +++ b/tests/cases/contract/project/packages/AppIdentityContractTest.m @@ -14,10 +14,10 @@ function publicDefinitionsDeclareUniqueStableIds(testCase) source = fileread(fullfile( ... definitions(1).folder, definitions(1).name)); token = regexp(source, ... - '["'']Id["'']\s*,\s*["'']([A-Za-z][A-Za-z0-9_.-]*)["'']', ... + 'AppId\s*=\s*["'']([A-Za-z][A-Za-z0-9_.-]*)["'']', ... "tokens", "once"); testCase.assertNotEmpty(token, ... - "App definition must declare one literal stable Id."); + "App definition must declare one literal stable AppId."); ids(k) = string(token{1}); end testCase.verifyEqual(numel(unique(ids, 'stable')), numel(ids), ... diff --git a/tests/cases/contract/project/packages/PackageDependencyBoundariesTest.m b/tests/cases/contract/project/packages/PackageDependencyBoundariesTest.m index 71019906e..757d5f987 100644 --- a/tests/cases/contract/project/packages/PackageDependencyBoundariesTest.m +++ b/tests/cases/contract/project/packages/PackageDependencyBoundariesTest.m @@ -14,7 +14,7 @@ function packageWordChecksUseMatlabTokenBoundaries(testCase) testCase.verifyTrue(h.sourceContainsForbiddenWord( ... "tableHandle = uitable(parent);", "uitable")); testCase.verifyTrue(h.sourceContainsForbiddenWord( ... - "labkit.ui.runtime.launch", "labkit.ui")); + "labkit.app.Definition", "labkit.app")); end end end @@ -29,12 +29,12 @@ function verify_package_dependency_boundaries() workflowWords = h.experimentWorkflowWords(); h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+dta', 'private'), ... - [guiWords {'labkit.ui', 'apps/'} appWords workflowWords], ... + [guiWords {'labkit.app', 'apps/'} appWords workflowWords], ... 'DTA private implementation'); h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+dta'), ... [guiWords {'apps/', 'labkit.io', 'labkit.data'} appWords], ... 'Reusable +labkit DTA facade'); - rhsForbidden = [guiWords {'apps/', 'labkit.ui', 'labkit.dta', ... + rhsForbidden = [guiWords {'apps/', 'labkit.app', 'labkit.dta', ... 'labkit.biosignal', 'protocol', 'stimulus train', 'CAP'} appWords workflowWords]; h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+rhs'), ... rhsForbidden, ... @@ -43,48 +43,13 @@ function verify_package_dependency_boundaries() rhsForbidden, ... 'RHS private implementation'); h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+biosignal'), ... - [guiWords {'apps/', 'labkit.ui', 'labkit.dta'} appWords], ... + [guiWords {'apps/', 'labkit.app', 'labkit.dta'} appWords], ... 'Reusable +labkit biosignal facade'); h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+biosignal', 'private'), ... - [guiWords {'apps/', 'labkit.ui', 'labkit.dta'} appWords], ... + [guiWords {'apps/', 'labkit.app', 'labkit.dta'} appWords], ... 'Biosignal private implementation'); h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+image'), ... - [guiWords {'apps/', 'labkit.ui', 'labkit.dta', 'labkit.rhs', ... + [guiWords {'apps/', 'labkit.app', 'labkit.dta', 'labkit.rhs', ... 'labkit.biosignal'} appWords], ... 'Reusable +labkit image facade'); - uiForbidden = [{'DTA', 'Gamry', 'labkit.dta', 'labkit.io', ... - 'labkit.data', 'labkit.analysis', 'apps/'} appWords]; - h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui'), ... - uiForbidden, ... - 'Reusable +labkit UI root'); - h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+runtime'), ... - uiForbidden, ... - 'Reusable +labkit UI runtime facade'); - h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+runtime', 'private'), ... - uiForbidden, ... - 'Reusable +labkit UI runtime private implementation'); - h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+plot'), ... - uiForbidden, ... - 'Reusable +labkit UI plot facade'); - h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+plot', 'private'), ... - uiForbidden, ... - 'Reusable +labkit UI plot private implementation'); - h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+layout'), ... - uiForbidden, ... - 'Reusable +labkit UI layout facade'); - h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+layout', 'private'), ... - uiForbidden, ... - 'Reusable +labkit UI layout private implementation'); - h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+interaction'), ... - uiForbidden, ... - 'Reusable +labkit UI interaction facade'); - h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+interaction', 'private'), ... - uiForbidden, ... - 'Reusable +labkit UI interaction private implementation'); - h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+debug'), ... - uiForbidden, ... - 'Reusable +labkit UI debug facade'); - h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+ui', '+debug', 'private'), ... - uiForbidden, ... - 'Reusable +labkit UI debug private implementation'); end diff --git a/tests/cases/contract/project/packages/PackageFacadeContractTest.m b/tests/cases/contract/project/packages/PackageFacadeContractTest.m index 9399fbf16..59e47ef55 100644 --- a/tests/cases/contract/project/packages/PackageFacadeContractTest.m +++ b/tests/cases/contract/project/packages/PackageFacadeContractTest.m @@ -5,7 +5,7 @@ function facadeVersionsAreValid(testCase) setupLabKitTestPath(); versions = currentVersions(); - expectedFacades = ["ui"; "dta"; "rhs"; "biosignal"; "image"; "thermal"]; + expectedFacades = ["app"; "dta"; "rhs"; "biosignal"; "image"; "thermal"]; testCase.verifyEqual(sort([versions.facade].'), sort(expectedFacades)); for k = 1:numel(versions) @@ -57,11 +57,11 @@ function appVersionsAreValidAndExposed(testCase) function incompatibleRequirementsProduceClearFailures(testCase) setupLabKitTestPath(); - req = labkit.contract.requirements("ui", ">=99.0 <100"); + req = labkit.contract.requirements("app", ">=99.0 <100"); report = labkit.contract.checkRequirements(req); testCase.verifyFalse(report.ok); - testCase.verifyTrue(contains(report.message, "labkit.ui")); + testCase.verifyTrue(contains(report.message, "labkit.app")); testCase.verifyTrue(contains(report.message, ... "does not satisfy the app requirement >=99.0 <100")); testCase.verifyError(@() labkit.contract.assertRequirements("probe_app", req), ... @@ -81,9 +81,9 @@ function defaultRequirementSourceIncludesThermal(testCase) function futureRequirementsFailEvenWhenRangesIntersect(testCase) setupLabKitTestPath(); - versions = labkit.contract.versionInfo("ui", "2.2.1", ">=2.0 <3", ... - "stable", "Probe UI contract."); - req = labkit.contract.requirements("ui", ">=2.9 <3"); + versions = labkit.contract.versionInfo("app", "2.2.1", ">=2.0 <3", ... + "stable", "Probe App SDK contract."); + req = labkit.contract.requirements("app", ">=2.9 <3"); report = labkit.contract.checkRequirements(req, versions); testCase.verifyFalse(report.ok); @@ -114,7 +114,7 @@ function validateAppVersionShape(testCase, info, appName) function versions = currentVersions() versions = [ - labkit.ui.version() + labkit.app.version() labkit.dta.version() labkit.rhs.version() labkit.biosignal.version() diff --git a/tests/cases/contract/project/packages/PackagePublicSurfaceTest.m b/tests/cases/contract/project/packages/PackagePublicSurfaceTest.m index 295c7bbbb..79d98a57f 100644 --- a/tests/cases/contract/project/packages/PackagePublicSurfaceTest.m +++ b/tests/cases/contract/project/packages/PackagePublicSurfaceTest.m @@ -15,8 +15,54 @@ function verify_package_public_surface() root = testRepoRoot(); h = architectureTestHelpers(); - assert(exist(fullfile(root, '+labkit', '+app'), 'dir') ~= 7, ... - 'Reusable +labkit should not expose a generic +app package.'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+app'), ... + {'CallbackContext.m', 'Definition.m', 'version.m'}, ... + 'LabKit App SDK root'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+app', '+layout'), ... + {'button.m', 'dataTable.m', 'field.m', 'fileList.m', 'group.m', ... + 'logPanel.m', 'plotArea.m', 'rangeField.m', 'section.m', 'slider.m', ... + 'statusPanel.m', 'tab.m', 'workbench.m', 'workspace.m'}, ... + 'LabKit App SDK layout authoring'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+app', '+view'), ... + {'Snapshot.m'}, 'LabKit App SDK view snapshots'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+app', '+event'), ... + {'IntervalScroll.m', 'ListSelection.m', 'TableCellEdit.m', ... + 'TableCellSelection.m'}, ... + 'LabKit App SDK callback events'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+app', '+project'), ... + {'emptySourceRecords.m', 'Schema.m', 'sourceRecord.m'}, ... + 'LabKit App SDK project persistence'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+app', '+result'), ... + {'File.m', 'Package.m'}, 'LabKit App SDK result export'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+app', '+dialog'), ... + {'Choice.m'}, 'LabKit App SDK dialog results'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+app', '+diagnostic'), ... + {'Artifact.m', 'Options.m', 'SampleContext.m', 'SamplePack.m'}, ... + 'LabKit App SDK diagnostics'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+app', '+interaction'), ... + {'anchorPath.m', 'interpolateAnchorPath.m', 'interval.m', ... + 'pairedAnchors.m', 'pointSlots.m', 'rectangle.m', ... + 'regionSelection.m', 'scaleBarGeometry.m', ... + 'scaleCalibration.m', 'scaleReference.m'}, ... + 'LabKit App SDK managed interactions'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+app', '+plot'), ... + {'clampPointToAxes.m', 'clearAxes.m', 'enablePopout.m', ... + 'fitAxesToGraphics.m', 'fitCanvasToSource.m', ... + 'offsetPointByAxesFraction.m', 'showMessage.m'}, ... + 'LabKit App SDK plot helpers'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+app', '+internal'), ... + {'CallbackContextFactory.m', 'CompiledDefinition.m', ... + 'DefinitionInspector.m', 'DiagnosticRecorder.m', ... + 'HeadlessPlatformAdapter.m', 'InteractionSpec.m', ... + 'LayoutNode.m', 'LayoutNodeValues.m', ... + 'NativeAdapterValues.m', 'OptionParser.m', ... + 'PortableSourceStore.m', 'ProjectDocumentStore.m', ... + 'ResourceStore.m', 'ResultWriter.m', 'RuntimeFactory.m', ... + 'RuntimeContractBoundary.m', 'RuntimeKernel.m', ... + 'RuntimePresentation.m', 'RuntimeStatePath.m', ... + 'SignalBinding.m'}, ... + 'LabKit App SDK private implementation'); + assertNativeAdapterClassFolder(root); assert(exist(fullfile(root, '+labkit', '+task'), 'dir') ~= 7, ... 'App task lifecycle semantics should stay app-owned, not in reusable +labkit/+task.'); assert(exist(fullfile(root, '+labkit', '+plot'), 'dir') ~= 7, ... @@ -35,54 +81,8 @@ function verify_package_public_surface() 'versionInfo.m'}, ... 'LabKit contract facade'); - h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui'), {'version.m'}, ... - 'Layered +labkit UI root'); - assertNoPublicPackage(fullfile(root, '+labkit', '+ui', '+app'), ... - 'UI runtime helpers should live under +runtime, not +app.'); - assertNoPublicPackage(fullfile(root, '+labkit', '+ui', '+spec'), ... - 'UI layout constructors should live under +layout, not +spec.'); - assertNoPublicPackage(fullfile(root, '+labkit', '+ui', '+view'), ... - 'UI control and plot helpers should not be mixed under +view.'); - assertNoPublicPackage(fullfile(root, '+labkit', '+ui', '+tool'), ... - 'UI interaction helpers should live under +interaction, not +tool.'); - assertNoPublicPackage(fullfile(root, '+labkit', '+ui', '+diag'), ... - 'UI debug helpers should live under +debug, not +diag.'); - h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+runtime'), ... - {'create.m', 'defaultOutputFolder.m', 'define.m', ... - 'emptySourceRecords.m', 'launch.m', 'loadState.m', 'saveState.m', ... - 'sourcePaths.m', 'sourceRecord.m'}, ... - 'UI runtime facade'); - assertPrivatePackageHasMFiles(fullfile(root, '+labkit', '+ui', '+runtime', 'private'), ... - 'UI runtime private implementation'); - h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+debug'), ... - {'context.m'}, ... - 'UI debug facade'); - assertPrivatePackageHasMFiles(fullfile(root, '+labkit', '+ui', '+debug', 'private'), ... - 'UI debug private implementation'); - assertNoPublicPackage(fullfile(root, '+labkit', '+ui', '+control'), ... - 'Retired UI control mutation facade must not exist.'); - h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+plot'), ... - {'clampData.m', 'clear.m', 'fit.m', 'fitCanvas.m', 'message.m', ... - 'offsetData.m'}, ... - 'UI plot facade'); - assertPrivatePackageHasMFiles(fullfile(root, '+labkit', '+ui', '+plot', 'private'), ... - 'UI plot private implementation'); - h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+layout'), ... - {'action.m', 'field.m', 'group.m', ... - 'logPanel.m', 'panner.m', 'previewArea.m', ... - 'rangeField.m', 'resultTable.m', 'section.m', 'statusPanel.m', ... - 'tab.m', 'filePanel.m', ... - 'workbench.m', 'workspace.m'}, ... - 'UI layout facade'); - assertPrivatePackageHasMFiles(fullfile(root, '+labkit', '+ui', '+layout', 'private'), ... - 'UI layout private implementation'); - h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+interaction'), ... - {'anchorPath.m', 'enablePopout.m', 'scaleBarCalibration.m', ... - 'scaleBarGeometry.m'}, ... - 'UI interaction facade'); - assertPrivatePackageHasMFiles(fullfile(root, '+labkit', '+ui', '+interaction', 'private'), ... - 'UI interaction private implementation'); - assertNoDuplicateUiPrivateHelperNames(root); + assert(isempty(dir(fullfile(root, '+labkit', '+ui', '**', '*.m'))), ... + 'Retired labkit.ui facade must keep no MATLAB source.'); h.assertTopLevelMFiles(fullfile(root, '+labkit', '+dta'), ... {'detectPulses.m', 'detectType.m', 'findFiles.m', ... @@ -150,6 +150,25 @@ function assertPrivatePackageHasMFiles(packageDir, label) [label ' package should keep private implementation files.']); end +function assertNativeAdapterClassFolder(root) + classDir = fullfile(root, '+labkit', '+app', '+internal', ... + '@MatlabPlatformAdapter'); + assert(exist(fullfile(classDir, 'MatlabPlatformAdapter.m'), 'file') == 2, ... + 'Native adapter class definition should remain in its class folder.'); + files = string({dir(fullfile(classDir, '*.m')).name}); + responsibilityAnchors = [ ... + "buildTree.m", "createComponent.m", "installWorkbenchLayout.m", ... + "applyView.m", "renderPlot.m", "installCallbacks.m"]; + assert(all(ismember(responsibilityAnchors, files)), ... + 'Native adapter class folder should own layout and reconciliation methods.'); + lifecycleGlue = [ ... + "attachRuntime.m", "chooseFiles.m", "reconcile.m", "requestClose.m"]; + assert(~any(ismember(lifecycleGlue, files)), ... + 'Native adapter lifecycle and callback glue should stay in the class definition.'); + assert(numel(files) >= 20 && numel(files) <= 50, ... + 'Native adapter class-folder split should remain cohesive, not monolithic or fragmented.'); +end + function assertNoDuplicateUiPrivateHelperNames(root) privateDirs = { fullfile(root, '+labkit', '+ui', '+runtime', 'private') diff --git a/tests/cases/contract/project/release/DocumentationHistoryGuardrailTest.m b/tests/cases/contract/project/release/DocumentationHistoryGuardrailTest.m index 0378088ed..4787258f3 100644 --- a/tests/cases/contract/project/release/DocumentationHistoryGuardrailTest.m +++ b/tests/cases/contract/project/release/DocumentationHistoryGuardrailTest.m @@ -108,7 +108,7 @@ function generatedPagesAggregateRelatedHistory(testCase) testCase.verifyTrue(contains(dicPage, "Change history") && ... contains(dicPage, "dic-rigid-point-editor")); testCase.verifyTrue(contains(frameworkPage, "Change history") && ... - contains(frameworkPage, "runtime-v2-app-migration")); + contains(frameworkPage, "ui-explicit-contract-migration")); end end end diff --git a/tests/cases/contract/project/release/VersionChangeGuardrailTest.m b/tests/cases/contract/project/release/VersionChangeGuardrailTest.m index 367d8a029..6288adbfe 100644 --- a/tests/cases/contract/project/release/VersionChangeGuardrailTest.m +++ b/tests/cases/contract/project/release/VersionChangeGuardrailTest.m @@ -115,7 +115,7 @@ function finalVersionsUseOneStepFromMainlineBaseline(testCase) end if startsWith(artifact.label, "labkit.") facade = extractAfter(artifact.label, "labkit."); - if facade == "ui" + if any(facade == ["app", "ui"]) path = "docs/framework/README.md"; else path = "docs/libraries/" + facade + "/README.md"; diff --git a/tests/cases/contract/project/runtime/AppSdkUsageGuardrailTest.m b/tests/cases/contract/project/runtime/AppSdkUsageGuardrailTest.m new file mode 100644 index 000000000..c76204db7 --- /dev/null +++ b/tests/cases/contract/project/runtime/AppSdkUsageGuardrailTest.m @@ -0,0 +1,103 @@ +classdef AppSdkUsageGuardrailTest < matlab.unittest.TestCase + %APPSDKUSAGEGUARDRAIL Guard Apps toward the public App SDK boundary. + + methods (Test, TestTags = {'Integration', 'Style'}) + function appsUseOnlyPublicAppSdkNames(testCase) + setupLabKitTestPath(); + root = testRepoRoot(); + files = appSourceFiles(root); + forbidden = [ + "labkit.app.internal." + "labkitUiRegistry" + "labkitUiAppRuntime" + ]; + + hits = strings(0, 1); + for k = 1:numel(files) + source = fileread(files(k)); + for n = 1:numel(forbidden) + if contains(source, forbidden(n)) + hits(end+1, 1) = relativePath(root, files(k)) + ... + " uses " + forbidden(n); + end + end + end + + testCase.verifyEmpty(hits, ... + "Apps should use only public App SDK contracts: " + ... + strjoin(hits, "; ")); + end + + function appsDoNotReimplementPlotClear(testCase) + setupLabKitTestPath(); + root = testRepoRoot(); + files = appSourceFiles(root); + hits = strings(0, 1); + for k = 1:numel(files) + source = fileread(files(k)); + clearsGraphics = contains(source, "delete(allchild(") || ... + contains(source, "delete(ax.Children)") || ... + contains(source, "delete(axesHandle.Children)"); + resetsAxes = contains(source, "XLimMode = 'auto'") && ... + contains(source, "YLimMode = 'auto'"); + clearsLegend = contains(source, "legend(ax, 'off'") || ... + contains(source, "legend(axesHandle, 'off'"); + if clearsGraphics && resetsAxes && clearsLegend + hits(end+1, 1) = relativePath(root, files(k)); + end + end + + testCase.verifyEmpty(hits, ... + "Apps should call labkit.app.plot.clearAxes instead of reimplementing axes cleanup: " + ... + strjoin(hits, ", ")); + end + + function appActionsOwnExplanatoryTooltips(testCase) + root = setupLabKitTestPath(); + definitions = dir(fullfile(root, "apps", "**", "definition.m")); + hits = strings(0, 1); + for k = 1:numel(definitions) + packageFolder = string(definitions(k).folder); + [~, packageName] = fileparts(packageFolder); + packageName = extractAfter(packageName, 1); + definition = feval(packageName + ".definition"); + plan = labkit.app.internal.DefinitionInspector.platformPlan( ... + definition); + for n = 1:numel(plan.Nodes) + node = plan.Nodes(n); + config = node.Configuration; + if node.Kind == "button" && ... + config.Tooltip == config.Label + hits(end + 1, 1) = packageName + ":" + node.Id + ... + " repeats its label"; + elseif node.Kind == "fileList" && ... + config.ChooseTooltip == config.ChooseLabel + hits(end + 1, 1) = packageName + ":" + node.Id + ... + " repeats its choose label"; + end + end + end + + testCase.verifyEmpty(hits, ... + "App actions should explain their scientific or workflow " + ... + "effect instead of repeating the visible label: " + ... + strjoin(hits, "; ")); + end + end +end + +function files = appSourceFiles(root) + scope = labkitQualityScanScope(root); + files = scope.appMFiles; +end + +function rel = relativePath(root, filepath) + root = char(root); + filepath = char(filepath); + prefix = [root filesep]; + if startsWith(filepath, prefix) + rel = string(filepath(numel(prefix)+1:end)); + else + rel = string(filepath); + end +end diff --git a/tests/cases/contract/project/runtime/UiFacadeUsageGuardrailTest.m b/tests/cases/contract/project/runtime/UiFacadeUsageGuardrailTest.m deleted file mode 100644 index f016631e6..000000000 --- a/tests/cases/contract/project/runtime/UiFacadeUsageGuardrailTest.m +++ /dev/null @@ -1,73 +0,0 @@ -classdef UiFacadeUsageGuardrailTest < matlab.unittest.TestCase - %UIFACADEUSAGEGUARDRAIL Guard app code toward the UI5 facade split. - - methods (Test, TestTags = {'Integration', 'Style'}) - function test_apps_use_ui5_facade_names(testCase) - setupLabKitTestPath(); - root = testRepoRoot(); - files = appSourceFiles(root); - forbidden = [ - "labkit.ui.app." - "labkit.ui.spec." - "labkit.ui.view." - "labkit.ui.tool." - "labkit.ui.diag." - ]; - - hits = strings(0, 1); - for k = 1:numel(files) - source = fileread(files(k)); - for n = 1:numel(forbidden) - if contains(source, forbidden(n)) - hits(end+1, 1) = relativePath(root, files(k)) + ... - " uses " + forbidden(n); - end - end - end - - testCase.verifyEmpty(hits, ... - "Apps should use UI5 runtime/layout/control/plot/interaction/debug facades: " + ... - strjoin(hits, "; ")); - end - - function test_apps_do_not_reimplement_plot_clear(testCase) - setupLabKitTestPath(); - root = testRepoRoot(); - files = appSourceFiles(root); - hits = strings(0, 1); - for k = 1:numel(files) - source = fileread(files(k)); - clearsGraphics = contains(source, "delete(allchild(") || ... - contains(source, "delete(ax.Children)") || ... - contains(source, "delete(axesHandle.Children)"); - resetsAxes = contains(source, "XLimMode = 'auto'") && ... - contains(source, "YLimMode = 'auto'"); - clearsLegend = contains(source, "legend(ax, 'off'") || ... - contains(source, "legend(axesHandle, 'off'"); - if clearsGraphics && resetsAxes && clearsLegend - hits(end+1, 1) = relativePath(root, files(k)); - end - end - - testCase.verifyEmpty(hits, ... - "Apps should call labkit.ui.plot.clear instead of reimplementing axes cleanup: " + ... - strjoin(hits, ", ")); - end - end -end - -function files = appSourceFiles(root) - scope = labkitQualityScanScope(root); - files = scope.appMFiles; -end - -function rel = relativePath(root, filepath) - root = char(root); - filepath = char(filepath); - prefix = [root filesep]; - if startsWith(filepath, prefix) - rel = string(filepath(numel(prefix)+1:end)); - else - rel = string(filepath); - end -end diff --git a/tests/cases/gui/apps/dic/dic_postprocess/GuiLayoutDicPostprocessTest.m b/tests/cases/gui/apps/dic/dic_postprocess/GuiLayoutDicPostprocessTest.m index 01894a462..276574b2a 100644 --- a/tests/cases/gui/apps/dic/dic_postprocess/GuiLayoutDicPostprocessTest.m +++ b/tests/cases/gui/apps/dic/dic_postprocess/GuiLayoutDicPostprocessTest.m @@ -8,7 +8,7 @@ function dic_postprocess_workflow_generates_overlays_and_summary(testCase) h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - folder = tempname; + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); matPath = fullfile(folder, 'synthetic_dic.mat'); @@ -18,54 +18,42 @@ function dic_postprocess_workflow_generates_overlays_and_summary(testCase) imwrite(syntheticReferenceImage(), referencePath); imwrite(uint8(255 .* syntheticMask()), maskPath); - fig = h.launchFigure('labkit_DICPostprocess_app', ... - 'DIC Strain Postprocess'); + summaryPath = fullfile(folder, ... + 'synthetic_dic_strain_summary.csv'); + backend = struct( ... + "chooseOutputFolder", @(~) ... + labkit.app.dialog.Choice(folder), ... + "chooseOutputFile", @(~, ~) ... + labkit.app.dialog.Choice(summaryPath), ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + dic_postprocess.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); assertDicPostprocessLayout(h, fig); - driver = labkitWorkflowDriver(fig); - driver.chooseFiles('matFile', matPath); - driver.chooseFiles('referenceFile', referencePath); - driver.chooseFiles('maskFile', maskPath); + runtime.applyFileSelection('matFile', matPath, 1); + runtime.applyFileSelection('referenceFile', referencePath, 1); + runtime.applyFileSelection('maskFile', maskPath, 1); + runtime.invokeAction('generate'); - driver.click('Choose DIC MAT'); - driver.click('Choose reference'); - driver.click('Choose mask'); - driver.click('Generate overlays + summary'); - [generated, waitInfo] = h.waitForCondition(fig, ... - @() dicPostprocessGenerated(driver), 15); + testCase.verifyGreaterThan(height( ... + runtime.State.project.results.summaryTable), 0); + testCase.verifyNotEmpty(runtime.State.session.cache.overlayExx); + testCase.verifyNotEmpty(runtime.State.session.cache.overlayEyy); + exxAxes = findall(fig, 'Tag', 'overlayAxes.exx'); + eyyAxes = findall(fig, 'Tag', 'overlayAxes.eyy'); + testCase.verifyNotEmpty(exxAxes.Children); + testCase.verifyNotEmpty(eyyAxes.Children); + runtime.applyControlValue("alpha", 0.45); + runtime.applyControlValue("brightness", 0.1); + testCase.verifyEqual( ... + runtime.State.project.parameters.alpha, 0.45); + testCase.verifyEqual( ... + runtime.State.project.parameters.brightness, 0.1); + testCase.verifyNotEmpty(runtime.State.session.cache.overlayExx); - ui = driver.registry(); - testCase.verifyTrue(contains(string(ui.controls.matFile.status.Value), ... - 'synthetic_dic.mat'), ... - 'DIC postprocess workflow should show the loaded MAT file.'); - testCase.verifyTrue(contains(string(ui.controls.referenceFile.status.Value), ... - 'reference.png'), ... - 'DIC postprocess workflow should show the loaded reference image.'); - testCase.verifyTrue(contains(string(ui.controls.maskFile.status.Value), ... - 'mask.png'), ... - 'DIC postprocess workflow should show the loaded mask image.'); - testCase.verifyTrue(generated, sprintf(['DIC postprocess workflow should ' ... - 'generate overlays and summary. %s'], ... - h.waitDiagnostic(waitInfo, 'appLog', appLog(driver)))); - data = driver.tableData('resultTable'); - testCase.verifyGreaterThan(size(data, 1), 0, ... - 'DIC postprocess workflow should populate the strain summary table.'); - testCase.verifyTrue(any(contains(string(driver.textAreaValue('summaryText')), ... - 'Overlays: available')), ... - 'DIC postprocess workflow should report generated overlays.'); - testCase.verifyGreaterThan(numel(ui.controls.overlayAxes.axesById.exx.Children), 0, ... - 'DIC postprocess workflow should draw the EXX overlay.'); - testCase.verifyGreaterThan(numel(ui.controls.overlayAxes.axesById.eyy.Children), 0, ... - 'DIC postprocess workflow should draw the EYY overlay.'); - - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'DIC postprocess workflow must execute through runtime V2.'); - runtime.request.outputFolderChooser = @(~, ~) folder; - runtime.request.outputFileChooser = @(~, ~, ~) deal( ... - 'synthetic_dic_strain_summary.csv', folder); - setappdata(fig, 'labkitUiAppRuntime', runtime); - driver.click('Save overlay PNGs'); - driver.click('Export summary CSV'); + runtime.invokeAction('saveOverlays'); + runtime.invokeAction('exportSummary'); expectedOutputs = { ... 'overlay_exx_unknown_mm.png', ... 'overlay_eyy_unknown_mm.png', ... @@ -88,7 +76,7 @@ function dic_postprocess_workflow_generates_overlays_and_summary(testCase) "success"); projectPath = fullfile(folder, 'dic-postprocess-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); testCase.verifyEqual(string(saved.labkitProject.format), ... "labkit.project"); @@ -96,59 +84,40 @@ function dic_postprocess_workflow_generates_overlays_and_summary(testCase) testCase.verifyEqual(fieldnames(saved.labkitProject.payload.inputs), ... {'sources'}, ... 'Saved projects should rebuild decoded DIC inputs from sources.'); - cla(ui.controls.overlayAxes.axesById.exx); - cla(ui.controls.overlayAxes.axesById.eyy); - labkit.ui.runtime.loadState(fig, projectPath); - testCase.verifyGreaterThan(size(driver.tableData('resultTable'), 1), 0, ... - 'Project reopen should restore the durable summary.'); - testCase.verifyGreaterThan( ... - numel(ui.controls.overlayAxes.axesById.exx.Children), 0, ... - 'Project reopen should rebuild the EXX overlay cache.'); - testCase.verifyGreaterThan( ... - numel(ui.controls.overlayAxes.axesById.eyy.Children), 0, ... - 'Project reopen should rebuild the EYY overlay cache.'); + runtime.applyFileSelection( ... + 'matFile', strings(1, 0), zeros(1, 0)); + runtime.restoreProject(projectPath); + testCase.verifyGreaterThan(height( ... + runtime.State.project.results.summaryTable), 0); + testCase.verifyNotEmpty(runtime.State.session.cache.overlayExx); + testCase.verifyNotEmpty(runtime.State.session.cache.overlayEyy); + clear runtimeCleanup end end end -function tf = dicPostprocessGenerated(driver) - ui = driver.registry(); - data = driver.tableData('resultTable'); - tf = size(data, 1) > 0 && ... - any(contains(string(driver.textAreaValue('summaryText')), ... - 'Overlays: available')) && ... - numel(ui.controls.overlayAxes.axesById.exx.Children) > 0 && ... - numel(ui.controls.overlayAxes.axesById.eyy.Children) > 0; -end - -function text = appLog(driver) - text = strjoin(string(driver.logValue('appLog')), ' | '); -end - function assertDicPostprocessLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Choose DIC MAT', ... - 'Choose reference', 'Choose mask', ... - 'Generate overlays + summary', ... - 'Save overlay PNGs', 'Export summary CSV'}); - h.assertTabTitles(fig, {'Files + Analysis', 'Summary + Results', 'Log'}); - assertFilesAnalysisSectionsFit(fig); -end - -function assertFilesAnalysisSectionsFit(fig) - ui = getappdata(fig, 'labkitUiRegistry'); - sectionIds = {'inputsSection', 'overlayOptions', 'imageOptions', ... - 'exportsSection'}; - layoutProps = {'height', 'minRows', 'minHeight', 'maxColumns', ... - 'rowSpacing', 'columnSpacing', 'padding', 'chrome', ... - 'columnWidth', 'rowHeight', 'position', 'leftWidth'}; - for k = 1:numel(sectionIds) - props = ui.sections.(sectionIds{k}).layout.props; - for iProp = 1:numel(layoutProps) - assert(~isfield(props, layoutProps{iProp}), ... - 'DIC postprocess sections should use framework-owned layout.'); - end + h.assertStartupSucceeded(fig); + ids = ["matFile", "referenceFile", "maskFile", "generate", ... + "alpha", "colorMin", "colorMax", "oversample", ... + "smoothSigma", "edgeTrim", "brightness", "contrast", ... + "gamma", "saturation", "redGain", "greenGain", "blueGain", ... + "saveOverlays", "exportSummary", "resultTable", ... + "summaryText", "overlayAxes.exx", "overlayAxes.eyy"]; + for id = ids + assert(numel(findall(fig, "Tag", id)) == 1, ... + "Missing DIC postprocess semantic target: %s.", id); end + tabs = findall(fig, "Type", "uitab"); + assert(isequal(sort(string({tabs.Title})), ... + sort(["Files + Analysis", "Summary + Results", "Log"]))); + assert(numel(findall(fig, "Title", "Strain Overlays")) >= 2); + generate = findall(fig, "Tag", "generate"); + chooseMat = findall(fig, "Tag", "matFile.choose"); + assert(contains(string(generate.Tooltip), "finite EXX/EYY")); + assert(contains(string(chooseMat.Tooltip), "Ncorr MAT")); + assert(~contains(string(generate.Tooltip), newline)); + assert(~contains(string(chooseMat.Tooltip), newline)); end function writeSyntheticDicMat(filepath) diff --git a/tests/cases/gui/apps/dic/dic_preprocess/GuiLayoutDicPreprocessTest.m b/tests/cases/gui/apps/dic/dic_preprocess/GuiLayoutDicPreprocessTest.m index 1ac5687e9..b4ed9b458 100644 --- a/tests/cases/gui/apps/dic/dic_preprocess/GuiLayoutDicPreprocessTest.m +++ b/tests/cases/gui/apps/dic/dic_preprocess/GuiLayoutDicPreprocessTest.m @@ -1,213 +1,90 @@ classdef GuiLayoutDicPreprocessTest < matlab.unittest.TestCase - %GUILAYOUTDICPREPROCESSTEST Verify DIC preprocess GUI layout contracts. - + % Verify DIC Preprocess through the explicit App SDK runtime. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) - function dic_preprocess_workflow_loads_and_auto_aligns_pair(testCase) + function nativeLayoutUsesSemanticTargets(testCase) setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - - folder = tempname; - mkdir(folder); - folderCleanup = onCleanup(@() removeTempFolder(folder)); - referencePath = fullfile(folder, 'reference.png'); - movingPath = fullfile(folder, 'moving.png'); - reference = syntheticDicImage(); - imwrite(reference, referencePath); - imwrite(reference, movingPath); - - fig = h.launchFigure('labkit_DICPreprocess_app', ... - 'DIC Image Preprocess'); - assertDicPreprocessLayout(h, fig); - driver = labkitWorkflowDriver(fig); - driver.chooseFiles('referenceFile', referencePath); - driver.chooseFiles('movingFile', movingPath); - - driver.click('Choose reference'); - driver.click('Choose moving'); - driver.click('Auto align current pair'); - [aligned, waitInfo] = h.waitForCondition(fig, ... - @() dicPreprocessAlignmentReady(driver), 15); - - ui = driver.registry(); - testCase.verifyTrue(contains(string(ui.controls.referenceFile.status.Value), ... - 'reference.png'), ... - 'DIC preprocess workflow should show the loaded reference image.'); - testCase.verifyTrue(contains(string(ui.controls.movingFile.status.Value), ... - 'moving.png'), ... - 'DIC preprocess workflow should show the loaded moving image.'); - testCase.verifyEqual(string(ui.controls.previewMode.valueHandle.Value), ... - "False-color overlay", ... - 'DIC preprocess workflow should switch to false-color overlay after auto align.'); - testCase.verifyTrue(any(contains(string(driver.textAreaValue('summaryText')), ... - 'Reference')), ... - 'DIC preprocess workflow should refresh the summary after loading images.'); - testCase.verifyTrue(aligned, sprintf(['DIC preprocess workflow should ' ... - 'refresh alignment details after auto align. %s'], ... - h.waitDiagnostic(waitInfo, 'appLog', appLog(driver)))); - testCase.verifyGreaterThan(numel(ui.controls.previewAxes.axesById.reference.Children), 0, ... - 'DIC preprocess workflow should draw the reference preview.'); - testCase.verifyGreaterThan(numel(ui.controls.previewAxes.axesById.current.Children), 0, ... - 'DIC preprocess workflow should draw the current preview.'); - driver.click('Start/reset crop ROI'); - assertCropRectangleDragStarts(fig); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + cleanup = onCleanup(@() helpers.closeAllFigures()); + figure = labkit_DICPreprocess_app(); + ids = ["referenceFile", "movingFile", "startPointMatching", ... + "applyPointAlignment", "autoAlign", "startCropRoi", ... + "applyCropRoi", "startMaskEdit", "saveCurrentImages", ... + "saveMask", "preview.reference", "preview.moving"]; + for id = ids + testCase.verifyEqual(numel(findall( ... + figure, "Tag", id)), 1); + end + clear cleanup end - function pointMatchingStaysInMainWorkbench(testCase) + function pairDrivesAlignmentMatchingAndCrop(testCase) setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - - folder = tempname; + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); - referencePath = fullfile(folder, 'reference.png'); - movingPath = fullfile(folder, 'moving.png'); + referencePath = fullfile(folder, "reference.png"); + movingPath = fullfile(folder, "moving.png"); imageData = syntheticDicImage(); imwrite(imageData, referencePath); imwrite(imageData, movingPath); - - fig = h.launchFigure('labkit_DICPreprocess_app', ... - 'DIC Image Preprocess'); - driver = labkitWorkflowDriver(fig); - driver.chooseFiles('referenceFile', referencePath); - driver.chooseFiles('movingFile', movingPath); - driver.click('Choose reference'); - driver.click('Choose moving'); - labels = dic_preprocess.userInterface.registrationLabels(); - driver.click(labels.startPointMatching); - - ui = driver.registry(); - testCase.verifyEmpty(findall(groot, 'Type', 'figure', ... - 'Name', 'DIC Manual Alignment'), ... - 'Point matching should remain inside the main DIC workbench.'); - testCase.verifyEqual(string( ... - ui.controls.applyPointAlignment.button.Enable), ... - "off", 'Alignment should wait for at least two complete pairs.'); - testCase.verifyEqual(string( ... - ui.controls.cancelPointMatching.button.Enable), ... - "on", 'The active in-place point matcher should be cancellable.'); - referenceAxes = ui.controls.previewAxes.axesById.reference; - movingAxes = ui.controls.previewAxes.axesById.current; - referenceImage = findobj(referenceAxes, 'Type', 'Image', ... - 'Tag', 'labkitDicPreprocessPreviewImage'); - movingImage = findobj(movingAxes, 'Type', 'Image', ... - 'Tag', 'labkitDicPreprocessPreviewImage'); - referenceAxes.XLim = [10 80]; - referenceAxes.YLim = [10 70]; - addPointPair(fig, [20 20], [20 20]); - addPointPair(fig, [60 40], [60 40]); - ui = driver.registry(); - testCase.verifyEqual(findobj(referenceAxes, 'Type', 'Image', ... - 'Tag', 'labkitDicPreprocessPreviewImage'), referenceImage, ... - 'Point edits should preserve the reference image handle.'); - testCase.verifyEqual(findobj(movingAxes, 'Type', 'Image', ... - 'Tag', 'labkitDicPreprocessPreviewImage'), movingImage, ... - 'Point edits should preserve the moving image handle.'); - testCase.verifyEqual(referenceAxes.XLim, [10 80], ... - 'Point edits should preserve the reference zoom viewport.'); - testCase.verifyEqual(referenceAxes.YLim, [10 70], ... - 'Point edits should preserve the reference zoom viewport.'); - pointLabels = findobj(referenceAxes, 'Type', 'Text', ... - 'Tag', 'labkitDicPreprocessPreviewOverlay'); - testCase.verifyNotEmpty(pointLabels, ... - 'Point matching should render numbered feature labels.'); - testCase.verifyTrue(all(string({pointLabels.Clipping}) == "on"), ... - 'Feature labels must be clipped to their owning preview axes.'); - testCase.verifyEqual(string( ... - ui.controls.applyPointAlignment.button.Enable), ... - "on", 'Two complete point pairs should enable alignment.'); - driver.click(labels.applyPointAlignment); - ui = driver.registry(); - testCase.verifyEqual(string( ... - ui.controls.startPointMatching.button.Enable), ... - "on", 'Applying matched points should leave matching mode.'); - testCase.verifyEqual(string(ui.controls.previewMode.valueHandle.Value), ... - "False-color overlay", ... - 'Point alignment should switch to the comparison preview.'); - clear folderCleanup cleanup + backend = struct("alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + dic_preprocess.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); + + runtime.applyFileSelection("referenceFile", referencePath, 1); + runtime.applyFileSelection("movingFile", movingPath, 1); + + testCase.verifyTrue(dic_preprocess.sourceFiles.hasImagePair( ... + runtime.State.session.cache)); + runtime.invokeAction("autoAlign"); + testCase.verifyEqual( ... + runtime.State.project.parameters.previewMode, ... + "False-color overlay"); + testCase.verifyEqual(numel( ... + runtime.State.project.annotations.editSteps), 1); + referenceAxes = findall(figure, "Tag", "preview.reference"); + movingAxes = findall(figure, "Tag", "preview.moving"); + testCase.verifyNotEmpty(referenceAxes.Children); + testCase.verifyNotEmpty(movingAxes.Children); + + runtime.invokeAction("startPointMatching"); + points = {[20 20; 60 40], [20 20; 60 40]}; + runtime.applyInteraction( ... + "matchPoints", "interactionChanged", points); + runtime.invokeAction("applyPointAlignment"); + testCase.verifyEqual(numel( ... + runtime.State.project.annotations.editSteps), 2); + testCase.verifyEqual( ... + runtime.State.session.workflow.mode, "idle"); + + runtime.invokeAction("startCropRoi"); + runtime.applyInteraction("cropRectangle", ... + "interactionChanged", [10 10 40 40]); + runtime.invokeAction("applyCropRoi"); + testCase.verifyEqual(numel( ... + runtime.State.project.annotations.editSteps), 3); + testCase.verifyLessThan(size( ... + runtime.State.session.cache.currentReferenceImage, 1), ... + size(imageData, 1)); + clear runtimeCleanup folderCleanup end end end -function addPointPair(fig, referencePoint, movingPoint) - runtime = getappdata(fig, 'labkitUiAppRuntime'); - resource = pointPairResource(runtime.resources); - resource.editors{1}.insertPoint(referencePoint); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - resource = pointPairResource(runtime.resources); - resource.editors{2}.insertPoint(movingPoint); -end - -function resource = pointPairResource(resources) - resource = interactionResource(resources, "pointPairs"); -end - -function assertCropRectangleDragStarts(fig) - runtime = getappdata(fig, 'labkitUiAppRuntime'); - resource = interactionResource(runtime.resources, "cropRectangle"); - graphics = resource.editors{1}.graphics(); - box = graphics(find(arrayfun(@(item) isa(item, ... - 'matlab.graphics.primitive.Rectangle'), graphics), 1, 'first')); - assert(~isempty(box), ... - 'DIC crop mode should create one editable rectangle graphic.'); - fig.WindowButtonDownFcn(fig, struct('HitObject', box)); - assert(runtime.interactionHub.isDragging(), ... - 'DIC crop rectangle should start a drag through the Runtime V2 hub.'); - fig.WindowButtonUpFcn(fig, struct()); - assert(~runtime.interactionHub.isDragging(), ... - 'DIC crop rectangle should end its drag on pointer release.'); -end - -function resource = interactionResource(resources, id) - index = find([resources.scope] == "interaction" & ... - [resources.id] == string(id), 1, 'first'); - assert(~isempty(index), ... - 'DIC workflow should own the requested controlled interaction resource.'); - resource = resources(index).value; -end - -function tf = dicPreprocessAlignmentReady(driver) - details = lower(string(driver.textAreaValue('detailsText'))); - tf = any(contains(details, 'transform matrix')); -end - -function text = appLog(driver) - text = strjoin(string(driver.logValue('appLog')), ' | '); -end - -function assertDicPreprocessLayout(h, fig) - labels = dic_preprocess.userInterface.registrationLabels(); - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Choose reference', 'Choose moving', ... - labels.startPointMatching, labels.applyPointAlignment, ... - labels.cancelPointMatching, labels.undoPointPair, labels.autoAlign, ... - 'Start/reset crop ROI', 'Apply ROI crop', 'Cancel ROI', ... - 'Undo align/crop', 'Save current images', 'Reset to originals', ... - 'Start ROI edit', 'Preview ROI mask', 'Add to mask', ... - 'Subtract from mask', 'Undo point', 'Undo mask edit', ... - 'Clear boundary', 'Clear mask', 'Save ROI mask'}); - h.assertDropdownGroups(fig, [ ... - h.dropdownGroup({'Current pair', 'Current moving image', ... - 'False-color overlay', 'Original pair', 'ROI mask'}, 1), ... - h.dropdownGroup({'Curve', 'Straight lines'}, 1)]); - h.assertTabTitles(fig, {'Files + Analysis', 'Summary + Results', 'Log'}); - h.assertDropdownCallbacksPresent(fig); - assert(~isempty(fig.WindowScrollWheelFcn), ... - 'DIC preprocess should install a preview scroll-wheel zoom callback.'); -end - -function img = syntheticDicImage() - [x, y] = meshgrid(1:96, 1:72); - base = 0.35 + 0.25 .* sin(x ./ 6) + 0.25 .* cos(y ./ 5); - dots = mod(round(x ./ 9) + round(y ./ 7), 2) .* 0.12; - img = uint8(255 .* min(max(base + dots, 0), 1)); +function imageData = syntheticDicImage() +[x, y] = meshgrid(1:96, 1:72); +base = 0.35 + 0.25 .* sin(x ./ 6) + 0.25 .* cos(y ./ 5); +dots = mod(round(x ./ 9) + round(y ./ 7), 2) .* 0.12; +imageData = uint8(255 .* min(max(base + dots, 0), 1)); end function removeTempFolder(folder) - if exist(folder, 'dir') == 7 - rmdir(folder, 's'); - end +if exist(folder, "dir") == 7 + rmdir(folder, "s"); +end end diff --git a/tests/cases/gui/apps/electrochem/chrono_overlay/GuiLayoutChronoOverlayTest.m b/tests/cases/gui/apps/electrochem/chrono_overlay/GuiLayoutChronoOverlayTest.m index 8a016e294..4399ed2b5 100644 --- a/tests/cases/gui/apps/electrochem/chrono_overlay/GuiLayoutChronoOverlayTest.m +++ b/tests/cases/gui/apps/electrochem/chrono_overlay/GuiLayoutChronoOverlayTest.m @@ -1,168 +1,82 @@ classdef GuiLayoutChronoOverlayTest < matlab.unittest.TestCase - %GUILAYOUTCHRONOOVERLAYTEST Verify chrono overlay GUI layout contracts. - + % Verify Chrono Overlay through the explicit UI runtime and native adapter. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) - function chrono_overlay_debug_launch_generates_boundary_samples(testCase) + function nativeLayoutUsesSemanticTargets(testCase) setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - - [fig, debug] = labkit_ChronoOverlay_app("debug"); - assertChronoOverlayLayout(h, fig); - h.invokeDropdownValue(fig, 'Time (ms)'); - h.invokeCheckbox(fig, 'Show file-name legend', false); - testCase.verifyTrue(debug.enabled && debug.traceEnabled, ... - 'Chrono overlay debug launch should return an enabled trace logger.'); - testCase.verifyTrue(isfolder(debug.sampleFolder), ... - 'Chrono overlay debug launch should create a controlled samples folder.'); - testCase.verifyTrue(isfolder(debug.outputFolder), ... - 'Chrono overlay debug launch should create a controlled output folder.'); - testCase.verifyTrue(isfile(debug.manifestFile), ... - 'Chrono overlay debug launch should record a sample manifest.'); - - driver = labkitWorkflowDriver(fig); - testCase.verifyEqual(char(driver.fileStatus('files')), 'No files loaded'); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + cleanup = onCleanup(@() helpers.closeAllFigures()); + figure = labkit_ChronoOverlay_app(); - manifestText = string(fileread(debug.manifestFile)); - testCase.verifyTrue(contains(manifestText, 'malformedChronoDta') && ... - contains(manifestText, 'validEdgeChronoDta'), ... - 'Chrono overlay debug manifest should include boundary-test samples.'); + testCase.verifyEqual(numel(findall(figure, "Tag", "files")), 1); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "exportCurves")), 1); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "overlayPlots.voltage")), 1); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "overlayPlots.current")), 1); + clear cleanup end - function chrono_overlay_workflow_loads_and_plots_chrono_files(testCase) + function standardFilesDrivePlotExportAndRestore(testCase) setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + outputFolder = string(tempname); + mkdir(outputFolder); + folderCleanup = onCleanup(@() rmdir(outputFolder, "s")); + csvPath = fullfile(outputFolder, "overlay.csv"); + backend = struct( ... + "chooseOutputFile", @(~, ~) ... + labkit.app.dialog.Choice(csvPath), ... + "alert", @(~, ~) []); + app = chrono_overlay.definition(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + app, [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); fixtures = [ ... - string(dtaFixturePath('chrono_chronopot_current_pulse_0p2ms.DTA')); ... - string(dtaFixturePath('chrono_chronoamp_voltage_pulse_0p2ms.DTA'))]; - fig = h.launchFigure('labkit_ChronoOverlay_app', ... - 'Gamry Multi-DTA Plot Export GUI'); - driver = labkitWorkflowDriver(fig); - driver.chooseFiles('files', fixtures); + string(dtaFixturePath( ... + "chrono_chronopot_current_pulse_0p2ms.DTA")); ... + string(dtaFixturePath( ... + "chrono_chronoamp_voltage_pulse_0p2ms.DTA"))]; - driver.click('Add DTA files'); + runtime.applyFileSelection("files", fixtures, 1:2); - testCase.verifyEqual(char(driver.fileStatus('files')), '2 file(s) loaded'); - testCase.verifyEqual(numel(driver.fileListItems('files')), 2, ... - 'Chrono overlay workflow should list both loaded chrono fixtures.'); - ui = driver.registry(); - axVoltage = ui.controls.overlayPlots.axesById.voltage; - axCurrent = ui.controls.overlayPlots.axesById.current; - testCase.verifyGreaterThan(numel(axVoltage.Children), 0, ... - 'Chrono overlay workflow should draw voltage traces.'); - testCase.verifyGreaterThan(numel(axCurrent.Children), 0, ... - 'Chrono overlay workflow should draw current traces.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'Chrono overlay workflow must execute through runtime V2.'); - testCase.verifyTrue(all(isfield(runtime.state.session, ... - {'selection', 'workflow', 'view', 'cache'})), ... - 'Runtime should supply omitted empty session buckets.'); - originalSourceIds = string( ... - {runtime.state.project.inputs.sources.id}); - driver.selectFile('files', ... - 'chrono_chronopot_current_pulse_0p2ms.DTA'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(numel(runtime.state.session.selection.paths), 1, ... - 'Chrono overlay should commit a one-file selection subset.'); - testCase.verifyTrue(contains(driver.fileSelection('files'), ... - 'chrono_chronopot_current_pulse_0p2ms.DTA'), ... - 'Chrono overlay presentation should preserve the selected subset.'); - driver.click('Remove selected'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(numel(runtime.state.project.inputs.sources), 1); - testCase.verifyEqual( ... - string(runtime.state.project.inputs.sources.id), ... - originalSourceIds(2), ... - 'Removing the first file should preserve the remaining identity.'); - driver.chooseFiles('files', fixtures(1)); - driver.click('Add DTA files'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - reconciledSourceIds = string( ... - {runtime.state.project.inputs.sources.id}); - testCase.verifyEqual(numel(unique(reconciledSourceIds)), ... - numel(reconciledSourceIds), ... - ['Removing and adding a file must not reuse an ID that ' ... - 'collides with a remaining source.']); - testCase.verifyTrue(any(reconciledSourceIds == originalSourceIds(2)), ... - 'Source reconciliation should preserve the remaining file ID.'); - - outputFolder = string(tempname); - mkdir(outputFolder); - outputCleanup = onCleanup(@() rmdir(outputFolder, 's')); - runtime.request.outputChooser = @(~, ~, ~) deal( ... - 'overlay.csv', char(outputFolder)); - setappdata(fig, 'labkitUiAppRuntime', runtime); - driver.click('Export curves CSV'); - csvPath = fullfile(outputFolder, 'overlay.csv'); - manifestPath = fullfile(outputFolder, 'overlay.labkit.json'); - testCase.verifyTrue(isfile(csvPath), ... - 'Chrono overlay should retain its CSV export.'); - testCase.verifyTrue(isfile(manifestPath), ... - 'Chrono overlay should add a standard result manifest.'); - manifest = jsondecode(fileread(manifestPath)); - testCase.verifyEqual(string(manifest.format), "labkit.result"); - testCase.verifyEqual(string(manifest.outputs.status), "success"); - testCase.verifyGreaterThan(double(manifest.outputs.bytes), 0); + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.sources), 2); + testCase.verifyEqual(numel( ... + runtime.State.session.cache.items), 2); + voltage = findall(figure, "Tag", "overlayPlots.voltage"); + current = findall(figure, "Tag", "overlayPlots.current"); + testCase.verifyNotEmpty(voltage.Children); + testCase.verifyNotEmpty(current.Children); - projectPath = fullfile(outputFolder, 'overlay-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); - saved = load(projectPath, 'labkitProject'); - testCase.verifyEqual(string(saved.labkitProject.format), ... - "labkit.project"); - testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 2); - testCase.verifyFalse(isfield( ... - saved.labkitProject.payload.inputs, 'items'), ... - 'Chrono Overlay projects must not persist decoded DTA items.'); - driver.click('Clear all'); - labkit.ui.runtime.loadState(fig, projectPath); - testCase.verifyEqual(char(driver.fileStatus('files')), ... - '2 file(s) loaded', ... - 'Project reopen should replace and restore durable inputs.'); - testCase.verifyGreaterThan(numel(axVoltage.Children), 0); - testCase.verifyGreaterThan(numel(axCurrent.Children), 0); - runtime = getappdata(fig, 'labkitUiAppRuntime'); + runtime.applyFilePanelSelection("files", 1); testCase.verifyEqual( ... - string({runtime.state.project.inputs.sources.id}), ... - reconciledSourceIds, ... - 'Project reopen should preserve reconciled source identities.'); + runtime.State.session.selection.files.Indices, 1); + runtime.invokeAction("exportCurves"); + testCase.verifyTrue(isfile(csvPath)); + testCase.verifyTrue(isfile( ... + fullfile(outputFolder, "labkit_result.json"))); - axVoltage.XLim = [-1 0]; - axVoltage.YLim = [-0.01 0.01]; - driver.dropdown('Time (ms)'); - testCase.verifyTrue(contains(string(axVoltage.XLabel.String), "Time (ms)")); - testCase.verifyTrue(contains(string(axCurrent.XLabel.String), "Time (ms)")); - testCase.verifyFalse(isequal(axVoltage.XLim, [-1 0]), ... - 'Chrono overlay option redraw should replace stale manual X limits.'); - testCase.verifyFalse(isequal(axVoltage.YLim, [-0.01 0.01]), ... - 'Chrono overlay option redraw should replace stale manual Y limits.'); + projectPath = fullfile(outputFolder, "overlay-project.mat"); + runtime.saveProject(runtime.State, projectPath); + runtime.applyFileSelection("files", strings(1, 0), ... + zeros(1, 0)); + testCase.verifyEmpty(runtime.State.session.cache.items); + runtime.restoreProject(projectPath); + testCase.verifyEqual(numel( ... + runtime.State.session.cache.items), 2); - driver.click('Clear all'); - testCase.verifyEqual(char(driver.fileStatus('files')), 'No files loaded'); - testCase.verifyEmpty(axVoltage.Children, ... - 'Chrono overlay clear-all should remove stale voltage plots and legends.'); - testCase.verifyEmpty(axCurrent.Children, ... - 'Chrono overlay clear-all should remove stale current plots and legends.'); - testCase.verifyEqual(axVoltage.XLimMode, 'auto', ... - 'Chrono overlay clear-all should restore automatic X limits.'); - testCase.verifyEqual(axVoltage.YLimMode, 'auto', ... - 'Chrono overlay clear-all should restore automatic Y limits.'); - clear outputCleanup; + voltage.XLim = [-1 0]; + voltage.YLim = [-0.01 0.01]; + runtime.applyBinding("xAxis", "Time (ms)"); + testCase.verifyEqual(voltage.XLim, [-1 0]); + testCase.verifyEqual(voltage.YLim, [-0.01 0.01]); + testCase.verifyTrue(contains( ... + string(voltage.XLabel.String), "Time (ms)")); + clear runtimeCleanup folderCleanup end end end - -function assertChronoOverlayLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Add DTA files', 'Remove selected', ... - 'Clear all', 'Export curves CSV'}); - h.assertCheckboxContract(fig, {'Show file-name legend', 'Show grid'}); - h.assertDropdownGroups(fig, h.dropdownGroup( ... - {'Time (s)', 'Time (ms)', 'Sample #'}, 1)); - h.assertTabTitles(fig, {'Files + Analysis', 'Log'}); - h.assertDropdownCallbacksPresent(fig); -end diff --git a/tests/cases/gui/apps/electrochem/cic/GuiLayoutCicTest.m b/tests/cases/gui/apps/electrochem/cic/GuiLayoutCicTest.m index 81054a55f..60273fe1d 100644 --- a/tests/cases/gui/apps/electrochem/cic/GuiLayoutCicTest.m +++ b/tests/cases/gui/apps/electrochem/cic/GuiLayoutCicTest.m @@ -15,19 +15,25 @@ function cic_workflow_loads_analyzes_and_plots_chrono(testCase) thirdCleanup = onCleanup(@() rmdir(thirdFolder, 's')); thirdFixture = fullfile(thirdFolder, 'chrono_third_pulse.DTA'); copyfile(secondFixture, thirdFixture); - fig = h.launchFigure('labkit_CIC_app', ... - 'Gamry CIC GUI (Voltage Transient)'); + outputFolder = string(tempname); + mkdir(outputFolder); + outputCleanup = onCleanup(@() rmdir(outputFolder, 's')); + backend = struct( ... + "chooseOutputFile", @(~, ~) labkit.app.dialog.Choice( ... + fullfile(outputFolder, "cic_results.csv")), ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + cic.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); assertCicLayout(h, fig); - driver = labkitWorkflowDriver(fig); - driver.chooseFiles('files', fixture); + runtime.applyFileSelection("files", string(fixture), 1); - driver.click('Add DTA files'); - - testCase.verifyEqual(char(driver.fileStatus('files')), '1 file(s) registered'); - testCase.verifyTrue(any(contains(driver.fileListItems('files'), ... - 'chrono_chronopot_current_pulse_0p2ms.DTA')), ... - 'CIC workflow should list the loaded chrono fixture.'); - data = driver.tableData('results'); + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.sources), 1); + testCase.verifyEqual(numel(runtime.State.session.cache.items), 1); + results = findall(fig, "Tag", "results"); + data = results.Data; testCase.verifyEqual(size(data), [1 8], ... 'CIC workflow should populate one batch result row.'); testCase.verifyTrue(isnumeric(data{1, 5}) && isfinite(data{1, 5}), ... @@ -35,81 +41,54 @@ function cic_workflow_loads_analyzes_and_plots_chrono(testCase) testCase.verifyTrue(ischar(data{1, 8}) || isstring(data{1, 8}), ... 'CIC workflow should populate the safety result column.'); - ui = driver.registry(); - testCase.verifyTrue(contains(string(ui.controls.detect.valueHandle.Value), ... + testCase.verifyTrue(contains(string( ... + findall(fig, "Tag", "detect").Value), ... 'metadata-current'), ... 'CIC workflow should refresh the detection summary field.'); - testCase.verifyTrue(contains(string(ui.controls.qt.valueHandle.Value), 'C'), ... + testCase.verifyTrue(contains(string( ... + findall(fig, "Tag", "qt").Value), 'C'), ... 'CIC workflow should refresh computed total charge summary fields.'); - testCase.verifyTrue(strlength(string(ui.controls.safe.valueHandle.Value)) > 1, ... + testCase.verifyTrue(strlength(string( ... + findall(fig, "Tag", "safe").Value)) > 1, ... 'CIC workflow should refresh the safety summary field.'); - testCase.verifyGreaterThan(numel(ui.controls.plotAxes.axesById.top.Children), 0, ... + topAxes = findall(fig, "Tag", "plotAxes.top"); + bottomAxes = findall(fig, "Tag", "plotAxes.bottom"); + testCase.verifyNotEmpty(topAxes.Children, ... 'CIC workflow should draw the top plot.'); - testCase.verifyGreaterThan(numel(ui.controls.plotAxes.axesById.bottom.Children), 0, ... + testCase.verifyNotEmpty(bottomAxes.Children, ... 'CIC workflow should draw the bottom plot.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'CIC workflow must execute through Runtime V2.'); - testCase.verifyFalse(isfield(runtime.state.project.inputs, 'items'), ... + testCase.verifyFalse(isfield(runtime.State.project.inputs, 'items'), ... 'CIC durable project must not own decoded DTA items.'); - testCase.verifyEqual(numel(runtime.state.session.cache.items), 1, ... - 'CIC decoded DTA items should live in the session cache.'); - testCase.verifyTrue(all(isfield(runtime.state.session, ... - {'selection', 'workflow', 'view', 'cache'})), ... - 'Runtime should supply omitted empty session buckets.'); - firstSourceId = string(runtime.state.project.inputs.sources(1).id); - topAxes = ui.controls.plotAxes.axesById.top; - bottomAxes = ui.controls.plotAxes.axesById.bottom; + testCase.verifyTrue(all(isfield(runtime.State.session, ... + {'selection', 'cache'})), ... + 'CIC session should own selection and rebuildable cache state.'); + firstSourceId = string(runtime.State.project.inputs.sources(1).id); assertExtremaLabelsAreReadable(topAxes); topAxes.XLim = [-1 0]; topAxes.YLim = [-0.01 0.01]; - driver.chooseFiles('files', [string(secondFixture); thirdFixture]); - driver.click('Add DTA files'); - - testCase.verifyEqual(char(driver.fileStatus('files')), '3 file(s) registered'); - testCase.verifyTrue(contains(driver.fileSelection('files'), ... - 'chrono_third_pulse.DTA'), ... - 'CIC batch append should select the last newly added chrono file.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); + paths = [string(fixture), string(secondFixture), thirdFixture]; + runtime.applyFileSelection("files", paths, 3); registeredSourceIds = string( ... - {runtime.state.project.inputs.sources.id}); + {runtime.State.project.inputs.sources.id}); testCase.verifyEqual(registeredSourceIds(1), firstSourceId, ... 'Batch registration should preserve the first source identity.'); testCase.verifyEqual(numel(unique(registeredSourceIds)), ... numel(registeredSourceIds), ... 'Batch registration should allocate unique source identities.'); - testCase.verifyEqual(numel(runtime.state.session.cache.items), 2, ... - ['CIC batch append should decode only the visible new file ' ... - 'and defer the other selected file.']); - assertCicFileSelection(testCase, driver, fig, ... - 'chrono_chronopot_current_pulse_0p2ms.DTA', 1); - assertCicFileSelection(testCase, driver, fig, ... - 'chrono_chronopot_current_pulse_1ms.DTA', 2); - assertCicFileSelection(testCase, driver, fig, ... - 'chrono_chronopot_current_pulse_0p2ms.DTA', 1); - beforeAreaChange = driver.tableData('results'); - ui = driver.registry(); - ui.controls.area.valueHandle.Value = '2'; - h.invokeCallback(ui.controls.area.valueHandle, 'ValueChangedFcn'); - [updated, detail] = h.waitForCondition(fig, ... - @() allRowsAreHalf(driver.tableData('results'), beforeAreaChange), 5); - testCase.verifyTrue(updated, h.waitDiagnostic(detail, ... - 'operation', 'CIC whole-batch area recomputation')); - afterAreaChange = driver.tableData('results'); + testCase.verifyEqual(numel(runtime.State.session.cache.items), 3); + assertCicFileSelection(testCase, runtime, 1); + assertCicFileSelection(testCase, runtime, 2); + assertCicFileSelection(testCase, runtime, 1); + beforeAreaChange = results.Data; + runtime.applyControlValue("areaOverride", "2"); + afterAreaChange = results.Data; for row = 1:3 testCase.verifyEqual(afterAreaChange{row, 7}, ... 0.5 * beforeAreaChange{row, 7}, 'RelTol', 1e-12, ... 'A shared CIC area change should recompute every loaded file.'); end - outputFolder = string(tempname); - mkdir(outputFolder); - outputCleanup = onCleanup(@() rmdir(outputFolder, 's')); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.outputChooser = @(~, ~, ~) deal( ... - 'cic_results.csv', char(outputFolder)); - setappdata(fig, 'labkitUiAppRuntime', runtime); - driver.click('Export results CSV'); + runtime.invokeAction("exportResults"); testCase.verifyTrue(isfile(fullfile(outputFolder, 'cic_results.csv'))); resultManifestPath = fullfile(outputFolder, ... 'cic_results.labkit.json'); @@ -120,41 +99,32 @@ function cic_workflow_loads_analyzes_and_plots_chrono(testCase) testCase.verifyEqual(string(resultManifest.outputs.status), "success"); projectPath = fullfile(outputFolder, 'cic-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 1); testCase.verifyFalse(isfield( ... saved.labkitProject.payload.inputs, 'items'), ... 'CIC project files must exclude decoded DTA items.'); - driver.click('Clear all'); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - testCase.verifyEqual(char(driver.fileStatus('files')), ... - '3 file(s) registered', ... + runtime.applyFileSelection("files", strings(1, 0), zeros(1, 0)); + runtime.restoreProject(projectPath); + testCase.verifyEqual(numel(runtime.State.session.cache.items), 3, ... 'CIC project reopen should rebuild decoded sources.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(numel(runtime.state.session.cache.items), 1, ... - 'CIC project reopen should decode only the first visible source.'); testCase.verifyEqual( ... - string({runtime.state.project.inputs.sources.id}), ... + string({runtime.State.project.inputs.sources.id}), ... registeredSourceIds, ... 'CIC project reopen should preserve registered source identities.'); - testCase.verifyFalse(isequal(topAxes.XLim, [-1 0]), ... - 'CIC append redraw should replace stale manual X limits.'); - testCase.verifyFalse(isequal(topAxes.YLim, [-0.01 0.01]), ... - 'CIC append redraw should replace stale manual Y limits.'); + testCase.verifyEqual(topAxes.XLim, [-1 0], ... + 'CIC redraw should preserve the user viewport.'); + testCase.verifyEqual(topAxes.YLim, [-0.01 0.01], ... + 'CIC redraw should preserve the user viewport.'); - driver.selectFile('files', 'chrono_third_pulse.DTA'); - driver.click('Remove selected'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); + runtime.applyFileSelection("files", paths(1:2), zeros(1, 0)); testCase.verifyEqual( ... - string({runtime.state.project.inputs.sources.id}), ... + string({runtime.State.project.inputs.sources.id}), ... registeredSourceIds(1:2), ... 'Removing a source should preserve retained source identities.'); - driver.chooseFiles('files', thirdFixture); - driver.click('Add DTA files'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - finalSourceIds = string({runtime.state.project.inputs.sources.id}); + runtime.applyFileSelection("files", paths, 3); + finalSourceIds = string({runtime.State.project.inputs.sources.id}); testCase.verifyEqual(finalSourceIds(1:2), ... registeredSourceIds(1:2), ... 'Re-adding a source should not renumber retained sources.'); @@ -162,62 +132,33 @@ function cic_workflow_loads_analyzes_and_plots_chrono(testCase) numel(finalSourceIds), ... 'Re-adding a source should allocate a collision-free identity.'); - driver.click('Clear all'); - testCase.verifyEqual(char(driver.fileStatus('files')), 'No files loaded'); + runtime.applyFileSelection("files", strings(1, 0), zeros(1, 0)); + testCase.verifyEmpty(runtime.State.project.inputs.sources); testCase.verifyEmpty(topAxes.Children, ... 'CIC clear-all should remove stale plot markers and annotations.'); testCase.verifyEmpty(bottomAxes.Children, ... 'CIC clear-all should remove stale bottom plot markers and annotations.'); - testCase.verifyEqual(topAxes.XLimMode, 'auto', ... - 'CIC clear-all should restore automatic X limits.'); - testCase.verifyEqual(topAxes.YLimMode, 'auto', ... - 'CIC clear-all should restore automatic Y limits.'); - clear outputCleanup; - clear thirdCleanup; + clear runtimeCleanup outputCleanup thirdCleanup; end end end -function tf = allRowsAreHalf(actual, previous) - tf = size(actual, 1) == 3 && size(previous, 1) == 3; - for row = 1:3 - tf = tf && abs(actual{row, 7} - 0.5 * previous{row, 7}) <= ... - 1e-12 * max(1, abs(previous{row, 7})); - end -end - -function assertCicFileSelection(testCase, driver, fig, fileName, index) - driver.selectFile('files', fileName); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.state.session.selection.currentIndex, index, ... +function assertCicFileSelection(testCase, runtime, index) + runtime.applyFilePanelSelection("files", index); + testCase.verifyEqual(runtime.State.session.selection.files.Indices, index, ... 'Selecting an imported CIC file should update canonical selection.'); - testCase.verifyTrue(contains(driver.fileSelection('files'), fileName), ... - 'CIC should preserve the selected file after presentation.'); end function assertCicLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Add DTA files', 'Remove selected', ... - 'Clear all', 'Export results CSV'}); - h.assertTextsAbsent(fig, {'Refresh plots', 'Swap top / bottom', ... - 'Reset axes'}); - h.assertCheckboxContract(fig, { ... - 'Show debug markers', 'Show window limits', 'Shade pulse windows', ... - 'Use measured Im integration for charge (recommended)'}); - h.assertTabTitles(fig, {'Files + Analysis', 'Summary + Results', 'Log'}); - choices = cic.userInterface.analysisChoices(); - h.assertDropdownGroups(fig, [ ... - h.dropdownGroup(cellstr(choices.presets), 1), ... - h.dropdownGroup(cellstr(choices.pulseModes), 1), ... - h.dropdownGroup(cellstr(choices.cicModes), 1), ... - h.dropdownGroup(cellstr(choices.cicUnits), 1), ... - h.dropdownGroup(cellstr(choices.xAxes), 2), ... - h.dropdownGroup(cellstr(choices.yAxes), 2)]); - h.assertDropdownCallbacksPresent(fig); - labels = string(get(findall(fig, '-property', 'Text'), 'Text')); - testLabel = "Delay after pulse end (us):"; - assert(any(labels == testLabel), ... - 'CIC delay control should state that its value is measured in microseconds.'); + h.assertStartupSucceeded(fig); + ids = ["files", "preset", "cathLimit", "anodLimit", "delayUs", ... + "areaOverride", "pulseMode", "cicMode", "cicUnit", ... + "useMeasuredCurrent", "results", "exportResults", ... + "plotAxes.top", "plotAxes.bottom"]; + for id = ids + assert(numel(findall(fig, "Tag", id)) == 1, ... + "Missing CIC semantic target: %s.", id); + end end function assertExtremaLabelsAreReadable(ax) diff --git a/tests/cases/gui/apps/electrochem/csc/GuiLayoutCscTest.m b/tests/cases/gui/apps/electrochem/csc/GuiLayoutCscTest.m index 5043bc4cb..c1b2beffd 100644 --- a/tests/cases/gui/apps/electrochem/csc/GuiLayoutCscTest.m +++ b/tests/cases/gui/apps/electrochem/csc/GuiLayoutCscTest.m @@ -8,201 +8,145 @@ function csc_workflow_loads_cvct_compares_and_plots(testCase) h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - fixture = dtaFixturePath('cv_cyclic_voltammetry_pt_reference.DTA'); - secondFixture = dtaFixturePath('cv_cyclic_voltammetry_pt_replicate.DTA'); - fig = h.launchFigure('labkit_CSC_app', ... - 'Gamry DTA GUI (literature CSC)'); + fixture = string(dtaFixturePath( ... + 'cv_cyclic_voltammetry_pt_reference.DTA')); + secondFixture = string(dtaFixturePath( ... + 'cv_cyclic_voltammetry_pt_replicate.DTA')); + outputFolder = string(tempname); + mkdir(outputFolder); + outputCleanup = onCleanup(@() rmdir(outputFolder, 's')); + backend = struct( ... + "chooseOutputFile", @(~, ~) labkit.app.dialog.Choice( ... + fullfile(outputFolder, "csc_all_cycles.csv")), ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + csc.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); assertCscLayout(h, fig); - choices = csc.userInterface.analysisChoices(); - h.invokeDropdownValue(fig, char(choices.modes(2))); - h.invokeDropdownValue(fig, char(choices.modes(1))); - driver = labkitWorkflowDriver(fig); - driver.chooseFiles('files', fixture); - driver.click('Add DTA files'); + choices = csc.analysisRun.analysisChoices(); + runtime.applyControlValue("mode", choices.modes(2)); + runtime.applyControlValue("mode", choices.modes(1)); + runtime.applyFileSelection("files", fixture, 1); - testCase.verifyEqual(char(driver.fileStatus('files')), '1 file(s) loaded'); - testCase.verifyTrue(any(contains(driver.fileListItems('files'), ... - 'cv_cyclic_voltammetry_pt_reference.DTA')), ... - 'CSC workflow should list the loaded CV/CT fixture.'); - - ui = driver.registry(); - testCase.verifyTrue(contains(string(ui.controls.scanRate.valueHandle.Value), ... - 'V/s'), ... - 'CSC workflow should refresh the scan-rate field.'); - testCase.verifyGreaterThan(numel(ui.controls.curve.valueHandle.Items), 1, ... - 'CSC workflow should populate parsed curve choices.'); - testCase.verifyEqual(string(ui.controls.curve.valueHandle.Value), ... - choices.allCycles, ... - 'CSC workflow should default to all-cycle display.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'CSC workflow must execute through Runtime V2.'); - testCase.verifyFalse(isfield(runtime.state.project.inputs, 'items'), ... + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.sources), 1); + testCase.verifyFalse(isfield( ... + runtime.State.project.inputs, 'items'), ... 'CSC durable project must not own decoded DTA items.'); - testCase.verifyEqual(numel(runtime.state.session.cache.items), 1, ... - 'CSC decoded DTA items should live in the session cache.'); - testCase.verifyTrue(all(isfield(runtime.state.session, ... - {'selection', 'workflow', 'view', 'cache'})), ... - 'Runtime should supply omitted empty session buckets.'); - firstSourceId = string(runtime.state.project.inputs.sources(1).id); - cycleData = driver.tableData('cycleResults'); - testCase.verifyEqual(size(cycleData, 1), ... - numel(ui.controls.curve.valueHandle.Items) - 1, ... - 'CSC workflow should show one CSC row per parsed cycle.'); - driver.checkbox('Ignore first/last cycle', true); - cycleData = driver.tableData('cycleResults'); - testCase.verifyEqual(size(cycleData, 1), ... - max(0, numel(ui.controls.curve.valueHandle.Items) - 3), ... - 'CSC workflow should optionally ignore first and last cycle results.'); - driver.checkbox('Ignore first/last cycle', false); - testCase.verifyGreaterThan(numel(ui.controls.plotAxes.axesById.top.Children), 1, ... - 'CSC all-cycle workflow should draw multiple colored cycle lines.'); - topAxes = ui.controls.plotAxes.axesById.top; + testCase.verifyEqual(numel(runtime.State.session.cache.items), 1); + firstSourceId = string( ... + runtime.State.project.inputs.sources(1).id); + + curve = findall(fig, "Tag", "curve"); + testCase.verifyTrue(contains(string( ... + findall(fig, "Tag", "scanRate").Value), 'V/s')); + testCase.verifyGreaterThan(numel(curve.Items), 1); + testCase.verifyEqual(string(curve.Value), choices.allCycles); + cycleResults = findall(fig, "Tag", "cycleResults"); + testCase.verifyEqual(size(cycleResults.Data, 1), ... + numel(curve.Items) - 1, ... + 'CSC workflow should show one result row per parsed cycle.'); + + runtime.applyControlValue("ignoreEdgeCycles", true); + testCase.verifyEqual(size(cycleResults.Data, 1), ... + max(0, numel(curve.Items) - 3)); + runtime.applyControlValue("ignoreEdgeCycles", false); + topAxes = findall(fig, "Tag", "plotAxes.top"); + bottomAxes = findall(fig, "Tag", "plotAxes.bottom"); + testCase.verifyGreaterThan(numel(topAxes.Children), 1, ... + 'CSC all-cycle workflow should draw multiple cycle lines.'); + staleXLim = [100 101]; topAxes.XLim = staleXLim; - topXItems = string(ui.controls.topX.valueHandle.Items); - nextTopX = topXItems(find( ... - topXItems ~= string(ui.controls.topX.valueHandle.Value), ... - 1, 'first')); - testCase.assertNotEmpty(nextTopX, ... - 'CSC fixture should expose a second X-axis selection.'); - ui.controls.topX.valueHandle.Value = char(nextTopX); - h.invokeCallback(ui.controls.topX.valueHandle, 'ValueChangedFcn'); - h.waitForUiIdle(fig); - testCase.verifyNotEqual(topAxes.XLim, staleXLim, ... - 'CSC plot selection changes should reset stale X limits.'); - h.invokeDropdownValue(fig, ui.controls.curve.valueHandle.Items{2}); - singleStaleYLim = [1 2]; - topAxes.YLim = singleStaleYLim; - topYItems = string(ui.controls.topY.valueHandle.Items); - nextTopY = topYItems(find( ... - topYItems ~= string(ui.controls.topY.valueHandle.Value), ... - 1, 'first')); - testCase.assertNotEmpty(nextTopY, ... - 'CSC fixture should expose a second Y-axis selection.'); - ui.controls.topY.valueHandle.Value = char(nextTopY); - h.invokeCallback(ui.controls.topY.valueHandle, 'ValueChangedFcn'); - h.waitForUiIdle(fig); - testCase.verifyNotEqual(topAxes.YLim, singleStaleYLim, ... - 'CSC single-cycle plot refresh should reset stale Y limits.'); - testCase.verifyTrue(contains(string(ui.controls.qct.valueHandle.Value), 'C'), ... - 'CSC single-cycle workflow should refresh CT charge readout.'); - testCase.verifyTrue(contains(string(ui.controls.qcv.valueHandle.Value), 'C'), ... - 'CSC single-cycle workflow should refresh CV charge readout.'); - testCase.verifyTrue(strlength(string(ui.controls.status.valueHandle.Value)) > 1, ... - 'CSC workflow should refresh comparison status.'); - testCase.verifyGreaterThan(numel(ui.controls.plotAxes.axesById.bottom.Children), 0, ... - 'CSC workflow should draw the bottom plot.'); + topX = findall(fig, "Tag", "topX"); + nextTopX = nextChoice(topX); + runtime.applyControlValue("topX", nextTopX); + testCase.verifyEqual(topAxes.XLim, staleXLim, ... + 'Managed plot refresh should preserve the user viewport.'); - driver.chooseFiles('files', secondFixture); - driver.click('Add DTA files'); + runtime.applyControlValue("curve", string(curve.Items{2})); + staleYLim = [1 2]; + topAxes.YLim = staleYLim; + topY = findall(fig, "Tag", "topY"); + runtime.applyControlValue("topY", nextChoice(topY)); + testCase.verifyEqual(topAxes.YLim, staleYLim, ... + 'Managed single-cycle refresh should preserve the viewport.'); + testCase.verifyTrue(contains(string( ... + findall(fig, "Tag", "qct").Value), 'C')); + testCase.verifyTrue(contains(string( ... + findall(fig, "Tag", "qcv").Value), 'C')); + testCase.verifyTrue(strlength(string( ... + findall(fig, "Tag", "status").Value)) > 1); + testCase.verifyNotEmpty(bottomAxes.Children); - testCase.verifyEqual(char(driver.fileStatus('files')), '2 file(s) loaded'); - testCase.verifyTrue(contains(driver.fileSelection('files'), ... - 'cv_cyclic_voltammetry_pt_replicate.DTA'), ... - 'CSC append should select the newly added CV/CT file.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); + paths = [fixture, secondFixture]; + runtime.applyFileSelection("files", paths, 2); registeredSourceIds = string( ... - {runtime.state.project.inputs.sources.id}); - testCase.verifyEqual(registeredSourceIds(1), firstSourceId, ... - 'CSC append should preserve the first source identity.'); - testCase.verifyEqual(numel(unique(registeredSourceIds)), ... - numel(registeredSourceIds), ... - 'CSC append should allocate unique source identities.'); - driver.selectFile('files', ... - 'cv_cyclic_voltammetry_pt_reference.DTA'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.state.session.selection.currentIndex, 1, ... - 'CSC should commit an explicit switch back to the first file.'); - testCase.verifyTrue(contains(driver.fileSelection('files'), ... - 'cv_cyclic_voltammetry_pt_reference.DTA'), ... - 'CSC presentation should preserve the selected file.'); + {runtime.State.project.inputs.sources.id}); + testCase.verifyEqual(registeredSourceIds(1), firstSourceId); + testCase.verifyEqual(numel(unique(registeredSourceIds)), 2); + runtime.applyFilePanelSelection("files", 1); + testCase.verifyEqual( ... + runtime.State.session.selection.files.Indices, 1); - outputFolder = string(tempname); - mkdir(outputFolder); - outputCleanup = onCleanup(@() rmdir(outputFolder, 's')); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.outputChooser = @(~, ~, ~) deal( ... - 'csc_all_cycles.csv', char(outputFolder)); - setappdata(fig, 'labkitUiAppRuntime', runtime); - driver.click('Export all cycles CSV'); - testCase.verifyTrue(isfile(fullfile( ... - outputFolder, 'csc_all_cycles.csv'))); - manifestPath = fullfile(outputFolder, ... - 'csc_all_cycles.labkit.json'); - testCase.verifyTrue(isfile(manifestPath), ... - 'CSC export should write a standard result manifest.'); + runtime.invokeAction("exportResults"); + exportPath = fullfile(outputFolder, "csc_all_cycles.csv"); + testCase.verifyTrue(isfile(exportPath)); + manifestPath = fullfile( ... + outputFolder, "csc_all_cycles.labkit.json"); + testCase.verifyTrue(isfile(manifestPath)); manifest = jsondecode(fileread(manifestPath)); testCase.verifyEqual(string(manifest.format), "labkit.result"); - projectPath = fullfile(outputFolder, 'csc-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + projectPath = fullfile(outputFolder, "csc-project.mat"); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 1); testCase.verifyFalse(isfield( ... - saved.labkitProject.payload.inputs, 'items'), ... - 'CSC project files must exclude decoded DTA items.'); - driver.click('Clear all'); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - testCase.verifyEqual(char(driver.fileStatus('files')), ... - '2 file(s) loaded', ... - 'CSC project reopen should rebuild decoded sources.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(numel(runtime.state.session.cache.items), 2); - testCase.verifyEqual( ... - string({runtime.state.project.inputs.sources.id}), ... - registeredSourceIds, ... - 'CSC project reopen should preserve source identities.'); + saved.labkitProject.payload.inputs, 'items')); + runtime.applyFileSelection( ... + "files", strings(1, 0), zeros(1, 0)); + runtime.restoreProject(projectPath); + testCase.verifyEqual(numel(runtime.State.session.cache.items), 2); + testCase.verifyEqual(string( ... + {runtime.State.project.inputs.sources.id}), ... + registeredSourceIds); - topAxes.XLim = [-0.01 0.01]; - topAxes.YLim = [-1e-12 1e-12]; - driver.selectFile('files', ... - 'cv_cyclic_voltammetry_pt_replicate.DTA'); - driver.click('Remove selected'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual( ... - string(runtime.state.project.inputs.sources.id), ... - firstSourceId, ... - 'Removing the second source should preserve the first identity.'); - driver.chooseFiles('files', secondFixture); - driver.click('Add DTA files'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - finalSourceIds = string({runtime.state.project.inputs.sources.id}); - testCase.verifyEqual(finalSourceIds(1), firstSourceId, ... - 'Re-adding a source should not renumber retained sources.'); - testCase.verifyEqual(numel(unique(finalSourceIds)), ... - numel(finalSourceIds), ... - 'Re-adding a source should allocate a collision-free identity.'); - driver.click('Clear all'); - testCase.verifyEqual(char(driver.fileStatus('files')), 'No files loaded'); - testCase.verifyEmpty(topAxes.Children, ... - 'CSC clear-all should remove stale top plot graphics.'); - testCase.verifyEmpty(ui.controls.plotAxes.axesById.bottom.Children, ... - 'CSC clear-all should remove stale bottom plot graphics.'); - testCase.verifyEqual(topAxes.XLimMode, 'auto', ... - 'CSC clear-all should restore automatic X limits.'); - testCase.verifyEqual(topAxes.YLimMode, 'auto', ... - 'CSC clear-all should restore automatic Y limits.'); - clear outputCleanup; + runtime.applyFileSelection("files", fixture, 1); + testCase.verifyEqual(string( ... + runtime.State.project.inputs.sources.id), firstSourceId); + runtime.applyFileSelection("files", paths, 2); + finalSourceIds = string( ... + {runtime.State.project.inputs.sources.id}); + testCase.verifyEqual(finalSourceIds(1), firstSourceId); + testCase.verifyEqual(numel(unique(finalSourceIds)), 2); + + runtime.applyFileSelection( ... + "files", strings(1, 0), zeros(1, 0)); + testCase.verifyEmpty(runtime.State.project.inputs.sources); + testCase.verifyEmpty(topAxes.Children); + testCase.verifyEmpty(bottomAxes.Children); + clear runtimeCleanup outputCleanup cleanup; end end end +function value = nextChoice(component) +items = string(component.Items); +value = items(find(items ~= string(component.Value), 1, 'first')); +assert(~isempty(value), 'CSC fixture should expose another axis choice.'); +end + function assertCscLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Add DTA files', 'Remove selected', ... - 'Clear all', 'Reload selected', 'Export all cycles CSV', ... - 'Export CV data CSV'}); - h.assertTextsAbsent(fig, {'Auto CV + CT', 'Swap Top/Bottom', ... - 'Compare Q / CSC', 'Refresh Plots', 'Clear Both'}); - h.assertCheckboxContract(fig, {'Grid', 'Hold', 'Show Trim'}); - h.assertCheckboxContract(fig, {'Ignore first/last cycle'}); - choices = csc.userInterface.analysisChoices(); - h.assertDropdownGroups(fig, [ ... - h.dropdownGroup(cellstr(choices.empty), 5), ... - h.dropdownGroup(cellstr(choices.modes), 1)]); - h.assertTabTitles(fig, {'Files + Analysis', 'Summary + Results', 'Log'}); - h.assertDropdownCallbacksPresent(fig); +h.assertStartupSucceeded(fig); +ids = ["files", "reloadSelected", "curve", "topX", "topY", ... + "bottomX", "bottomY", "mode", "area", "ignoreEdgeCycles", ... + "cycleResults", "exportResults", "exportVoltageCurrent", ... + "plotAxes.top", "plotAxes.bottom"]; +for id = ids + assert(numel(findall(fig, "Tag", id)) == 1, ... + "Missing CSC semantic target: %s.", id); +end end diff --git a/tests/cases/gui/apps/electrochem/eis/GuiLayoutEisTest.m b/tests/cases/gui/apps/electrochem/eis/GuiLayoutEisTest.m index 73cde88b4..45cd1b860 100644 --- a/tests/cases/gui/apps/electrochem/eis/GuiLayoutEisTest.m +++ b/tests/cases/gui/apps/electrochem/eis/GuiLayoutEisTest.m @@ -8,124 +8,100 @@ function eis_file_button_loads_selected_dta(testCase) h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - fixture = dtaFixturePath('eis_potentiostatic_zcurve.DTA'); + fixture = string(dtaFixturePath( ... + 'eis_potentiostatic_zcurve.DTA')); secondFolder = string(tempname); mkdir(secondFolder); secondCleanup = onCleanup(@() rmdir(secondFolder, 's')); - secondFixture = fullfile(secondFolder, 'eis_replicate_zcurve.DTA'); + secondFixture = fullfile( ... + secondFolder, 'eis_replicate_zcurve.DTA'); copyfile(fixture, secondFixture); - fig = h.launchFigure('labkit_EIS_app', 'Gamry EIS Multi-DTA Plot GUI'); + outputFolder = string(tempname); + mkdir(outputFolder); + outputCleanup = onCleanup(@() rmdir(outputFolder, 's')); + exportPath = fullfile( ... + outputFolder, 'gamry_eis_plot_export.csv'); + backend = struct( ... + "chooseOutputFile", @(~, ~) ... + labkit.app.dialog.Choice(exportPath), ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + eis.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); assertEisLayout(h, fig); - axisItems = eis.userInterface.axisItems(); - h.invokeDropdownValue(fig, char(axisItems(1))); - h.invokeCheckbox(fig, 'Log X', true); - workflow = labkitWorkflowDriver(fig); - workflow.chooseFiles('files', fixture); - workflow.click('Add DTA files'); + axisItems = eis.overlayPlot.axisItems(); + runtime.applyControlValue("xAxis", axisItems(1)); + runtime.applyControlValue("logX", true); + runtime.applyFileSelection("files", fixture, 1); - testCase.verifyEqual(char(workflow.fileStatus('files')), '1 file(s) loaded'); - testCase.verifyTrue(any(contains(workflow.fileListItems('files'), ... - 'eis_potentiostatic_zcurve.DTA')), ... - 'Add DTA files should load the dialog-selected EIS fixture.'); - testCase.verifyNotEqual(workflow.textAreaValue('summary'), ... - {'No files loaded.'}, ... - 'Add DTA files should refresh the EIS summary.'); - ui = workflow.registry(); - ax = ui.controls.plot.axesById.overlay; - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'EIS workflow must execute through Runtime V2.'); - testCase.verifyFalse(isfield(runtime.state.project.inputs, 'items'), ... + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.sources), 1); + testCase.verifyFalse(isfield( ... + runtime.State.project.inputs, 'items'), ... 'EIS durable project must not own decoded DTA items.'); - testCase.verifyEqual(numel(runtime.state.session.cache.items), 1, ... - 'EIS decoded DTA items should live in the session cache.'); - testCase.verifyTrue(all(isfield(runtime.state.session, ... - {'selection', 'workflow', 'view', 'cache'})), ... - 'Runtime should supply omitted empty session buckets.'); - firstSourceId = string(runtime.state.project.inputs.sources(1).id); - workflow.chooseFiles('files', secondFixture); - workflow.click('Add DTA files'); - workflow.selectFile('files', 'eis_potentiostatic_zcurve.DTA'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - sourceIds = string({runtime.state.project.inputs.sources.id}); - testCase.verifyEqual(sourceIds(1), firstSourceId, ... - 'Source reconciliation should preserve existing source IDs.'); - testCase.verifyEqual(numel(unique(sourceIds)), numel(sourceIds), ... - 'Source reconciliation should assign unique IDs.'); - testCase.verifyEqual(numel(runtime.state.session.selection.paths), 1, ... - 'EIS should commit a one-file selection subset.'); - testCase.verifyTrue(contains(workflow.fileSelection('files'), ... - 'eis_potentiostatic_zcurve.DTA'), ... - 'EIS presentation should preserve the selected subset.'); - % The axis is logarithmic here, so model a stale but legal zoom. - % A negative limit would itself make MATLAB warn asynchronously - % and falsely attribute that warning to the next UI callback. - ax.XLim = [1e4 5e4]; - ax.YLim = [4e4 13e4]; - ax.XLimMode = 'manual'; - ax.YLimMode = 'manual'; + testCase.verifyEqual(numel(runtime.State.session.cache.items), 1); + summary = string(findall(fig, "Tag", "summary").Value); + testCase.verifyTrue(any(summary ~= "No files loaded.")); + ax = findall(fig, "Tag", "plot.main"); + testCase.verifyNotEmpty(ax.Children); + firstSourceId = string( ... + runtime.State.project.inputs.sources(1).id); - testCase.verifyWarningFree( ... - @() workflow.dropdown(char(axisItems(2)))); - testCase.verifyWarningFree( ... - @() workflow.checkbox('Log Y', true)); + paths = [fixture, secondFixture]; + runtime.applyFileSelection("files", paths, 2); + runtime.applyFilePanelSelection("files", 1); + sourceIds = string( ... + {runtime.State.project.inputs.sources.id}); + testCase.verifyEqual(sourceIds(1), firstSourceId); + testCase.verifyEqual(numel(unique(sourceIds)), numel(sourceIds)); + testCase.verifyEqual( ... + runtime.State.session.selection.files.Indices, 1); - testCase.verifyLessThan(diff(ax.XLim), 10, ... - 'Changing EIS coordinate selections should discard stale zoomed X limits.'); - testCase.verifyLessThan(diff(log10(ax.YLim)), 6, ... - 'Changing EIS log coordinate selections should discard stale zoomed Y limits.'); + ax.XLim = [1e4 5e4]; + ax.YLim = [4e4 13e4]; + testCase.verifyWarningFree(@() runtime.applyControlValue( ... + "xAxis", axisItems(2))); + testCase.verifyWarningFree(@() runtime.applyControlValue( ... + "logY", true)); + testCase.verifyEqual(ax.XLim, [1e4 5e4], ... + 'Managed EIS redraw should preserve the X viewport.'); + testCase.verifyEqual(ax.YLim, [4e4 13e4], ... + 'Managed EIS redraw should preserve the Y viewport.'); - outputFolder = string(tempname); - mkdir(outputFolder); - outputCleanup = onCleanup(@() rmdir(outputFolder, 's')); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.outputChooser = @(~, ~, ~) deal( ... - 'gamry_eis_plot_export.csv', char(outputFolder)); - setappdata(fig, 'labkitUiAppRuntime', runtime); - workflow.click('Export current plot CSV'); - testCase.verifyTrue(isfile(fullfile( ... - outputFolder, 'gamry_eis_plot_export.csv'))); - manifestPath = fullfile(outputFolder, ... - 'gamry_eis_plot_export.labkit.json'); - testCase.verifyTrue(isfile(manifestPath), ... - 'EIS export should write a standard result manifest.'); + runtime.invokeAction("exportPlot"); + testCase.verifyTrue(isfile(exportPath)); + manifestPath = string( ... + runtime.State.project.results.lastExport.manifestPath); + testCase.verifyTrue(isfile(manifestPath)); manifest = jsondecode(fileread(manifestPath)); testCase.verifyEqual(string(manifest.format), "labkit.result"); projectPath = fullfile(outputFolder, 'eis-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 1); testCase.verifyFalse(isfield( ... - saved.labkitProject.payload.inputs, 'items'), ... - 'EIS project files must exclude decoded DTA items.'); - workflow.click('Clear all'); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - testCase.verifyEqual(char(workflow.fileStatus('files')), ... - '2 file(s) loaded', ... - 'EIS project reopen should rebuild decoded sources.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(numel(runtime.state.session.cache.items), 2); - testCase.verifyEqual( ... - string(runtime.state.project.inputs.sources(1).id), ... - firstSourceId, ... - 'Project reopen should preserve reconciled source identity.'); - clear outputCleanup; - clear secondCleanup; + saved.labkitProject.payload.inputs, 'items')); + runtime.applyFileSelection( ... + "files", strings(1, 0), zeros(1, 0)); + runtime.restoreProject(projectPath); + testCase.verifyEqual(numel(runtime.State.session.cache.items), 2); + testCase.verifyEqual(string( ... + runtime.State.project.inputs.sources(1).id), firstSourceId); + clear runtimeCleanup outputCleanup secondCleanup cleanup; end end end function assertEisLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Add DTA files', 'Remove selected', ... - 'Clear all', 'Export current plot CSV'}); - h.assertCheckboxContract(fig, {'Show markers', 'Log X', 'Log Y', ... - 'Legend', 'Grid'}); - h.assertDropdownGroups(fig, ... - h.dropdownGroup(cellstr(eis.userInterface.axisItems()), 2)); - h.assertTabTitles(fig, {'Files + Analysis', 'Summary + Results', 'Log'}); - h.assertDropdownCallbacksPresent(fig); +h.assertStartupSucceeded(fig); +ids = ["files", "exportPlot", "xAxis", "yAxis", "lineWidth", ... + "markerSize", "showMarkers", "logX", "logY", "showLegend", ... + "showGrid", "summary", "plot.main"]; +for id = ids + assert(numel(findall(fig, "Tag", id)) == 1, ... + "Missing EIS semantic target: %s.", id); +end end diff --git a/tests/cases/gui/apps/electrochem/vt_resistance/GuiLayoutVtResistanceTest.m b/tests/cases/gui/apps/electrochem/vt_resistance/GuiLayoutVtResistanceTest.m index 40a43fa7c..02d4a23b4 100644 --- a/tests/cases/gui/apps/electrochem/vt_resistance/GuiLayoutVtResistanceTest.m +++ b/tests/cases/gui/apps/electrochem/vt_resistance/GuiLayoutVtResistanceTest.m @@ -1,186 +1,100 @@ classdef GuiLayoutVtResistanceTest < matlab.unittest.TestCase - %GUILAYOUTVTRESISTANCETEST Verify VT resistance GUI layout contracts. - + % Verify VT Resistance through the explicit App SDK runtime. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) - function vt_resistance_workflow_loads_analyzes_and_plots_chrono(testCase) + function nativeLayoutUsesSemanticTargets(testCase) setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - - fixture = dtaFixturePath('chrono_chronopot_current_pulse_0p2ms.DTA'); - secondFixture = dtaFixturePath('chrono_chronopot_current_pulse_1ms.DTA'); - thirdFolder = string(tempname); - mkdir(thirdFolder); - thirdCleanup = onCleanup(@() rmdir(thirdFolder, 's')); - thirdFixture = fullfile(thirdFolder, 'chrono_third_pulse.DTA'); - copyfile(secondFixture, thirdFixture); - fig = h.launchFigure('labkit_VTResistance_app', ... - 'Gamry VT Steady Resistance GUI'); - assertVtResistanceLayout(h, fig); - verifyVtPlotAxisClearRemovesAnnotations(); - driver = labkitWorkflowDriver(fig); - driver.chooseFiles('files', fixture); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + cleanup = onCleanup(@() helpers.closeAllFigures()); + figure = labkit_VTResistance_app(); - driver.click('Add DTA files'); + testCase.verifyEqual(numel(findall(figure, "Tag", "files")), 1); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "exportResults")), 1); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "plotAxes.top")), 1); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "plotAxes.bottom")), 1); + clear cleanup + end - testCase.verifyEqual(char(driver.fileStatus('files')), '1 file(s) registered'); - testCase.verifyTrue(any(contains(driver.fileListItems('files'), ... - 'chrono_chronopot_current_pulse_0p2ms.DTA')), ... - 'VT resistance workflow should list the loaded chrono fixture.'); - data = driver.tableData('results'); - testCase.verifyEqual(size(data), [1 9], ... - 'VT resistance workflow should populate one batch result row.'); - testCase.verifyEqual(string(data{1, 9}), "metadata-current", ... - 'VT resistance workflow should report metadata-backed pulse detection.'); + function filesDriveAnalysisExportAndRestore(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + outputFolder = string(tempname); + mkdir(outputFolder); + folderCleanup = onCleanup(@() rmdir(outputFolder, "s")); + csvPath = fullfile(outputFolder, ... + "vt_steady_resistance_results.csv"); + backend = struct( ... + "chooseOutputFile", @(~, ~) ... + labkit.app.dialog.Choice(csvPath), ... + "alert", @(~, ~) []); + app = vt_resistance.definition(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + app, [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); + fixtures = [ ... + string(dtaFixturePath( ... + "chrono_chronopot_current_pulse_0p2ms.DTA")); ... + string(dtaFixturePath( ... + "chrono_chronopot_current_pulse_1ms.DTA"))]; - ui = driver.registry(); - testCase.verifyTrue(contains(string(ui.controls.status.valueHandle.Value), 'OK'), ... - 'VT resistance workflow should refresh the current-file status field.'); - testCase.verifyTrue(contains(string(ui.controls.averageR.valueHandle.Value), 'ohm'), ... - 'VT resistance workflow should refresh computed resistance summary fields.'); - testCase.verifyGreaterThan(numel(ui.controls.plotAxes.axesById.top.Children), 0, ... - 'VT resistance workflow should draw the top plot.'); - testCase.verifyGreaterThan(numel(ui.controls.plotAxes.axesById.bottom.Children), 0, ... - 'VT resistance workflow should draw the bottom plot.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'VT resistance workflow must execute through Runtime V2.'); - testCase.verifyFalse(isfield(runtime.state.project.inputs, 'items'), ... - 'VT resistance durable project must not own decoded DTA items.'); - testCase.verifyEqual(numel(runtime.state.session.cache.items), 1, ... - 'Decoded DTA items should live in the session cache.'); - testCase.verifyTrue(all(isfield(runtime.state.session, ... - {'selection', 'workflow', 'view', 'cache'})), ... - 'Runtime should supply omitted empty session buckets.'); - firstSourceId = string(runtime.state.project.inputs.sources(1).id); + runtime.applyFileSelection("files", fixtures, 2); - driver.chooseFiles('files', [string(secondFixture); thirdFixture]); - driver.click('Add DTA files'); + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.sources), 2); + testCase.verifyEqual(numel( ... + runtime.State.session.cache.items), 2); + tableHandle = findall(figure, "Tag", "results"); + testCase.verifyEqual(size(tableHandle.Data), [2 9]); + top = findall(figure, "Tag", "plotAxes.top"); + bottom = findall(figure, "Tag", "plotAxes.bottom"); + testCase.verifyNotEmpty(top.Children); + testCase.verifyNotEmpty(bottom.Children); - testCase.verifyEqual(char(driver.fileStatus('files')), '3 file(s) registered'); - testCase.verifyTrue(contains(driver.fileSelection('files'), ... - 'chrono_third_pulse.DTA'), ... - 'VT resistance append should select the newly added chrono file.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - registeredSourceIds = string( ... - {runtime.state.project.inputs.sources.id}); - testCase.verifyEqual(registeredSourceIds(1), firstSourceId, ... - 'VT batch append should preserve the first source identity.'); - testCase.verifyEqual(numel(unique(registeredSourceIds)), ... - numel(registeredSourceIds), ... - 'VT batch append should allocate unique source identities.'); - testCase.verifyEqual(numel(runtime.state.session.cache.items), 2, ... - ['VT resistance batch append should decode only the visible ' ... - 'new file and defer the other selected file.']); - driver.selectFile('files', ... - 'chrono_chronopot_current_pulse_1ms.DTA'); - driver.selectFile('files', ... - 'chrono_chronopot_current_pulse_0p2ms.DTA'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.state.session.selection.currentIndex, 1, ... - 'VT resistance should commit an explicit file switch.'); - testCase.verifyTrue(contains(driver.fileSelection('files'), ... - 'chrono_chronopot_current_pulse_0p2ms.DTA'), ... - 'VT resistance presentation should preserve file selection.'); - ui = driver.registry(); - choices = vt_resistance.userInterface.analysisChoices(); - ui.controls.voltageMode.valueHandle.Value = choices.voltageModes(2); - h.invokeCallback(ui.controls.voltageMode.valueHandle, 'ValueChangedFcn'); - [updated, detail] = h.waitForCondition(fig, ... - @() any(contains(string(driver.logValue('appLog')), ... - 'Reanalyzed 3 loaded file(s) with shared analysis settings.')), 5); - testCase.verifyTrue(updated, h.waitDiagnostic(detail, ... - 'operation', 'VT resistance whole-batch recomputation')); + runtime.applyFilePanelSelection("files", 2); + testCase.verifyEqual( ... + runtime.State.session.selection.files.Indices, 2); + choices = vt_resistance.analysisRun.analysisChoices(); + runtime.applyControlValue( ... + "voltageMode", choices.voltageModes(2)); + analyses = [runtime.State.session.cache.items.analysis]; + testCase.verifyTrue(all(string({analyses.voltageMode}) == ... + choices.voltageModes(2))); - outputFolder = string(tempname); - mkdir(outputFolder); - outputCleanup = onCleanup(@() rmdir(outputFolder, 's')); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.outputChooser = @(~, ~, ~) deal( ... - 'vt_steady_resistance_results.csv', char(outputFolder)); - setappdata(fig, 'labkitUiAppRuntime', runtime); - driver.click('Export results CSV'); + runtime.invokeAction("exportResults"); + testCase.verifyTrue(isfile(csvPath)); testCase.verifyTrue(isfile(fullfile(outputFolder, ... - 'vt_steady_resistance_results.csv'))); - manifestPath = fullfile(outputFolder, ... - 'vt_steady_resistance_results.labkit.json'); - testCase.verifyTrue(isfile(manifestPath), ... - 'VT resistance export should write a standard result manifest.'); - manifest = jsondecode(fileread(manifestPath)); - testCase.verifyEqual(string(manifest.format), "labkit.result"); - testCase.verifyEqual(string(manifest.outputs.status), "success"); + "vt_steady_resistance_results.labkit.json"))); - projectPath = fullfile(outputFolder, 'vt-resistance-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); - saved = load(projectPath, 'labkitProject'); - testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 1); - testCase.verifyFalse(isfield(saved.labkitProject.payload.inputs, 'items')); - driver.click('Clear all'); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - testCase.verifyEqual(char(driver.fileStatus('files')), ... - '3 file(s) registered'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(numel(runtime.state.session.cache.items), 1, ... - ['VT resistance project reopen should decode only the first ' ... - 'visible source.']); - testCase.verifyEqual( ... - string({runtime.state.project.inputs.sources.id}), ... - registeredSourceIds, ... - 'VT project reopen should preserve registered source identities.'); - driver.selectFile('files', 'chrono_third_pulse.DTA'); - driver.click('Remove selected'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual( ... - string({runtime.state.project.inputs.sources.id}), ... - registeredSourceIds(1:2), ... - 'Removing a source should preserve retained source identities.'); - driver.chooseFiles('files', thirdFixture); - driver.click('Add DTA files'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - finalSourceIds = string({runtime.state.project.inputs.sources.id}); - testCase.verifyEqual(finalSourceIds(1:2), ... - registeredSourceIds(1:2), ... - 'Re-adding a source should not renumber retained sources.'); - testCase.verifyEqual(numel(unique(finalSourceIds)), ... - numel(finalSourceIds), ... - 'Re-adding a source should allocate a collision-free identity.'); - clear outputCleanup; - clear thirdCleanup; + projectPath = fullfile(outputFolder, "vt-project.mat"); + runtime.saveProject(runtime.State, projectPath); + runtime.applyFileSelection("files", strings(1, 0), ... + zeros(1, 0)); + testCase.verifyEmpty(runtime.State.session.cache.items); + runtime.restoreProject(projectPath); + testCase.verifyEqual(numel( ... + runtime.State.session.cache.items), 2); + clear runtimeCleanup folderCleanup end - end -end -function assertVtResistanceLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Add DTA files', 'Remove selected', ... - 'Clear all', 'Export results CSV'}); - h.assertTextsAbsent(fig, {'Re-analyze file', 'Refresh plots', ... - 'Swap top / bottom', 'Reset axes'}); - h.assertCheckboxContract(fig, {'Show markers', 'Shade windows', 'Grid'}); - h.assertTabTitles(fig, {'Files + Analysis', 'Summary + Results', 'Log'}); - choices = vt_resistance.userInterface.analysisChoices(); - h.assertDropdownGroups(fig, [ ... - h.dropdownGroup(cellstr(choices.pulseModes), 1), ... - h.dropdownGroup(cellstr(choices.steadyWindows), 1), ... - h.dropdownGroup(cellstr(choices.voltageModes), 1), ... - h.dropdownGroup(cellstr(choices.xAxes), 2), ... - h.dropdownGroup(cellstr(choices.yAxes), 2)]); - h.assertDropdownCallbacksPresent(fig); -end - -function verifyVtPlotAxisClearRemovesAnnotations() - fig = uifigure('Visible', 'off'); - cleaner = onCleanup(@() delete(fig)); - ax = uiaxes(fig); - plot(ax, 1:3, [1 4 2], 'HandleVisibility', 'off'); - hold(ax, 'on'); - xline(ax, 2, ':', 'marker'); - text(ax, 2, 3, 'annotation', 'HandleVisibility', 'off'); - labkit.ui.plot.clear(ax, "ResetScale", true); - assert(isempty(ax.Children), ... - 'VT plot refresh should remove previous hidden markers and annotations.'); - assert(strcmp(ax.XLimMode, 'auto') && strcmp(ax.YLimMode, 'auto'), ... - 'VT plot refresh should restore automatic axis limits.'); + function plotRedrawRemovesHiddenAnnotations(testCase) + figure = uifigure("Visible", "off"); + cleanup = onCleanup(@() delete(figure)); + axesHandle = uiaxes(figure); + plot(axesHandle, 1:3, [1 4 2], "HandleVisibility", "off"); + hold(axesHandle, "on"); + xline(axesHandle, 2, ":", "marker"); + text(axesHandle, 2, 3, "annotation", ... + "HandleVisibility", "off"); + labkit.app.plot.clearAxes(axesHandle, "ResetScale", true); + testCase.verifyEmpty(axesHandle.Children); + testCase.verifyEqual(axesHandle.XLimMode, 'auto'); + testCase.verifyEqual(axesHandle.YLimMode, 'auto'); + clear cleanup + end + end end diff --git a/tests/cases/gui/apps/gait/gait_analysis/GuiLayoutGaitAnalysisTest.m b/tests/cases/gui/apps/gait/gait_analysis/GuiLayoutGaitAnalysisTest.m index 507950b84..0c601dfc4 100644 --- a/tests/cases/gui/apps/gait/gait_analysis/GuiLayoutGaitAnalysisTest.m +++ b/tests/cases/gui/apps/gait/gait_analysis/GuiLayoutGaitAnalysisTest.m @@ -1,85 +1,184 @@ classdef GuiLayoutGaitAnalysisTest < matlab.unittest.TestCase - %GUILAYOUTGAITANALYSISTEST Verify Gait Analysis GUI launch and layout contract. + % Verify Gait Analysis through the explicit App SDK runtime. + methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) + function nativeLayoutUsesSemanticTargets(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + cleanup = onCleanup(@() helpers.closeAllFigures()); + figure = labkit_GaitAnalysis_app(); + + assertGaitLayout(helpers, figure); + clear cleanup + end - methods (Test, TestTags = {'GUI', 'Structural'}) - function gait_analysis_launches_with_expected_controls(testCase) + function poseDrivesAnalysisNavigationExportAndRestore(testCase) setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + outputFolder = string(tempname); + mkdir(outputFolder); + folderCleanup = onCleanup(@() removeTempFolder(outputFolder)); + backend = struct( ... + "chooseOutputFolder", @(~) ... + labkit.app.dialog.Choice(outputFolder), ... + "alert", @(~, ~) []); + app = gait_analysis.definition(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + app, [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); + sampleContext = labkit.app.diagnostic.SampleContext( ... + fullfile(outputFolder, "debug-sample")); + pack = gait_analysis.debug.writeSamplePack(sampleContext); + posePath = string(pack.InitialProject.inputs.sources(1) ... + .reference.originalPath); - [fig, debug] = labkit_GaitAnalysis_app("debug"); - drawnow; + runtime.applyFileSelection( ... + "poseFile", posePath, 1); - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Open Video Marker MAT', 'Run analysis', ... - 'Choose output folder', 'Export CSV set'}); - h.assertTabTitles(fig, {'Source', 'Roles + Detection', ... - 'Results + Export', 'Log'}); - testCase.verifyTrue(debug.enabled && debug.traceEnabled); - assertAnyTextAreaContains(h, fig, 'Debug sample generation enabled', ... - 'Runtime debug-sample lifecycle should be mirrored into the Log tab.'); + testCase.verifyTrue(runtime.State.session.cache.pose.ok); + testCase.verifyEqual( ... + runtime.State.project.parameters.frameRate, 30); + skeleton = findall(figure, "Tag", "gaitAxes.skeleton"); + testCase.verifyNotEmpty(skeleton.Children); + testCase.verifyEqual(skeleton.YDir, 'reverse'); + testCase.verifyEqual(string( ... + component(figure, "poseFile.choose").Text), ... + "Open Video Marker MAT"); + testCase.verifyEqual(string( ... + component(figure, "poseFile.status").Value), ... + posePath); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'Gait Analysis must execute through Runtime V2.'); - driver = labkitWorkflowDriver(fig); - pack = gait_analysis.debug.writeSamplePack(debug); - driver.chooseFiles('poseFile', pack.representativeFiles); - driver.click('Open Video Marker MAT'); - testCase.verifyTrue(driver.enabled('runAnalysis')); - testCase.verifyGreaterThan(driver.previewChildCount('gaitAxes'), 0); - ui = getappdata(fig, 'labkitUiRegistry'); - gaitAxes = ui.controls.gaitAxes.axesById.skeleton; - testCase.verifyEqual(string(gaitAxes.YDir), "reverse", ... - ['Trajectory preview should use the same top-left image ' ... - 'coordinate origin as marker source data.']); - driver.click('Run analysis'); - testCase.verifyTrue(driver.enabled('exportResults')); - testCase.verifyGreaterThan(height( ... - getappdata(fig, 'labkitUiAppRuntime').state.project.results.analysis.frameTable), 0); - angleAxes = ui.controls.gaitAxes.axesById.angles; - testCase.verifyGreaterThan(numel(angleAxes.Children), 0); - testCase.verifyEqual(string(angleAxes.YDir), "normal", ... - 'Time-series previews should restore the normal Y direction.'); + runtime.applyControlValue( ... + "originAtFirstFrameFirstPoint", true); + testCase.verifyTrue(runtime.State.project.parameters ... + .originAtFirstFrameFirstPoint); + testCase.verifyFalse( ... + runtime.State.project.results.analysis.ok); - outputFolder = string(tempname); - mkdir(outputFolder); - outputCleanup = onCleanup(@() removeTempFolder(outputFolder)); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.outputFolderChooser = @(~, ~) char(outputFolder); - setappdata(fig, 'labkitUiAppRuntime', runtime); - driver.click('Choose output folder'); - driver.click('Export CSV set'); - expected = ["synthetic_video_marker_autosave_frames.csv", ... - "synthetic_video_marker_autosave_coordinates.csv", ... - "synthetic_video_marker_autosave_steps.csv", ... - "synthetic_video_marker_autosave_summary.csv", ... - "synthetic.video_marker.autosave_gait.labkit.json"]; + runtime.invokeAction("runAnalysis"); + + result = runtime.State.project.results.analysis; + testCase.verifyTrue(result.ok); + testCase.verifyGreaterThan(height(result.frameTable), 0); + testCase.verifyGreaterThan(height(result.stepTable), 0); + angles = findall(figure, "Tag", "gaitAxes.angles"); + testCase.verifyNotEmpty(angles.Children); + testCase.verifyEqual(angles.YDir, 'normal'); + row = result.stepTable(1, :); + helpers.assertAxesContract(figure, { ... + helpers.axesSpec(sprintf("Step 1 | frames %d-%d", ... + row.lift_off_frame, row.landing_frame), ... + "Pixel X", "Pixel Y"), ... + helpers.axesSpec("Step 1 joint angles", ... + "Time (s)", "Angle (deg)"), ... + helpers.axesSpec("Step 1 segment lengths", ... + "Time (s)", ... + "Length (" + row.coordinate_unit + ")")}); + assertDisplayGraphicsAreNonPickable(testCase, figure); + if height(result.stepTable) > 1 + runtime.applyTableSelection("stepTable", [2 1]); + testCase.verifyEqual( ... + runtime.State.session.selection.currentStepIndex, 2); + runtime.invokeAction("previousStep"); + testCase.verifyEqual( ... + runtime.State.session.selection.currentStepIndex, 1); + runtime.invokeAction("nextStep"); + testCase.verifyEqual( ... + runtime.State.session.selection.currentStepIndex, 2); + end + + runtime.invokeAction("chooseOutputFolder"); + runtime.invokeAction("exportResults"); + [~, stem] = fileparts(posePath); + expected = stem + [ ... + "_frames.csv", "_coordinates.csv", "_steps.csv", ... + "_summary.csv", "_gait.labkit.json"]; for filepath = fullfile(outputFolder, expected) testCase.verifyTrue(isfile(filepath), ... "Missing gait output: " + filepath); end - projectPath = fullfile(outputFolder, 'gait-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); - saved = load(projectPath, 'labkitProject'); - testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 3); - testCase.verifyFalse(isfield(saved.labkitProject.payload, 'pose')); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyTrue(runtime.state.session.cache.pose.ok, ... - 'Project reopen should rebuild decoded pose cache.'); - testCase.verifyTrue(runtime.state.project.results.analysis.ok, ... - 'Project reopen should retain durable gait results.'); - clear outputCleanup; + projectPath = fullfile(outputFolder, "gait-project.mat"); + runtime.saveProject(runtime.State, projectPath); + runtime.applyFileSelection( ... + "poseFile", strings(1, 0), zeros(1, 0)); + testCase.verifyFalse(runtime.State.session.cache.pose.ok); + runtime.restoreProject(projectPath); + testCase.verifyTrue(runtime.State.session.cache.pose.ok); + testCase.verifyTrue( ... + runtime.State.project.results.analysis.ok); + testCase.verifyEqual(string( ... + component(figure, "poseFile.status").Value), ... + posePath); + clear runtimeCleanup folderCleanup end end end -function removeTempFolder(folder) - if exist(folder, 'dir') == 7 - rmdir(folder, 's'); +function assertGaitLayout(helpers, figure) +ids = [ ... + "poseFile", "poseFile.choose", "poseFile.status", ... + "sourceSummary", "runAnalysis", "analysisStatus", ... + "iliacPoint", "hipPoint", "kneePoint", "anklePoint", "footPoint", ... + "frameRate", "pixelsPerUnit", "unitName", ... + "originAtFirstFrameFirstPoint", "smoothWindow", ... + "detectionProminence", "detectionMinHeightSigma", ... + "minLiftOffIntervalSeconds", "minSwingFrames", ... + "maxSwingFrames", "minStepLength", "maxHipTranslation", ... + "summaryTable", "previousStep", "nextStep", "stepTable", ... + "chooseOutputFolder", "outputFolder", "exportResults", "appLog", ... + "gaitAxes.skeleton", "gaitAxes.angles", "gaitAxes.segments"]; +for id = ids + assert(numel(findall(figure, "Tag", id)) == 1, ... + "Missing Gait Analysis semantic target: %s.", id); +end +tabs = findall(figure, "Type", "uitab"); +assert(isequal(sort(string({tabs.Title})), ... + sort(["Source", "Roles + Detection", "Results + Export", "Log"]))); +assert(numel(findall(figure, "Title", "Gait Preview")) >= 2); +assert(~isempty(findall(figure, "Title", "Workflow Notes"))); +assert(~isempty(findall(figure, "Title", "Video Marker Project"))); +assert(~isempty(findall(figure, "Title", "Keypoint Roles"))); +assert(~isempty(findall(figure, "Title", "Step Detection"))); +assert(isequal(string(component(figure, "stepTable").ColumnName), ... + ["Step"; "Valid"; "Swing_s"; "Step length"])); +previous = component(figure, "previousStep"); +next = component(figure, "nextStep"); +assert(abs(previous.Position(2) - next.Position(2)) <= 1, ... + "Gait step navigation must remain one horizontal action row."); +helpers.assertAxesContract(figure, { ... + helpers.axesSpec("", "", ""), ... + helpers.axesSpec("", "", ""), ... + helpers.axesSpec("", "", "")}); +end + +function assertDisplayGraphicsAreNonPickable(testCase, figure) +axesHandles = [ ... + component(figure, "gaitAxes.skeleton"), ... + component(figure, "gaitAxes.angles"), ... + component(figure, "gaitAxes.segments")]; +for ax = axesHandles + graphics = allchild(ax); + for k = 1:numel(graphics) + if isprop(graphics(k), "HitTest") + testCase.verifyEqual(string(graphics(k).HitTest), "off"); + end + if isprop(graphics(k), "PickableParts") + testCase.verifyEqual(string(graphics(k).PickableParts), "none"); + end end end +end + +function value = component(figure, tag) +value = findall(figure, "Tag", char(tag)); +assert(isscalar(value), "Expected one component with Tag %s.", tag); +end + +function removeTempFolder(folder) +if exist(folder, "dir") == 7 + rmdir(folder, "s"); +end +end diff --git a/tests/cases/gui/apps/image_measurement/batch_crop/GuiLayoutBatchCropTest.m b/tests/cases/gui/apps/image_measurement/batch_crop/GuiLayoutBatchCropTest.m index c0b6da28f..71deedecf 100644 --- a/tests/cases/gui/apps/image_measurement/batch_crop/GuiLayoutBatchCropTest.m +++ b/tests/cases/gui/apps/image_measurement/batch_crop/GuiLayoutBatchCropTest.m @@ -1,185 +1,166 @@ classdef GuiLayoutBatchCropTest < matlab.unittest.TestCase - %GUILAYOUTBATCHCROPTEST Verify batch crop GUI layout contracts. - + % Verify Batch Crop through the explicit App SDK runtime. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) - function batch_crop_workflow_exports_synthetic_crop(testCase) + function nativeLayoutUsesSemanticTargets(testCase) setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + backend = struct("alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + batch_crop.definition(), [], backend); + cleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); + ids = ["images", "duplicateImage", ... + "cropWidth", "rotation", "centerX", ... + "measureScaleReference", "scaleReferencePixels", ... + "placeScaleBar", "exportCrops", "resultTable", ... + "preview"]; + for id = ids + testCase.verifyEqual(numel(findall( ... + figure, "Tag", id)), 1); + end + tabs = findall(figure, "Type", "uitab"); + testCase.verifyEqual(sort(string({tabs.Title})), ... + sort(["Files + Analysis", "Scale", ... + "Summary + Results", "Log"])); + for id = ["cropWidth", "cropHeight", "rotation", ... + "paddingPercent", "centerX", "centerY", ... + "physicalWidth", "physicalHeight", ... + "targetPixelsPerUnit", "maxUpsamplePercent", ... + "scaleReferencePixels", "scaleReferenceLength", ... + "scaleBarLength"] + testCase.verifyEqual(numel(findall( ... + figure, "Tag", id + ".slider")), 1); + end + testCase.verifyEqual(string(component( ... + figure, "images.choose").Text), "Add images or folder"); + testCase.verifyEqual(string(component( ... + figure, "images.folder").Text), "Add folder"); + testCase.verifyEqual(string(component( ... + figure, "images.recursiveFolder").Text), "Add folder tree"); + testCase.verifyEqual(string(component( ... + figure, "images.remove").Text), "Remove selected"); + testCase.verifyEqual(string(component( ... + figure, "images.clear").Text), "Clear images"); + panelTitles = string({findall(figure, "Type", "uipanel").Title}); + testCase.verifyTrue(all(ismember( ... + ["Crop images", "Scale Mode", "Current Image Scale", ... + "Summary", "Batch Results", "Details", "Crop Preview", ... + "Padded rotation preview + fixed crop"], panelTitles))); + testCase.verifyClass(component(figure, "imageSource"), ... + "matlab.ui.control.TextArea"); + testCase.verifyEqual(string( ... + component(figure, "imageSource").Editable), "off"); + clear cleanup + end - folder = tempname; + function cropTasksCenterAndExportSyntheticImages(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); - sourcePath = fullfile(folder, 'source.png'); - secondSourcePath = fullfile(folder, 'source_second.png'); - imwrite(syntheticCropImage(), sourcePath); - imwrite(rot90(syntheticCropImage()), secondSourcePath); - - [fig, debug] = labkit_BatchImageCrop_app("debug"); - drawnow; - assertBatchCropLayout(h, fig); - assert(debug.enabled && debug.traceEnabled, ... - 'Batch crop debug launch should return an enabled trace logger.'); - assertAnyTextAreaContains(h, fig, 'Debug sample generation enabled', ... - 'Batch crop debug launch should mirror trace lines into the visible Log tab.'); - driver = labkitWorkflowDriver(fig); - testCase.verifyTrue(isfile(debug.manifestFile), ... - 'Batch crop debug launch should record a sample manifest.'); - testCase.verifyEqual(char(driver.fileStatus('images')), 'No images loaded', ... - 'Batch crop debug launch should not preload generated samples.'); - - driver.chooseFiles('images', sourcePath); - - driver.click('Add images or folder'); - assert(driver.enabled('useImageCenter') && ... - driver.enabled('useImageXCenter') && driver.enabled('useImageYCenter'), ... - 'Center alignment buttons should enable after a source image loads.'); - assert(driver.enabled('exportCrops'), ... - 'Batch crop export should enable after a source image loads.'); - assert(contains(driver.fileStatus('images'), '1'), ... - 'Batch crop image file status should report the loaded image count.'); - assert(any(contains(driver.fileListItems('images'), 'source.png')), ... - 'Batch crop file list should show the synthetic source image.'); - ui = getappdata(fig, 'labkitUiRegistry'); - testCase.verifyEqual(string(ui.controls.rotation.slider.Enable), "on"); - testCase.verifyEqual(ui.controls.rotation.valueSpinner.Step, 0.1); - testCase.verifyEqual(string(ui.controls.paddingPercent.slider.Enable), "on"); - testCase.verifyEqual(string(ui.controls.centerX.slider.Enable), "on"); - testCase.verifyEqual(string(ui.controls.centerY.slider.Enable), "on"); - testCase.verifyEqual(ui.controls.cropWidth.slider.Limits, [1 120]); - testCase.verifyEqual(ui.controls.cropHeight.slider.Limits, [1 120]); - testui.control.setValue(ui, 'cropWidth', 20); - ui.controls.cropWidth.valueSpinner.ValueChangedFcn( ... - ui.controls.cropWidth.valueSpinner, struct('PreviousValue', 34)); - h.waitForUiIdle(fig); - testCase.verifyEqual(testui.control.getValue(ui, 'cropWidth'), 20, ... - 'User crop-size edits should survive the migrated runtime render pass.'); - testui.control.setValue(ui, 'scaleMode', 'Physical'); - ui.controls.scaleMode.valueHandle.ValueChangedFcn( ... - ui.controls.scaleMode.valueHandle, struct()); - h.waitForUiIdle(fig); - testCase.verifyEqual(string(ui.controls.physicalWidth.slider.Enable), "on"); - testCase.verifyEqual(string(ui.controls.physicalHeight.slider.Enable), "on"); - testCase.verifyEqual(string(ui.controls.targetPixelsPerUnit.slider.Enable), "on"); - testCase.verifyEqual(string(ui.controls.maxUpsamplePercent.slider.Enable), "on"); - testui.control.setValue(ui, 'scaleMode', 'Pixels'); - ui.controls.scaleMode.valueHandle.ValueChangedFcn( ... - ui.controls.scaleMode.valueHandle, struct()); - h.waitForUiIdle(fig); + sourcePath = fullfile(folder, "source.png"); + imageData = syntheticCropImage(); + imwrite(imageData, sourcePath); + backend = struct("alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + batch_crop.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); - driver.click('Use XY center'); - data = driver.tableData('resultTable'); - assert(any(strcmp(string(data(:, 1)), 'Confirmed centers') & ... - strcmp(string(data(:, 2)), '1 / 1')), ... - 'Using the image center should confirm the current crop center.'); - - driver.click('Export cropped images'); - outputFolder = fullfile(folder, 'batch_crop'); - manifestFiles = dir(fullfile(outputFolder, '*manifest*.csv')); - cropFiles = dir(fullfile(outputFolder, '*_crop.png')); - assert(~isempty(manifestFiles), ... - 'Batch crop workflow should write a manifest CSV.'); - assert(~isempty(cropFiles), ... - 'Batch crop workflow should write a cropped image.'); - assert(any(contains(string(driver.textAreaValue('details')), 'Last manifest')), ... - 'Batch crop details should show the last manifest after export.'); - resultManifestPath = fullfile(outputFolder, ... - 'batch_crop_results.labkit.json'); - testCase.verifyTrue(isfile(resultManifestPath), ... - 'Batch crop workflow should write a standard LabKit result manifest.'); - resultManifest = jsondecode(fileread(resultManifestPath)); - testCase.verifyEqual(string(resultManifest.format), "labkit.result"); - testCase.verifyGreaterThanOrEqual(numel(resultManifest.outputs), 2); - - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'Batch crop workflow must execute through runtime V2.'); - testCase.verifyFalse(isfield(runtime.state, 'tools'), ... - 'Batch crop canonical state must not own live interaction tools.'); - testCase.verifyTrue(any([runtime.resources.scope] == "interaction"), ... - 'Batch crop ROI placement should be runtime-owned.'); - cropResource = runtime.resources( ... - [runtime.resources.scope] == "interaction" & ... - [runtime.resources.id] == "cropCenter").value; - testCase.verifyEqual(cropResource.spec.Kind, "pointSlots", ... - 'Crop placement should use the Imager-style center drag handle.'); + runtime.applyFileSelection("images", sourcePath, 1); + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.items), 1); testCase.verifyTrue( ... - cropResource.spec.Options.placeSelectedOnBackground, ... - 'Crop background clicks should remain a one-step placement action.'); - projectPath = fullfile(folder, 'batch-crop-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); - saved = load(projectPath, 'labkitProject'); - testCase.verifyEqual(string(saved.labkitProject.format), ... - "labkit.project"); - testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 2); - testCase.verifyFalse(isfield( ... - saved.labkitProject.payload.inputs.items, 'image'), ... - 'Batch crop projects must not persist decoded image pixels.'); - testCase.verifyFalse(isfield( ... - saved.labkitProject.payload.inputs.items, 'path'), ... - 'Crop tasks should reference the canonical source record.'); - testCase.verifyFalse(isfield(saved.labkitProject.payload, 'session'), ... - 'Batch crop project files should exclude session/cache state.'); + batch_crop.sourceFiles.hasCurrentImage(runtime.State)); + runtime.applyBinding("cropWidth", 20); + runtime.invokeAction("useImageCenter"); + runtime.invokeAction("duplicateImage"); + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.sources), 2); + testCase.verifyNotEqual(string( ... + runtime.State.project.inputs.sources(1).id), string( ... + runtime.State.project.inputs.sources(2).id)); + labels = string(component(figure, "images").Items); + testCase.verifyEqual(numel(labels), 2); + testCase.verifyTrue(startsWith(labels(1), "01 source.png")); + testCase.verifyTrue(endsWith(labels(1), "[ready]")); + testCase.verifyTrue(startsWith(labels(2), "02 source.png")); + testCase.verifyTrue(endsWith(labels(2), "[needs center]")); + testCase.verifyEqual( ... + runtime.State.session.selection.currentIndex, 2); + runtime.invokeAction("previousImage"); + testCase.verifyEqual( ... + runtime.State.session.selection.currentIndex, 1); + testCase.verifyEqual(string(component(figure, "images").Value), ... + string(component(figure, "images").Items(1))); + runtime.invokeAction("nextImage"); + remove = component(figure, "images.remove"); + remove.ButtonPushedFcn(remove, struct()); + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.items), 1); + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.sources), 1); + runtime.invokeAction("duplicateImage"); + runtime.invokeAction("useImageCenter"); + + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.items), 2); + testCase.verifyTrue(all( ... + [runtime.State.project.inputs.items.centerSet])); + testCase.verifyEqual( ... + runtime.State.project.parameters.cropWidth, 20); + testCase.verifyNotEmpty(findall( ... + figure, "Tag", "preview").Children); - testui.control.setValue(ui, 'cropWidth', 30); - ui.controls.cropWidth.valueSpinner.ValueChangedFcn( ... - ui.controls.cropWidth.valueSpinner, struct('PreviousValue', 20)); - h.waitForUiIdle(fig); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.state.project.parameters.cropWidth, 20, ... - 'Project reopen should restore durable crop parameters.'); - testCase.verifyEqual(numel(runtime.state.project.inputs.items), 1, ... - 'Project reopen should restore durable crop tasks.'); - testCase.verifyTrue(runtime.state.project.inputs.items(1).centerSet, ... - 'Project reopen should preserve confirmed crop centers.'); - testCase.verifyFalse(isfield(runtime.state.session.cache, 'tools'), ... - 'Project reopen should construct a fresh resource-free session cache.'); - testCase.verifyGreaterThan(numel(ui.controls.preview.primaryAxes.Children), 0, ... - 'Project reopen should rebuild the preview from durable image data.'); + runtime.applyControlValue("scaleMode", "Physical"); + testCase.verifyEqual(string(component( ... + figure, "physicalWidth").Enable), "on"); + runtime.invokeAction("measureScaleReference"); + testCase.verifyEqual(string(component( ... + figure, "measureScaleReference").Text), ... + "Finish reference edit"); + runtime.invokeAction("measureScaleReference"); + runtime.applyControlValue("scaleReferencePixels", 20); + runtime.applyControlValue("scaleReferenceLength", 10); + testCase.verifyTrue(runtime.State.project.inputs.items(2) ... + .scaleCalibration.isCalibrated); + testCase.verifyTrue(endsWith(string(component( ... + figure, "images").Items(2)), "[ready]")); + runtime.applyControlValue("scaleMode", "Pixels"); - driver.chooseFiles('images', secondSourcePath); - driver.click('Add images or folder'); - assert(contains(driver.fileStatus('images'), '2'), ... - 'Batch crop append should preserve the existing crop task.'); - assert(contains(driver.fileSelection('images'), 'source_second.png'), ... - 'Batch crop append should select the newly added image.'); + runtime.invokeAction("exportCrops"); - driver.click('Duplicate image'); - h.waitForUiIdle(fig); - assert(contains(driver.fileStatus('images'), '3'), ... - 'Duplicating the current crop task should redraw without invalid overlay coordinates.'); + outputFolder = fullfile(folder, "batch_crop"); + testCase.verifyNotEmpty(dir(fullfile( ... + outputFolder, "*_crop.png"))); + testCase.verifyNotEmpty(dir(fullfile( ... + outputFolder, "*manifest*.csv"))); + testCase.verifyTrue(isfile(fullfile( ... + outputFolder, "batch_crop_results.labkit.json"))); + testCase.verifyTrue(strlength( ... + runtime.State.project.results.resultManifestPath) > 0); + clear runtimeCleanup folderCleanup end end end -function assertBatchCropLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Add images or folder', ... - 'Remove selected', 'Clear images', ... - 'Duplicate image', 'Previous image', 'Next image', 'Use XY center', ... - 'Use X center', 'Use Y center', ... - 'Choose export folder', 'Export cropped images', ... - 'Measure reference pixels', 'Place scale bar'}); - h.assertDropdownGroups(fig, [ ... - h.dropdownGroup({'PNG', 'TIFF', 'JPEG'}, 1), ... - h.dropdownGroup({'Pixels', 'Physical'}, 1), ... - h.dropdownGroup({'m', 'cm', 'mm', 'um', 'nm'}, 2), ... - h.dropdownGroup({'Bottom center', 'Bottom left', 'Bottom right', ... - 'Top center', 'Top left', 'Top right'}, 1), ... - h.dropdownGroup({'Black', 'White'}, 1)]); - h.assertTabTitles(fig, {'Files + Analysis', 'Scale', 'Summary + Results', 'Log'}); +function value = component(figure, id) +matches = findall(figure, "Tag", id); +assert(numel(matches) == 1, ... + 'Expected one component with tag %s.', id); +value = matches(1); end -function img = syntheticCropImage() - [x, y] = meshgrid(1:48, 1:36); - img = uint8(mod(x .* 5 + y .* 7, 256)); +function imageData = syntheticCropImage() +[x, y] = meshgrid(1:48, 1:36); +imageData = uint8(mod(x .* 5 + y .* 7, 256)); end function removeTempFolder(folder) - if exist(folder, 'dir') == 7 - rmdir(folder, 's'); - end +if exist(folder, "dir") == 7 + rmdir(folder, "s"); +end end diff --git a/tests/cases/gui/apps/image_measurement/curvature/GuiLayoutCurvatureTest.m b/tests/cases/gui/apps/image_measurement/curvature/GuiLayoutCurvatureTest.m index 0b5f06755..fc402036c 100644 --- a/tests/cases/gui/apps/image_measurement/curvature/GuiLayoutCurvatureTest.m +++ b/tests/cases/gui/apps/image_measurement/curvature/GuiLayoutCurvatureTest.m @@ -1,5 +1,5 @@ classdef GuiLayoutCurvatureTest < matlab.unittest.TestCase - %GUILAYOUTCURVATURETEST Verify curvature measurement GUI layout contracts. + %GUILAYOUTCURVATURETEST Verify the complete Curvature GUI workflow. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) function curvature_workflow_fits_curve_and_measures_length(testCase) @@ -8,138 +8,163 @@ function curvature_workflow_fits_curve_and_measures_length(testCase) h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - folder = tempname; + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); - imagePath = fullfile(folder, 'curvature.png'); + imagePath = fullfile(folder, "curvature.png"); + replacementPath = fullfile(folder, "replacement.png"); imwrite(syntheticCurvatureImage(), imagePath); - - [fig, debug] = labkit_CurvatureMeasurement_app("debug"); - drawnow; - assertCurvatureLayout(h, fig); - assert(debug.enabled && debug.traceEnabled, ... - 'Curvature debug launch should return an enabled trace logger.'); - driver = labkitWorkflowDriver(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'Curvature must execute through Runtime V2.'); - assertAnyTextAreaContains(h, fig, 'Debug sample generation enabled', ... - 'Runtime debug-sample lifecycle should be mirrored into the Log tab.'); - driver.chooseFiles('imageFile', imagePath); - - driver.click('Choose image'); - driver.click('Measure reference pixels'); - driver.setAnchorPoints('imageAxes', [25 90; 125 90]); - driver.click('Finish reference edit'); - testCase.verifyTrue(driver.enabled('placeScaleBar'), ... - 'Scale-bar placement should enable after reference calibration.'); - driver.click('Place scale bar'); - driver.click('Start curve edit'); - ui = driver.registry(); - testCase.verifyTrue(contains(string( ... - ui.controls.imageAxes.primaryAxes.Subtitle.String), ... - 'Double-click blank image space'), ... - ['Active curve editing should show the placement, drag, and ' ... - 'deletion gestures directly on the preview.']); - driver.setAnchorPoints('imageAxes', [28 70; 48 42; 84 30; 120 42; 140 70]); - - ui = driver.registry(); - testCase.verifyTrue(contains(string(ui.controls.pointCount.valueHandle.Value), ... - '5'), ... - 'Curvature workflow should show the injected curve point count.'); - testCase.verifyTrue(contains(string(driver.textAreaValue('detailsText')), ... - 'Curve edit active'), ... - 'Curvature workflow should show edit guidance while the anchor editor is active.'); - - driver.click('Finish curve edit'); - testCase.verifyTrue(driver.enabled('fitCurvature'), ... - 'Curvature fit action should enable after at least three curve points.'); - testCase.verifyTrue(driver.enabled('measureCurveLength'), ... - 'Curve length action should enable after at least two curve points.'); - - driver.click('Fit circle + curvature'); - fitTable = driver.tableData('resultTable'); - testCase.verifyTrue(any(strcmp(string(fitTable(:, 1)), 'Radius')), ... - 'Curvature workflow should write radius into the result table.'); - testCase.verifyTrue(any(strcmp(string(fitTable(:, 1)), 'Curvature')), ... - 'Curvature workflow should write curvature into the result table.'); - testCase.verifyTrue(any(contains(string(driver.textAreaValue('detailsText')), ... - 'Curve length')), ... - 'Curvature workflow should refresh details after the curvature fit.'); - testCase.verifyGreaterThan(driver.previewChildCount('imageAxes'), 0, ... - 'Curvature workflow should draw the image preview and overlays.'); - - driver.click('Measure curve length'); - lengthTable = driver.tableData('resultTable'); - testCase.verifyTrue(any(strcmp(string(lengthTable(:, 1)), 'Curve length')), ... - 'Curvature workflow should keep curve length visible after measuring length.'); + imwrite(flip(syntheticCurvatureImage(), 2), replacementPath); outputFolder = string(tempname); mkdir(outputFolder); outputCleanup = onCleanup(@() removeTempFolder(outputFolder)); outputs = ["curvature_result.csv", "curvature_overlay.png"]; outputIndex = 0; - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.outputChooser = @chooseOutput; - setappdata(fig, 'labkitUiAppRuntime', runtime); - driver.click('Export result CSV'); - driver.click('Export overlay PNG'); + backend = struct( ... + "chooseOutputFile", @chooseOutput, ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + curvature.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); + assertCurvatureLayout(h, fig); + + runtime.applyFileSelection("imageFile", imagePath, 1); + testCase.verifyEqual(string(component( ... + fig, "imageFile.status").Value), "Image loaded"); + runtime.invokeAction("measureScaleReference"); + testCase.verifyEqual( ... + runtime.State.session.workflow.editMode, "reference"); + testCase.verifyEqual(string(component( ... + fig, "measureScaleReference").Text), ... + "Finish reference edit"); + runtime.applyInteraction( ... + "scaleReference", "interactionChanged", ... + [25 90; 125 90]); + runtime.invokeAction("measureScaleReference"); + runtime.applyControlValue("scaleReferenceLength", 100); + runtime.applyControlValue("scaleCalibrationUnit", "um"); + runtime.invokeAction("placeScaleBar"); + testCase.verifyTrue( ... + runtime.State.project.annotations.calibration.isCalibrated); + testCase.verifyNotEmpty(runtime.State.session.view.scaleBar); + + curvePoints = [28 70; 48 42; 84 30; 120 42; 140 70]; + runtime.invokeAction("startCurveEdit"); + testCase.verifyEqual( ... + runtime.State.session.workflow.editMode, "curve"); + testCase.verifyEqual(string(component( ... + fig, "startCurveEdit").Text), "Finish curve edit"); + runtime.applyInteraction( ... + "curve", "interactionChanged", curvePoints); + runtime.invokeAction("startCurveEdit"); + runtime.applyControlValue("densePointCount", 400); + runtime.invokeAction("fitCurvature"); + runtime.invokeAction("measureCurveLength"); + testCase.verifyTrue(runtime.State.project.results.fit.ok); + testCase.verifyTrue(runtime.State.project.results.length.ok); + testCase.verifyEqual( ... + runtime.State.project.parameters.densePointCount, 400); + previewAxes = component(fig, "preview.image"); + testCase.verifyNotEmpty(previewAxes.Children); + testCase.verifyTrue(contains( ... + string(previewAxes.Title.String), "curvature")); + resultTable = component(fig, "resultTable"); + testCase.verifyEqual(size(resultTable.Data, 1), 7); + testCase.verifyNotEqual(string(resultTable.Data{2, 2}), "-"); + + runtime.invokeAction("exportCsv"); + runtime.invokeAction("exportOverlay"); for filepath = fullfile(outputFolder, outputs) testCase.verifyTrue(isfile(filepath)); end - testCase.verifyTrue(isfile(fullfile(outputFolder, ... - 'curvature_result.labkit.json'))); - testCase.verifyTrue(isfile(fullfile(outputFolder, ... - 'curvature_overlay.labkit.json'))); + testCase.verifyTrue(isfile(fullfile( ... + outputFolder, "curvature_result.labkit.json"))); + testCase.verifyTrue(isfile(fullfile( ... + outputFolder, "curvature_overlay.labkit.json"))); + testCase.verifyEqual( ... + runtime.State.project.results.lastCsvExport.csvPath, ... + fullfile(outputFolder, outputs(1))); + testCase.verifyEqual( ... + runtime.State.project.results.lastOverlayExport.pngPath, ... + fullfile(outputFolder, outputs(2))); - projectPath = fullfile(outputFolder, 'curvature-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); - saved = load(projectPath, 'labkitProject'); - testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 2); - testCase.verifyFalse(isfield(saved.labkitProject.payload, 'image')); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyNotEmpty(runtime.state.session.cache.image, ... - 'Project reopen should rebuild the decoded image cache.'); - testCase.verifyTrue(runtime.state.project.results.fit.ok, ... - 'Project reopen should retain the durable curvature fit.'); - clear outputCleanup; + projectPath = fullfile( ... + outputFolder, "curvature-project.mat"); + runtime.saveProject(runtime.State, projectPath); + saved = load(projectPath, "labkitProject"); + testCase.verifyEqual( ... + saved.labkitProject.app.payloadVersion, 2); + testCase.verifyFalse(isfield( ... + saved.labkitProject.payload, "image")); - function [filename, folderPath] = chooseOutput(~, ~, ~) + runtime.applyFileSelection( ... + "imageFile", replacementPath, 1); + testCase.verifyFalse( ... + runtime.State.project.results.fit.ok, ... + "A replacement image must invalidate the old fit."); + testCase.verifyFalse( ... + runtime.State.project.annotations.calibration.isCalibrated, ... + "A replacement image must invalidate pixel calibration."); + runtime.restoreProject(projectPath); + testCase.verifyNotEmpty(runtime.State.session.cache.image, ... + "Project reopen should rebuild the decoded image cache."); + testCase.verifyTrue(runtime.State.project.results.fit.ok, ... + "Project reopen should retain the durable curvature fit."); + clear runtimeCleanup outputCleanup; + + function choice = chooseOutput(~, ~) outputIndex = outputIndex + 1; - filename = char(outputs(outputIndex)); - folderPath = char(outputFolder); + choice = labkit.app.dialog.Choice( ... + fullfile(outputFolder, outputs(outputIndex))); end end end end function assertCurvatureLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Choose image', 'Start curve edit', ... - 'Undo last point', 'Clear curve', 'Measure reference pixels', ... - 'Place scale bar', 'Fit circle + curvature', ... - 'Measure curve length', 'Export result CSV', 'Export overlay PNG'}); - h.assertCheckboxContract(fig, {'Densify before circle fit', ... - 'Show dense fit points'}); - h.assertDropdownGroups(fig, [ ... - h.dropdownGroup({'m', 'cm', 'mm', 'um', 'nm'}, 1), ... - h.dropdownGroup({'Bottom center', 'Bottom left', 'Bottom right', ... - 'Top center', 'Top left', 'Top right'}, 1), ... - h.dropdownGroup({'Black', 'White'}, 1)]); - h.assertTabTitles(fig, {'Files + Analysis', 'Summary + Results', 'Log'}); +h.assertStartupSucceeded(fig); +ids = [ ... + "imageFile", "pointCount", "startCurveEdit", ... + "undoCurvePoint", "clearCurve", ... + "measureScaleReference", "scaleReferencePixels", ... + "scaleReferenceLength", "scaleCalibrationUnit", ... + "scaleBarLength", "scaleBarPosition", "scaleBarColor", ... + "placeScaleBar", "scaleReferenceReadout", ... + "pixelsPerUnitReadout", "densify", "densePointCount", ... + "showDensePoints", "fitCurvature", "measureCurveLength", ... + "exportCsv", "exportOverlay", "resultTable", ... + "detailsText", "appLog", "preview.image"]; +for id = ids + assert(numel(findall(fig, "Tag", id)) == 1, ... + "Missing Curvature semantic target: %s.", id); +end +tabs = findall(fig, "Type", "uitab"); +assert(isequal(sort(string({tabs.Title})), ... + sort(["Files + Analysis", "Summary + Results", "Log"]))); +assert(numel(findall(fig, "Title", "Image Preview")) >= 2); +assert(~isempty(findall(fig, "Title", "Workflow Notes"))); +assert(~isempty(findall(fig, "Title", "Curvature Results"))); +assert(size(component(fig, "resultTable").Data, 1) == 7); +h.assertAxesContract(fig, { ... + h.axesSpec("Image + Circle Fit", "", "")}); +end + +function value = component(figureHandle, tag) +value = findall(figureHandle, "Tag", char(tag)); +assert(isscalar(value), "Expected one component with Tag %s.", tag); end function img = syntheticCurvatureImage() - [x, y] = meshgrid(1:168, 1:104); - background = 0.30 + 0.20 .* sin(x ./ 11) + 0.15 .* cos(y ./ 9); - curve = exp(-((sqrt((x - 84).^2 + (y - 88).^2) - 58).^2) ./ 12); - img = uint8(255 .* min(max(background + 0.45 .* curve, 0), 1)); +[x, y] = meshgrid(1:168, 1:104); +background = 0.30 + 0.20 .* sin(x ./ 11) + 0.15 .* cos(y ./ 9); +curve = exp(-((sqrt((x - 84).^2 + (y - 88).^2) - 58).^2) ./ 12); +img = uint8(255 .* min(max(background + 0.45 .* curve, 0), 1)); end function removeTempFolder(folder) - if exist(folder, 'dir') == 7 - rmdir(folder, 's'); - end +if exist(folder, "dir") == 7 + rmdir(folder, "s"); +end end diff --git a/tests/cases/gui/apps/image_measurement/flir_thermal/GuiLayoutFlirThermalTest.m b/tests/cases/gui/apps/image_measurement/flir_thermal/GuiLayoutFlirThermalTest.m index 9af2e69b9..e8a02bd66 100644 --- a/tests/cases/gui/apps/image_measurement/flir_thermal/GuiLayoutFlirThermalTest.m +++ b/tests/cases/gui/apps/image_measurement/flir_thermal/GuiLayoutFlirThermalTest.m @@ -2,11 +2,11 @@ %GUILAYOUTFLIRTHERMALTEST Verify FLIR thermal GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) - function flir_thermal_load_keeps_scale_axis_full_height(testCase) + function flir_thermal_restores_full_display_reading_and_export_workflow(testCase) setupLabKitTestPath(); h = guiTestHelpers(); h.assertUifigureAvailable(); - folder = tempname; + folder = string(tempname); mkdir(folder); cleanupFolder = onCleanup(@() removeTempFolder(folder)); cleanupFigure = onCleanup(@() h.closeAllFigures()); @@ -16,96 +16,112 @@ function flir_thermal_load_keeps_scale_axis_full_height(testCase) writeSyntheticFlirRjpegFixture(secondSourcePath, ... struct("raw", uint16(18000 + [50 60; 70 80]))); - [fig, debug] = labkit_FLIRThermal_app("debug"); - drawnow; + outputFolder = fullfile(folder, "flir_thermal"); + backend = struct( ... + "chooseOutputFolder", @(~) ... + labkit.app.dialog.Choice(outputFolder), ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + flir_thermal.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); assertFlirLayout(h, fig); - assert(debug.enabled && debug.traceEnabled, ... - 'FLIR Thermal debug launch should return an enabled trace logger.'); - assertAnyTextAreaContains(h, fig, 'Debug sample generation enabled', ... - 'FLIR Thermal debug launch should mirror trace lines into the visible Log tab.'); - ui = getappdata(fig, 'labkitUiRegistry'); - testCase.verifyTrue(isfile(debug.manifestFile), ... - 'FLIR Thermal debug launch should record a sample manifest.'); - testCase.verifyEqual(char(testui.control.getValue(ui, 'fileStatus')), 'Files: 0', ... - 'FLIR Thermal debug launch should not preload generated samples.'); - testCase.verifyEqual(char(testui.control.getValue(ui, 'currentImage')), 'No FLIR image loaded', ... - 'FLIR Thermal debug launch should not preload generated samples.'); - - driver = labkitWorkflowDriver(fig); - driver.chooseFiles('thermalFiles', sourcePath); - h.invokeButton(fig, 'Add FLIR files or folder'); - drawnow; - - ui = getappdata(fig, 'labkitUiRegistry'); - scaleAxes = ui.controls.preview.axesById.temperatureScale; - labels = flir_thermal.userInterface.rangeControlLabels(); - - testCase.verifyEqual(string(testui.control.getValue(ui, 'rangePreset')), ... - labels.defaultPreset); - testCase.verifyTrue(isempty(ui.controls.thermalFiles.status)); - testCase.verifyTrue(driver.enabled('rangePreset')); - testCase.verifyTrue(driver.enabled('temperatureMin')); - testCase.verifyTrue(driver.enabled('temperatureMax')); - testCase.verifyEqual(ui.controls.gammaValue.valueSpinner.Value, 2.2); - testCase.verifyEqual(ui.controls.gammaValue.valueSpinner.Limits, [0.1 5]); - testCase.verifyEqual(ui.controls.gammaValue.valueSpinner.Step, 0.1); - testCase.verifyTrue(driver.enabled('autoRange')); - testCase.verifyTrue(driver.enabled('groupRange')); - testCase.verifyTrue(driver.enabled('perImageRange')); - testCase.verifyFalse(driver.enabled('roundRange')); - testCase.verifyEqual(scaleAxes.DataAspectRatioMode, 'auto'); - testCase.verifyEqual(scaleAxes.PlotBoxAspectRatioMode, 'auto'); - testCase.verifyEmpty(char(string(scaleAxes.Title.String))); - testCase.verifyEqual(numel(scaleAxes.Children), 1); - - driver.chooseFiles('thermalFiles', secondSourcePath); - h.invokeButton(fig, 'Add FLIR files or folder'); - testCase.verifyTrue(contains(driver.fileSelection('thermalFiles'), ... - 'synthetic_flir_second.jpg'), ... - 'FLIR Thermal append should select the newly added image.'); - testCase.verifyTrue(contains(string(testui.control.getValue(ui, 'fileStatus')), ... - 'Files: 2'), ... - 'FLIR Thermal append should preserve the existing file.'); - - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'FLIR Thermal should run on the Runtime V2 state contract.'); - testCase.verifyEqual(numel(runtime.state.project.inputs.sources), 2); - testCase.verifyFalse(isfield(runtime.state.project.annotations.items, 'raw')); - testCase.verifyFalse(isfield(runtime.state.project.annotations.items, 'temperatureC'), ... + runtime.applyFileSelection( ... + 'thermalFiles', [sourcePath secondSourcePath], 2); + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.sources), 2); + testCase.verifyEqual( ... + runtime.State.session.selection.currentIndex, 2); + runtime.invokeAction("previousImage"); + testCase.verifyEqual( ... + runtime.State.session.selection.currentIndex, 1); + runtime.invokeAction("nextImage"); + testCase.verifyEqual( ... + runtime.State.session.selection.currentIndex, 2); + testCase.verifyFalse(isfield( ... + runtime.State.project.annotations.items, 'raw')); + testCase.verifyFalse(isfield( ... + runtime.State.project.annotations.items, 'temperatureC'), ... 'Durable FLIR annotations must not persist decoded thermal matrices.'); - testCase.verifyNotEmpty(runtime.state.session.cache.currentItem.temperatureC, ... + testCase.verifyNotEmpty( ... + runtime.State.session.cache.currentItem.temperatureC, ... 'The selected FLIR decode should remain an ephemeral session cache.'); - resource = interactionResource(runtime.resources, 'temperatureReading'); - testCase.verifyEqual(resource.spec.Kind, "regionSelection"); - testCase.verifyEqual(resource.spec.Event, "temperatureRegionSelected"); - testCase.verifyEqual(resource.spec.BackgroundEvent, ... - "temperaturePointSelected"); + thermalAxes = component(fig, "preview.thermalImage"); + scaleAxes = component(fig, "preview.temperatureScale"); + testCase.verifyNotEmpty(thermalAxes.Children); + testCase.verifyNotEmpty(scaleAxes.Children); + testCase.verifyLessThanOrEqual( ... + abs(thermalAxes.Position(4) - scaleAxes.Position(4)), 2); + + runtime.applyControlValue("palette", "iron"); + runtime.applyControlValue("colorMapping", "Gamma"); + runtime.applyControlValue("gammaValue", 1.6); + labels = ... + flir_thermal.thermalPreview.presentationData.rangeControlLabels(); + runtime.applyControlValue("rangePreset", labels.estimatedPreset); + presetRange = ... + runtime.State.session.cache.currentItem.displayRange; + crossedMinimum = presetRange(2) + 10; + crossedMaximum = presetRange(2) - 10; + runtime.applyControlValue("temperatureMin", crossedMinimum); + runtime.applyControlValue("temperatureMax", crossedMaximum); + testCase.verifyEqual( ... + runtime.State.session.cache.currentItem.displayRange, ... + [crossedMaximum presetRange(2)], AbsTol=1e-12); + + runtime.applyInteraction( ... + 'temperatureReading', 'backgroundPressed', [1 1]); + runtime.invokeAction("roiHotMode"); + runtime.applyInteraction( ... + 'temperatureReading', 'interactionChanged', [1 1 1 1]); + runtime.invokeAction("roiColdMode"); + runtime.applyInteraction( ... + 'temperatureReading', 'interactionChanged', [1 1 1 1]); + runtime.invokeAction("roiMeanMode"); + runtime.applyInteraction( ... + 'temperatureReading', 'interactionChanged', [1 1 1 1]); + item = runtime.State.session.cache.currentItem; + testCase.verifyTrue(isfinite(item.manualPoint.temperatureC)); + testCase.verifyTrue(isfinite(item.roiHotSpot.temperatureC)); + testCase.verifyTrue(isfinite(item.roiColdSpot.temperatureC)); + testCase.verifyTrue(isfinite(item.roiMean.temperatureC)); + testCase.verifyGreaterThan( ... + size(component(fig, "summaryTable").Data, 1), 4); + testCase.verifyNotEmpty(component(fig, "details").Value); projectPath = fullfile(folder, 'flir-thermal-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 1); testCase.verifyFalse(isfield(saved.labkitProject.payload, 'session'), ... 'FLIR projects must exclude decoded caches and live interactions.'); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyNotEmpty(runtime.state.session.cache.currentItem.temperatureC, ... + runtime.restoreProject(projectPath); + testCase.verifyNotEmpty( ... + runtime.State.session.cache.currentItem.temperatureC, ... 'Project reopen should lazily rebuild the selected FLIR cache.'); - h.invokeButton(fig, 'Export all'); - h.waitForUiIdle(fig); - testCase.verifyTrue(isfile(fullfile(folder, 'flir_thermal', ... - 'flir_thermal.labkit.json')), ... - 'FLIR export should add a standard result manifest.'); + runtime.invokeAction("chooseOutputFolder"); + runtime.invokeAction("exportCurrent"); + testCase.verifyEqual(numel( ... + runtime.State.project.results.lastExport.results), 1); + runtime.invokeAction("exportAll"); + testCase.verifyNotEmpty( ... + runtime.State.project.results.lastExport); + testCase.verifyTrue(isfolder(outputFolder)); + testCase.verifyEqual(numel( ... + runtime.State.project.results.lastExport.results), 2); + testCase.verifyTrue(isfile( ... + runtime.State.project.results.resultManifestPath)); + testCase.verifyTrue(isfile( ... + fullfile(outputFolder, "flir_thermal_manifest.csv"))); + clear runtimeCleanup end function flir_shared_range_limits_manual_adjustment_to_shared_bounds(testCase) setupLabKitTestPath(); h = guiTestHelpers(); h.assertUifigureAvailable(); - folder = tempname; + folder = string(tempname); mkdir(folder); cleanupFolder = onCleanup(@() removeTempFolder(folder)); cleanupFigure = onCleanup(@() h.closeAllFigures()); @@ -114,62 +130,60 @@ function flir_shared_range_limits_manual_adjustment_to_shared_bounds(testCase) writeSyntheticFlirRjpegFixture(coolPath, struct("raw", uint16(18000 + [0 10; 20 30]))); writeSyntheticFlirRjpegFixture(warmPath, struct("raw", uint16(18000 + [400 420; 450 470]))); - fig = h.launchFigure('labkit_FLIRThermal_app', ... - 'FLIR Thermal Postprocess'); - driver = labkitWorkflowDriver(fig); - driver.chooseFiles('thermalFiles', [string(coolPath); string(warmPath)]); - h.invokeButton(fig, 'Add FLIR files or folder'); - labels = flir_thermal.userInterface.rangeControlLabels(); - h.invokeButton(fig, char(labels.setSharedRange)); - drawnow; - - ui = getappdata(fig, 'labkitUiRegistry'); - minLimits = ui.controls.temperatureMin.slider.Limits; - maxLimits = ui.controls.temperatureMax.slider.Limits; - currentMin = testui.control.getValue(ui, 'temperatureMin'); - currentMax = testui.control.getValue(ui, 'temperatureMax'); - testCase.verifyLessThanOrEqual(max(abs(minLimits - [currentMin currentMax])), 0.05); - testCase.verifyLessThanOrEqual(max(abs(maxLimits - [currentMin currentMax])), 0.05); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + flir_thermal.definition(), [], ... + struct("alert", @(~, ~) [])); + runtimeCleanup = onCleanup(@() runtime.close()); + runtime.applyFileSelection( ... + 'thermalFiles', [coolPath warmPath], [1 2]); + runtime.invokeAction('groupRange'); + + items = runtime.State.project.annotations.items; + testCase.verifyEqual(numel(items), 2); + testCase.verifyEqual(items(1).displayRange, ... + items(2).displayRange, AbsTol=0.05); + testCase.verifyEqual(items(1).rangeControlBounds, ... + items(1).displayRange, AbsTol=0.05); + runtime.applyControlValue( ... + 'temperatureMin', items(1).displayRange(1)); + runtime.applyControlValue( ... + 'temperatureMax', items(1).displayRange(2)); + testCase.verifyEqual( ... + runtime.State.session.cache.currentItem.displayRange, ... + items(1).displayRange, AbsTol=0.05); + clear runtimeCleanup end end end -function resource = interactionResource(resources, id) - index = find([resources.scope] == "interaction" & ... - [resources.id] == string(id), 1); - assert(~isempty(index), 'Expected controlled FLIR interaction resource.'); - resource = resources(index).value; -end - function assertFlirLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - labels = flir_thermal.userInterface.rangeControlLabels(); - h.assertButtonContract(fig, {'Add FLIR files or folder', ... - 'Remove selected', 'Clear files', 'Previous image', ... - 'Next image', char(labels.setEachRange), ... - char(labels.setSharedRange), char(labels.setCurrentRange), ... - char(labels.roundSetRanges), ... - char(labels.roiHotSpot), char(labels.roiColdSpot), ... - char(labels.roiMean), ... - 'Choose folder', 'Export current', 'Export all'}); - h.assertDropdownGroups(fig, [ ... - h.dropdownGroup({'turbo', 'iron', 'hot', 'parula', 'gray'}, 1), ... - h.dropdownGroup({'Linear', 'Log', 'Gamma'}, 1), ... - h.dropdownGroup(flir_thermal.userInterface.rangePresetItems(), 1), ... - h.dropdownGroup({'PNG', 'TIFF', 'JPEG'}, 1)]); - h.assertTabTitles(fig, {'Files + Display + Export', 'Details', 'Log'}); + h.assertStartupSucceeded(fig); + ids = ["thermalFiles", "fileStatus", "previousImage", "nextImage", ... + "currentImage", "palette", "colorMapping", "gammaValue", ... + "rangePreset", "perImageRange", "groupRange", "autoRange", ... + "roundRange", "temperatureMin", "temperatureMax", ... + "outputFolder", "exportFormat", "chooseOutputFolder", ... + "exportCurrent", "exportAll", "summaryTable", ... + "roiHotMode", "roiColdMode", "roiMeanMode", "details", ... + "logPanel", "preview.thermalImage", "preview.temperatureScale"]; + for id = ids + assert(numel(findall(fig, "Tag", id)) == 1, ... + "Missing FLIR Thermal semantic target: %s.", id); + end + tabs = findall(fig, "Type", "uitab"); + assert(isequal(sort(string({tabs.Title})), ... + sort(["Files + Display + Export", "Details", "Log"]))); + assert(~isempty(findall(fig, "Title", "Thermal Preview"))); + assert(~isempty(findall(fig, "Title", "FLIR Images"))); + assert(~isempty(findall(fig, "Title", "Reading Tools"))); h.assertAxesContract(fig, { ... - h.axesSpec('Clean thermal image', '', ''), ... - h.axesSpec('Scale', '', '')}); + h.axesSpec("Clean thermal image", "", ""), ... + h.axesSpec("Scale", "", "")}); end -function assertAnyTextAreaContains(h, fig, needle, message) - areas = h.findControlsByClass(fig, 'TextArea'); - values = strings(1, numel(areas)); - for k = 1:numel(areas) - values(k) = strjoin(string(areas{k}.Value), newline); - end - assert(any(contains(values, needle)), message); +function value = component(figureHandle, tag) + value = findall(figureHandle, "Tag", char(tag)); + assert(isscalar(value), "Expected one component with Tag %s.", tag); end function removeTempFolder(folder) diff --git a/tests/cases/gui/apps/image_measurement/focus_stack/GuiLayoutFocusStackTest.m b/tests/cases/gui/apps/image_measurement/focus_stack/GuiLayoutFocusStackTest.m index 533e718c2..adfaa1a50 100644 --- a/tests/cases/gui/apps/image_measurement/focus_stack/GuiLayoutFocusStackTest.m +++ b/tests/cases/gui/apps/image_measurement/focus_stack/GuiLayoutFocusStackTest.m @@ -8,128 +8,111 @@ function focus_stack_workflow_loads_and_runs_synthetic_images(testCase) h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - folder = tempname; + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); [nearImage, farImage] = syntheticFocusPair(); nearPath = fullfile(folder, 'frame_near.png'); farPath = fullfile(folder, 'frame_far.png'); extraPath = fullfile(folder, 'frame_extra.png'); - folderStack = fullfile(folder, 'folder_stack'); - mkdir(folderStack); imwrite(uint8(255 .* nearImage), nearPath); imwrite(uint8(255 .* farImage), farPath); imwrite(uint8(255 .* flip(farImage, 2)), extraPath); - imwrite(uint8(255 .* nearImage), fullfile(folderStack, 'slice_1.png')); - imwrite(uint8(255 .* farImage), fullfile(folderStack, 'slice_2.png')); - - [fig, debug] = labkit_FocusStack_app("debug"); - drawnow; - assertFocusStackLayout(h, fig); - assert(debug.enabled && debug.traceEnabled, ... - 'Focus Stack debug launch should return an enabled trace logger.'); - assertAnyTextAreaContains(h, fig, 'Debug sample generation enabled', ... - 'The Runtime should mirror debug startup 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.'); - h.invokeDropdownValue(fig, 'Balanced'); - - driver = labkitWorkflowDriver(fig); - driver.chooseFiles('sourceImages', [string(nearPath); string(farPath)]); - - driver.click('Add images or folder'); - assert(driver.enabled('runFocusStack'), ... - 'Focus stack run action should enable after two source images load.'); - assert(contains(driver.fileStatus('sourceImages'), '2'), ... - 'Focus stack source file status should report the loaded image count.'); - assert(numel(driver.fileListItems('sourceImages')) == 2, ... - 'Focus stack source list should show both synthetic source images.'); - assert(any(contains(string(driver.textAreaValue('details')), 'Loaded images: 2')), ... - 'Focus stack details panel should describe the loaded source stack.'); - - driver.click('Run focus stack'); - assert(driver.enabled('exportFused'), ... - 'Fused PNG export should enable after a successful workflow run.'); - assert(driver.enabled('exportFocusMap'), ... - 'Focus-map PNG export should enable after a successful workflow run.'); - assert(driver.enabled('exportSummary'), ... - 'Summary CSV export should enable after a successful workflow run.'); - data = driver.tableData('resultTable'); - assert(any(strcmp(string(data(:, 1)), 'Input images')), ... - 'Focus stack result table should include the input image count metric.'); - assert(any(contains(string(driver.textAreaValue('details')), 'Selected pixel coverage by source')), ... - 'Focus stack details panel should describe the completed fusion result.'); - - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'Focus Stack should run on the Runtime V2 state contract.'); - testCase.verifyEqual(numel(runtime.state.project.inputs.sources), 2); - testCase.verifyFalse(isfield(runtime.state.project, 'images'), ... - 'Decoded focus images must not be persisted in the project.'); - testCase.verifyFalse(isfield(runtime.state.project.results.lastRun, 'fused'), ... - 'Durable Focus Stack results must exclude the fused image matrix.'); - testCase.verifyNotEmpty(runtime.state.session.cache.result.fused, ... - 'The fused image should remain an ephemeral session cache.'); fusedPath = fullfile(folder, 'focus_stack_fused.png'); - runtime.request.outputFileChooser = @(~, ~, ~) deal( ... - 'focus_stack_fused.png', folder); - setappdata(fig, 'labkitUiAppRuntime', runtime); - driver.click('Export fused PNG'); + focusMapPath = fullfile(folder, 'focus_stack_map.png'); + summaryPath = fullfile(folder, 'focus_stack_summary.csv'); + manifestPath = fullfile(folder, 'focus_stack.labkit.json'); + backend = struct( ... + "chooseInputFolder", @(~) ... + labkit.app.dialog.Choice(folder), ... + "chooseOutputFile", @(~, startPath) ... + labkit.app.dialog.Choice(startPath), ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + focus_stack.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); + assertFocusStackLayout(h, fig); + runtime.invokeAction("sourceFolderChosen"); + testCase.verifyEqual(numel( ... + runtime.State.session.cache.images), 3); + runtime.applyFileSelection( ... + 'sourceImages', [nearPath farPath], [1 2]); + testCase.verifyEqual(numel( ... + runtime.State.session.cache.images), 2); + runtime.applyControlValue("fusionPreset", "Crisp"); + testCase.verifyEqual( ... + runtime.State.project.parameters.fusionPreset, "Crisp"); + + runtime.invokeAction('runFocusStack'); + testCase.verifyTrue(runtime.State.session.cache.result.ok); + testCase.verifyNotEmpty( ... + runtime.State.session.cache.result.fused); + testCase.verifyFalse(isfield( ... + runtime.State.project.results.lastRun, 'fused')); + fusedAxes = findall(fig, 'Tag', 'preview.fused'); + mapAxes = findall(fig, 'Tag', 'preview.focusMap'); + testCase.verifyNotEmpty(fusedAxes.Children); + testCase.verifyNotEmpty(mapAxes.Children); + + runtime.invokeAction('exportFused'); testCase.verifyTrue(isfile(fusedPath)); - testCase.verifyTrue(isfile(fullfile(folder, ... - 'focus_stack.labkit.json')), ... - sprintf('Focus Stack export should add a standard result manifest. Log: %s', ... - strjoin(string(driver.textAreaValue('logPanel')), ' | '))); - - driver.chooseFiles('sourceImages', extraPath); - driver.click('Add images or folder'); - assert(contains(driver.fileStatus('sourceImages'), '3'), ... - 'Focus stack append should preserve the existing source stack.'); - assert(numel(driver.fileListItems('sourceImages')) == 3, ... - 'Focus stack append should keep prior source images in the file list.'); - - driver.click('Run focus stack'); + testCase.verifyTrue(isfile(manifestPath)); + runtime.invokeAction('exportFocusMap'); + testCase.verifyTrue(isfile(focusMapPath)); + runtime.invokeAction('exportSummary'); + testCase.verifyTrue(isfile(summaryPath)); + testCase.verifyEqual( ... + runtime.State.project.results.lastExport.kind, "summary"); + testCase.verifyEqual( ... + runtime.State.project.results.resultManifestPath, ... + string(manifestPath)); + + runtime.applyFileSelection( ... + 'sourceImages', [nearPath farPath extraPath], [1 2 3]); + testCase.verifyFalse( ... + runtime.State.project.results.lastRun.ok); + runtime.invokeAction('runFocusStack'); projectPath = fullfile(folder, 'focus-stack-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 1); testCase.verifyFalse(isfield(saved.labkitProject.payload, 'session'), ... 'Focus Stack projects must exclude decoded and result caches.'); testCase.verifyTrue(saved.labkitProject.payload.results.lastRun.ok, ... 'Focus Stack projects should preserve compact run results.'); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(numel(runtime.state.session.cache.images), 3, ... + runtime.applyFileSelection( ... + 'sourceImages', strings(1, 0), zeros(1, 0)); + runtime.restoreProject(projectPath); + testCase.verifyEqual(numel(runtime.State.session.cache.images), 3, ... 'Project reopen should rebuild the source-image cache.'); - testCase.verifyFalse(runtime.state.session.cache.result.ok, ... + testCase.verifyFalse(runtime.State.session.cache.result.ok, ... 'Project reopen should not persist full result matrices.'); - testCase.verifyTrue(any(contains(string(driver.textAreaValue('details')), ... - 'Saved summary restored')), ... - 'Project reopen should present the durable summary and rerun requirement.'); - - runtime.request.inputFolderChooser = @(~, ~) folderStack; - setappdata(fig, 'labkitUiAppRuntime', runtime); - driver.click('Choose folder'); - testCase.verifyEqual(numel(driver.fileListItems('sourceImages')), 2, ... - 'The V2 input-folder service should register the selected image folder.'); + clear runtimeCleanup end end end function assertFocusStackLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Add images or folder', ... - 'Remove selected', 'Clear images', 'Choose folder', ... - '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( ... - cellstr(focus_stack.userInterface.fusionPresetItems()), 1)); - h.assertTabTitles(fig, {'Files + Analysis', 'Summary + Results', 'Log'}); + h.assertStartupSucceeded(fig); + ids = ["sourceLocation", "sourceImages", "sourceFolderChosen", ... + "fusionPreset", "autoRegister", ... + "focusWindow", "smoothRadius", "uncertainBlend", ... + "runFocusStack", "exportFused", "exportFocusMap", ... + "exportSummary", "resultTable", "details", ... + "preview.fused", "preview.focusMap"]; + for id = ids + assert(numel(findall(fig, "Tag", id)) == 1, ... + "Missing Focus Stack semantic target: %s.", id); + end + tabs = findall(fig, "Type", "uitab"); + assert(isequal(sort(string({tabs.Title})), ... + sort(["Files + Analysis", "Summary + Results", "Log"]))); + assert(numel(findall(fig, "Title", "Focus Stack Preview")) >= 2); + assert(~isempty(findall(fig, "Title", "Workflow Notes"))); + resultTable = findall(fig, "Tag", "resultTable"); + assert(size(resultTable.Data, 1) == 7); end function [nearImage, farImage] = syntheticFocusPair() diff --git a/tests/cases/gui/apps/image_measurement/image_enhance/GuiLayoutImageEnhanceTest.m b/tests/cases/gui/apps/image_measurement/image_enhance/GuiLayoutImageEnhanceTest.m index 0bd707f7f..8a51ba5bf 100644 --- a/tests/cases/gui/apps/image_measurement/image_enhance/GuiLayoutImageEnhanceTest.m +++ b/tests/cases/gui/apps/image_measurement/image_enhance/GuiLayoutImageEnhanceTest.m @@ -8,7 +8,7 @@ function image_enhance_workflow_applies_tool_and_exports(testCase) h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - folder = tempname; + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); sourcePath = fullfile(folder, 'paper.png'); @@ -16,74 +16,73 @@ function image_enhance_workflow_applies_tool_and_exports(testCase) imwrite(syntheticPaperImage(), sourcePath); imwrite(rot90(syntheticPaperImage()), secondSourcePath); - [fig, debug] = labkit_ImageEnhance_app("debug"); - drawnow; + outputFolder = fullfile(folder, 'image_enhance'); + backend = struct( ... + "chooseOutputFolder", @(~) ... + labkit.app.dialog.Choice(outputFolder), ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + image_enhance.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); assertImageEnhanceLayout(h, fig); - assert(debug.enabled && debug.traceEnabled, ... - 'Image enhance debug launch should return an enabled trace logger.'); - assertAnyTextAreaContains(h, fig, 'Debug sample generation enabled', ... - 'Image enhance debug launch should mirror trace lines into the visible Log tab.'); - driver = labkitWorkflowDriver(fig); - testCase.verifyTrue(isfile(debug.manifestFile), ... - 'Image enhance debug launch should record a sample manifest.'); - testCase.verifyEqual(char(driver.fileStatus('sourceImages')), 'No images loaded', ... - 'Image enhance debug launch should not preload generated samples.'); - driver.chooseFiles('sourceImages', sourcePath); - - driver.click('Add images or folder'); - testCase.verifyTrue(driver.enabled('applyTool'), ... - 'Image enhance apply action should enable after image load.'); - testCase.verifyTrue(driver.enabled('exportImages'), ... - 'Image enhance export action should enable after image load.'); - testCase.verifyTrue(contains(driver.fileStatus('sourceImages'), '1'), ... - 'Image enhance file status should report the loaded image count.'); - - driver.checkbox('Batch shared processing', false); - driver.click('Apply tool'); - history = driver.tableData('historyTable'); - testCase.verifyEqual(size(history, 1), 1, ... - 'Image enhance workflow should add one history step.'); - testCase.verifyTrue(contains(string(history{1, 2}), 'Brightness'), ... - 'Image enhance default tool should be recorded in history.'); + runtime.applyFileSelection('sourceImages', sourcePath, 1); + runtime.applyControlValue('batchMode', false); + runtime.invokeAction('applyTool'); + testCase.verifyEqual(numel( ... + runtime.State.project.annotations.items), 1); + testCase.verifyEqual(numel( ... + runtime.State.project.annotations.items(1).steps), 1); - driver.click('Export enhanced images'); - outputFolder = fullfile(folder, 'image_enhance'); + runtime.invokeAction('chooseOutputFolder'); + runtime.invokeAction('exportImages'); manifestFiles = dir(fullfile(outputFolder, '*manifest*.csv')); outputFiles = dir(fullfile(outputFolder, '*_enhanced.png')); testCase.verifyFalse(isempty(manifestFiles), ... 'Image enhance workflow should write a manifest CSV.'); testCase.verifyFalse(isempty(outputFiles), ... 'Image enhance workflow should write an enhanced PNG.'); - testCase.verifyTrue(isfile(fullfile(outputFolder, ... - 'image_enhance.labkit.json')), ... - 'Image enhance export should add a standard result manifest.'); - testCase.verifyTrue(any(contains(string(driver.textAreaValue('exportDetails')), ... - 'Last manifest')), ... - 'Image enhance details should show the last manifest after export.'); - - driver.chooseFiles('sourceImages', secondSourcePath); - driver.click('Add images or folder'); - testCase.verifyTrue(contains(driver.fileStatus('sourceImages'), '2'), ... - 'Image enhance append should preserve the existing source image.'); - testCase.verifyTrue(contains(driver.fileSelection('sourceImages'), ... - 'paper_second.png'), ... - 'Image enhance append should select the newly added source image.'); - driver.dropdown('Sharpen'); - driver.click('Apply tool'); - secondHistory = driver.tableData('historyTable'); - testCase.verifyTrue(contains(string(secondHistory{1, 2}), 'Sharpen'), ... - 'The second image should own its independently applied tool.'); - driver.selectFile('sourceImages', 'paper.png'); - firstHistory = driver.tableData('historyTable'); - testCase.verifyTrue(contains(string(firstHistory{1, 2}), 'Brightness'), ... - 'Selecting the first image should restore its real persisted history.'); - driver.selectFile('sourceImages', 'paper_second.png'); - secondHistory = driver.tableData('historyTable'); - testCase.verifyTrue(contains(string(secondHistory{1, 2}), 'Sharpen'), ... - 'Selecting the second image should restore its real persisted history.'); + testCase.verifyTrue(isfile(fullfile( ... + outputFolder, 'image_enhance.labkit.json'))); + runtime.applyControlValue('preview', 'Original'); + testCase.verifyEqual( ... + runtime.State.session.view.previewMode, "Original"); + runtime.applyControlValue('toolKind', 'White ROI calibration'); + runtime.invokeAction('setWhiteRoi'); + testCase.verifyTrue(runtime.State.session.view.roiEditing); + runtime.applyInteraction( ... + 'whiteRoi', 'interactionChanged', [5 5 14 12]); + runtime.invokeAction('applyTool'); + testCase.verifyFalse(runtime.State.session.view.roiEditing); + runtime.applyFileSelection( ... + 'sourceImages', [sourcePath secondSourcePath], 2); + runtime.applyControlValue('toolKind', 'Sharpen'); + runtime.invokeAction('applyTool'); + testCase.verifyEqual(numel( ... + runtime.State.project.annotations.items), 2); + second = image_enhance.sourceLibrary.annotationForSource( ... + runtime.State.project.annotations.items, ... + runtime.State.project.inputs.sources(2).id); + testCase.verifyTrue(contains( ... + string(second.steps(1).label), 'Sharpen')); + runtime.invokeAction('undoHistory'); + testCase.verifyEmpty(image_enhance.analysisRun.activeSteps( ... + runtime.State)); + runtime.invokeAction('applyTool'); + runtime.applyFilePanelSelection('sourceImages', 1); + first = image_enhance.sourceLibrary.annotationForSource( ... + runtime.State.project.annotations.items, ... + runtime.State.project.inputs.sources(1).id); + testCase.verifyTrue(contains( ... + string(first.steps(1).label), 'Brightness')); + runtime.invokeAction('resetHistory'); + testCase.verifyEmpty(image_enhance.analysisRun.activeSteps( ... + runtime.State)); + runtime.applyControlValue('toolKind', 'Brightness/contrast'); + runtime.invokeAction('applyTool'); projectPath = fullfile(folder, 'image-enhance-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 1); testCase.verifyFalse(isfield(saved.labkitProject.payload, 'session'), ... @@ -92,33 +91,34 @@ function image_enhance_workflow_applies_tool_and_exports(testCase) saved.labkitProject.payload.annotations.items, ... {'image', 'previewImage', 'whiteRoiHandle'})), ... 'Image Enhance projects must exclude pixels and UI resources.'); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyNotEmpty(runtime.state.session.cache.item.image, ... + runtime.restoreProject(projectPath); + testCase.verifyNotEmpty(runtime.State.session.cache.item.image, ... 'Project reopen should lazily rebuild the selected image cache.'); - testCase.verifyEqual(numel(runtime.state.project.inputs.sources), 2); - reopenedHistory = driver.tableData('historyTable'); - testCase.verifyTrue(contains(string(reopenedHistory{1, 2}), 'Brightness'), ... - 'Project reopen should rebuild the first selection and its history.'); + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.sources), 2); + testCase.verifyEqual(numel( ... + runtime.State.project.annotations.items), 2); + clear runtimeCleanup end end end function assertImageEnhanceLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Add images or folder', ... - 'Remove selected', 'Clear images', ... - 'Set white ROI', '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', ... - 'White ROI calibration', 'Subject-preserving enhance'}, 1), ... - h.dropdownGroup({'PNG', 'TIFF', 'JPEG'}, 1)]); - h.assertCheckboxContract(fig, {'Batch shared processing'}); - h.assertTabTitles(fig, {'Library + Export', 'Tools + History', 'Log'}); + h.assertStartupSucceeded(fig); + ids = ["sourceImages", "imageStatus", "outputFolder", ... + "exportFormat", "chooseOutputFolder", "exportImages", ... + "exportDetails", "batchMode", "batchModeStatus", "toolKind", ... + "toolAmount", "toolSecondary", "toolStatus", "setWhiteRoi", ... + "applyTool", "undoHistory", "resetHistory", "historyTable", ... + "historyStatus", "metricsTable", "preview", "preview.image"]; + for id = ids + assert(numel(findall(fig, "Tag", id)) == 1, ... + "Missing Image Enhance semantic target: %s.", id); + end + tabs = findall(fig, "Type", "uitab"); + assert(isequal(sort(string({tabs.Title})), ... + sort(["Library + Export", "Tools + History", "Log"]))); + assert(numel(findall(fig, "Title", "Preview")) >= 2); end function img = syntheticPaperImage() diff --git a/tests/cases/gui/apps/image_measurement/image_match/GuiLayoutImageMatchTest.m b/tests/cases/gui/apps/image_measurement/image_match/GuiLayoutImageMatchTest.m index 9c9e7ecd9..243007271 100644 --- a/tests/cases/gui/apps/image_measurement/image_match/GuiLayoutImageMatchTest.m +++ b/tests/cases/gui/apps/image_measurement/image_match/GuiLayoutImageMatchTest.m @@ -8,7 +8,7 @@ function image_match_workflow_applies_reference_and_exports(testCase) h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - folder = tempname; + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); referencePath = fullfile(folder, 'reference.png'); @@ -18,88 +18,99 @@ function image_match_workflow_applies_reference_and_exports(testCase) imwrite(syntheticSourceImage(), sourcePath); imwrite(rot90(syntheticSourceImage()), secondSourcePath); - fig = h.launchFigure('labkit_ImageMatch_app', 'Paper Image Match'); + outputFolder = fullfile(folder, 'image_match'); + mkdir(outputFolder); + backend = struct( ... + "chooseOutputFolder", @(~) ... + labkit.app.dialog.Choice(outputFolder), ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + image_match.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); assertImageMatchLayout(h, fig); - driver = labkitWorkflowDriver(fig); - driver.chooseFiles('referenceImage', referencePath); - driver.chooseFiles('sourceImages', sourcePath); - - driver.click('Choose reference'); - driver.click('Add images or folder'); - testCase.verifyTrue(driver.enabled('applyMatch'), ... - 'Image match apply action should enable after source and reference images load.'); - testCase.verifyTrue(driver.enabled('exportImages'), ... - 'Image match export action should enable after source and reference images load.'); - testCase.verifyTrue(contains(driver.fileStatus('sourceImages'), '1'), ... - 'Image match source file status should report the loaded image count.'); - testCase.verifyTrue(any(contains(driver.fileListItems('sourceImages'), 'source.png')), ... - 'Image match file list should show the synthetic source image.'); + runtime.applyFileSelection('referenceImage', referencePath, 1); + runtime.applyFileSelection('sourceImages', sourcePath, 1); + testCase.verifyNotEmpty( ... + runtime.State.session.cache.referenceItem.image); + testCase.verifyNotEmpty( ... + runtime.State.session.cache.currentItem.image); - driver.click('Apply match'); - history = driver.tableData('historyTable'); - testCase.verifyEqual(size(history, 1), 1, ... - 'Image match workflow should add one history step.'); - testCase.verifyEqual(string(history{1, 2}), "Reference match", ... - 'Image match history should record a reference-match step.'); - testCase.verifyTrue(contains(string(history{1, 3}), 'Balanced reference'), ... - 'Image match default method should be recorded in history details.'); - testCase.verifyGreaterThan(driver.previewChildCount('preview'), 0, ... - 'Image match preview should render the matched image.'); + runtime.applyControlValue('matchStrength', 85); + testCase.verifyTrue( ... + runtime.State.session.workflow.pendingDirty); + runtime.invokeAction('applyMatch'); + steps = runtime.State.project.annotations.steps; + testCase.verifyEqual(numel(steps), 1); + testCase.verifyTrue(contains( ... + string(steps(1).label), "Balanced reference")); + previewAxes = findall(fig, 'Tag', 'preview.image'); + testCase.verifyNotEmpty(previewAxes.Children); - driver.click('Export matched images'); - outputFolder = fullfile(folder, 'image_match'); - manifestFiles = dir(fullfile(outputFolder, '*manifest*.csv')); - outputFiles = dir(fullfile(outputFolder, '*_matched.png')); - testCase.verifyFalse(isempty(manifestFiles), ... - 'Image match workflow should write a manifest CSV.'); - testCase.verifyFalse(isempty(outputFiles), ... + runtime.applyControlValue('preview', 'Original'); + testCase.verifyEqual( ... + runtime.State.session.view.previewMode, "Original"); + runtime.invokeAction('chooseOutputFolder'); + runtime.invokeAction('exportImages'); + testCase.verifyTrue(isfile(fullfile( ... + outputFolder, 'source_matched.png')), ... 'Image match workflow should write a matched PNG.'); - testCase.verifyTrue(isfile(fullfile(outputFolder, ... - 'image_match.labkit.json')), ... - 'Image match export should add a standard result manifest.'); - testCase.verifyTrue(any(contains(string(driver.textAreaValue('exportDetails')), ... - 'Last manifest')), ... - 'Image match details should show the last manifest after export.'); - - driver.chooseFiles('sourceImages', secondSourcePath); - driver.click('Add images or folder'); - testCase.verifyTrue(contains(driver.fileStatus('sourceImages'), '2'), ... - 'Image match append should preserve the existing source image.'); - testCase.verifyTrue(contains(driver.fileSelection('sourceImages'), ... - 'source_second.png'), ... - 'Image match append should select the newly added source image.'); + testCase.verifyFalse(isempty(dir(fullfile( ... + outputFolder, 'image_match_manifest*.csv')))); + testCase.verifyTrue(isfile(fullfile( ... + outputFolder, 'image_match.labkit.json'))); + runtime.invokeAction('undoHistory'); + testCase.verifyEmpty( ... + runtime.State.project.annotations.steps); + runtime.invokeAction('applyMatch'); + runtime.invokeAction('resetHistory'); + testCase.verifyEmpty( ... + runtime.State.project.annotations.steps); + runtime.invokeAction('applyMatch'); + runtime.applyFileSelection( ... + 'sourceImages', [sourcePath secondSourcePath], [1 2]); + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.sources), 2); + runtime.applyFilePanelSelection('sourceImages', 2); + testCase.verifyEqual( ... + runtime.State.session.cache.currentItem.name, ... + "source_second.png"); projectPath = fullfile(folder, 'image-match-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 1); testCase.verifyFalse(isfield(saved.labkitProject.payload, 'session'), ... 'Image Match projects must exclude rebuildable caches.'); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyNotEmpty(runtime.state.session.cache.currentItem.image); - testCase.verifyNotEmpty(runtime.state.session.cache.referenceItem.image); - reopenedHistory = driver.tableData('historyTable'); - testCase.verifyEqual(size(reopenedHistory, 1), 1, ... - 'Project reopen should preserve durable match history.'); + runtime.applyFileSelection( ... + 'sourceImages', strings(1, 0), zeros(1, 0)); + runtime.restoreProject(projectPath); + testCase.verifyNotEmpty(runtime.State.session.cache.currentItem.image); + testCase.verifyNotEmpty(runtime.State.session.cache.referenceItem.image); + testCase.verifyEqual(numel( ... + runtime.State.project.annotations.steps), 1); + clear runtimeCleanup end end end function assertImageMatchLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Choose reference', ... - 'Add images or folder', 'Remove selected', ... - 'Clear images', 'Apply match', ... - 'Undo history', 'Reset history', ... - 'Choose folder', 'Export matched images'}); - h.assertDropdownGroups(fig, [ ... - h.dropdownGroup({'Matched', 'Original', 'Before | After'}, 1), ... - h.dropdownGroup({'Balanced', 'White balance', 'Tone only', ... - 'Protected tone', 'Lab style', 'Histogram'}, 1), ... - h.dropdownGroup({'PNG', 'TIFF', 'JPEG'}, 1)]); - h.assertTabTitles(fig, {'Library + Export', 'Match + History', 'Log'}); + h.assertStartupSucceeded(fig); + ids = ["referenceImage", "sourceImages", "imageStatus", ... + "outputFolder", "exportFormat", "chooseOutputFolder", ... + "exportImages", "exportDetails", "matchMethod", ... + "matchStrength", "toneStrength", "colorStrength", ... + "applyMatch", "matchFlow", "undoHistory", ... + "resetHistory", "historyTable", "historyStatus", ... + "metricsTable", "preview", "preview.image"]; + for id = ids + assert(numel(findall(fig, "Tag", id)) == 1, ... + "Missing Image Match semantic target: %s.", id); + end + tabs = findall(fig, "Type", "uitab"); + assert(isequal(sort(string({tabs.Title})), ... + sort(["Library + Export", "Match + History", "Log"]))); + assert(numel(findall(fig, "Title", "Preview")) >= 2); end function img = syntheticReferenceImage() diff --git a/tests/cases/gui/apps/image_measurement/video_marker/GuiLayoutVideoMarkerTest.m b/tests/cases/gui/apps/image_measurement/video_marker/GuiLayoutVideoMarkerTest.m index 70fc6eba4..398e6dfac 100644 --- a/tests/cases/gui/apps/image_measurement/video_marker/GuiLayoutVideoMarkerTest.m +++ b/tests/cases/gui/apps/image_measurement/video_marker/GuiLayoutVideoMarkerTest.m @@ -1,344 +1,207 @@ classdef GuiLayoutVideoMarkerTest < matlab.unittest.TestCase - %GUILAYOUTVIDEOMARKERTEST Verify Video Marker GUI launch and layout contract. - - methods (Test, TestTags = {'GUI', 'Structural'}) - function video_marker_launches_with_expected_controls(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - - [fig, debug] = labkit_VideoMarker_app("debug"); - drawnow; - - h.assertStandardWorkbenchLayout(fig); - sessionChoices = video_marker.userInterface.sessionChoices(); - h.assertButtonContract(fig, {'Open video', 'Previous frame', ... - 'Next frame', 'Undo last point', 'Clear frame points', ... - 'Add keypoint', 'Remove keypoint', 'Move up', 'Move down', ... - 'Use preset', 'Add connection', 'Connect in order', ... - 'Remove connection', char(sessionChoices.openProject), ... - char(sessionChoices.saveAutosave), ... - char(sessionChoices.newSetup), ... - 'Measure reference pixels', ... - 'Place scale bar', 'Import marker CSV', 'Export marker CSV', ... - 'Export coordinate CSV'}); - h.assertDropdownGroups(fig, [ ... - h.dropdownGroup({'Legacy leg (5 points)', 'Three-point chain', ... - 'Five-point chain'}, 1), ... - h.dropdownGroup({'pixels', 'calibrated_physical'}, 1), ... - h.dropdownGroup({'top_left_pixel_center', 'first_point'}, 1), ... - h.dropdownGroup({'up', 'down'}, 1), ... - h.dropdownGroup({'m', 'cm', 'mm', 'um', 'nm'}, 1), ... - h.dropdownGroup({'Bottom center', 'Bottom left', 'Bottom right', ... - 'Top center', 'Top left', 'Top right'}, 1), ... - h.dropdownGroup({'Black', 'White'}, 1)]); - h.assertTabTitles(fig, {'Setup + Scale', 'Video', 'Import + Export', 'Log'}); - testCase.verifyEmpty(findall(fig, 'Type', 'uibutton', 'Text', 'Start point edit')); - testCase.verifyEmpty(findall(fig, 'Type', 'uibutton', 'Text', 'Confirm frame')); - testCase.verifyEmpty(findall(fig, 'Type', 'uibutton', 'Text', 'Interpolate frame')); - testCase.verifyEmpty(findall(fig, 'Type', 'uibutton', 'Text', 'Track from previous')); - testCase.verifyTrue(debug.enabled && debug.traceEnabled); - assertAnyTextAreaContains(h, fig, 'Debug sample generation enabled', ... - 'Debug trace should be mirrored into the visible Log tab.'); - end - - - function skeleton_setup_and_frame_change_use_continuous_marking(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - [fig, debug] = labkit_VideoMarker_app("debug"); - drawnow; - h.assertStandardWorkbenchLayout(fig); - - ui = getappdata(fig, 'labkitUiRegistry'); - invoke(ui.controls.useSkeletonPreset.button); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.state.project.annotations.skeleton.pointNames, ... - ["iliac"; "hip"; "knee"; "ankle"; "foot"]); - setChoiceAnswer(fig, ... - video_marker.userInterface.sessionChoices().discardAndStart); - invoke(ui.controls.newSetup.button); - ui = getappdata(fig, 'labkitUiRegistry'); - invoke(ui.controls.addKeypoint.button); - invoke(ui.controls.addKeypoint.button); - ui = getappdata(fig, 'labkitUiRegistry'); - editName(ui.controls.keypointTable.table, 1, 'hip'); - editName(ui.controls.keypointTable.table, 2, 'knee'); - ui = getappdata(fig, 'labkitUiRegistry'); - testui.control.setValue(ui, 'connectionFrom', 'hip'); - ui.controls.connectionFrom.valueHandle.ValueChangedFcn( ... - ui.controls.connectionFrom.valueHandle, struct()); - ui = getappdata(fig, 'labkitUiRegistry'); - testCase.verifyFalse(any(string(ui.controls.connectionTo.valueHandle.Items) == "hip")); - testui.control.setValue(ui, 'connectionTo', 'knee'); - invoke(ui.controls.connectInOrder.button); - - pack = video_marker.debug.writeSamplePack(debug); - ui = getappdata(fig, 'labkitUiRegistry'); - ui.controls.videoFile.choosePaths = @(varargin) cellstr(pack.representativeFiles); - setappdata(fig, 'labkitUiRegistry', ui); - invoke(ui.controls.videoFile.chooseButton); - drawnow; - - ui = getappdata(fig, 'labkitUiRegistry'); - testCase.verifyEqual(string(ui.controls.saveAutosave.button.Enable), ... - "on"); - invoke(ui.controls.saveAutosave.button); - expectedAutosave = video_marker.autosave.filePath( ... - pack.representativeFiles(1)); - testCase.verifyTrue(isfile(expectedAutosave), ... - 'Save autosave should use the visible source-adjacent path.'); - savedAutosave = load(expectedAutosave, 'labkitProject'); - savedReference = ... - savedAutosave.labkitProject.payload.inputs.sources.reference; - testCase.verifyEqual(savedReference.relativePath, ... - "../" + string(savedReference.fileName), ... - ['Explicit autosave must rebase its source reference from ' ... - 'the actual source-adjacent autosave destination.']); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(string(runtime.document.path), "", ... - 'An autosave must not become the named project file.'); - registered = getappdata(ui.controls.videoAxes.primaryAxes, ... - 'labkit_ui_activeAnchorEditor'); - ax = ui.controls.videoAxes.primaryAxes; - testCase.verifyTrue(contains(string(ax.Subtitle.String), ... - 'Click blank image space to add points'), ... - 'Video Marker should show its point-mode gestures on the preview.'); - xlim(ax, [10 70]); - ylim(ax, [10 60]); - scrollCallback = fig.WindowScrollWheelFcn; - registered.editor.insertPoint([20 30]); - testCase.verifyEqual(xlim(ax), [10 70], 'AbsTol', 1e-12); - testCase.verifyEqual(ylim(ax), [10 60], 'AbsTol', 1e-12); - testCase.verifyFalse(isempty(fig.WindowScrollWheelFcn)); - testCase.verifyEqual(fig.WindowScrollWheelFcn, scrollCallback); - stillRegistered = getappdata(ax, 'labkit_ui_activeAnchorEditor'); - testCase.verifyEqual(stillRegistered.token, registered.token); - registered.editor.insertPoint([40 50]); - invoke(ui.controls.nextFrame.button); - testCase.verifyEqual(xlim(ax), [10 70], 'AbsTol', 1e-12); - testCase.verifyEqual(ylim(ax), [10 60], 'AbsTol', 1e-12); - - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.state.session.selection.currentFrame, 2); - testCase.verifyEqual( ... - runtime.state.project.annotations.skeleton.pointNames, ... - ["hip"; "knee"]); - testCase.verifyEqual( ... - runtime.state.project.annotations.skeleton.edges, [1 2]); - predicted = video_marker.frameAnnotations.framePoints( ... - runtime.state.project.annotations.frames, 2); - testCase.verifySize(predicted, [2 2]); - testCase.verifyTrue(all(isfinite(predicted), 'all')); - testCase.verifyEqual(video_marker.frameAnnotations.statusName( ... - runtime.state.project.annotations.frames.frameStatus(1)), "confirmed"); - testCase.verifyEqual(video_marker.frameAnnotations.statusName( ... - runtime.state.project.annotations.frames.frameStatus(2)), "draft"); - testCase.verifyEqual(video_marker.frameAnnotations.sourceName( ... - runtime.state.project.annotations.frames.frameSource(1)), "manual"); - testCase.verifyEqual(video_marker.frameAnnotations.sourceName( ... - runtime.state.project.annotations.frames.frameSource(2)), "predicted"); - - predictedRevision = ... - runtime.state.project.annotations.frames.anchorRevision(2); - invoke(ui.controls.previousFrame.button); - invoke(ui.controls.nextFrame.button); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(video_marker.frameAnnotations.framePoints( ... - runtime.state.project.annotations.frames, 2), ... - predicted, 'AbsTol', 1e-12); - testCase.verifyEqual( ... - runtime.state.project.annotations.frames.anchorRevision(2), ... - predictedRevision); - end - - - function session_actions_confirm_save_discard_and_open_mat(testCase) + % Verify Video Marker through the explicit App SDK runtime. + methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) + function nativeLayoutUsesSemanticTargets(testCase) setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - fig = labkit_VideoMarker_app(); - drawnow; - ui = getappdata(fig, 'labkitUiRegistry'); - choices = video_marker.userInterface.sessionChoices(); - invoke(ui.controls.useSkeletonPreset.button); - - setChoiceAnswer(fig, choices.cancel); - invoke(ui.controls.newSetup.button); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(numel( ... - runtime.state.project.annotations.skeleton.pointIds), 5, ... - 'Cancel should preserve the current Video Marker project.'); - - folder = string(tempname); - mkdir(folder); - folderCleanup = onCleanup(@() removeTempFolder(folder)); - projectPath = fullfile(folder, "saved-project.mat"); - labkit.ui.runtime.saveState(fig, projectPath); - setChoiceAnswer(fig, choices.discardAndStart); - invoke(ui.controls.newSetup.button); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEmpty( ... - runtime.state.project.annotations.skeleton.pointIds, ... - 'Discard and start new should clear the project.'); - - setappdata(fig, 'labkitUiUtilityStateFile', projectPath); - ui = getappdata(fig, 'labkitUiRegistry'); - invoke(ui.controls.openProject.button); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(numel( ... - runtime.state.project.annotations.skeleton.pointIds), 5, ... - 'Open MAT should invoke the same framework load-state path.'); - - savedBeforeReset = fullfile(folder, "saved-before-reset.mat"); - setChoiceAnswer(fig, choices.saveAndStart); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.projectStateFile = savedBeforeReset; - setappdata(fig, 'labkitUiAppRuntime', runtime); - invoke(ui.controls.newSetup.button); - testCase.verifyTrue(isfile(savedBeforeReset), ... - 'Save and start new should persist the current project first.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEmpty( ... - runtime.state.project.annotations.skeleton.pointIds); - clear folderCleanup; - end - - - function framework_recovery_restores_annotations_and_current_frame(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - [fig, debug] = labkit_VideoMarker_app("debug"); - drawnow; - ui = getappdata(fig, 'labkitUiRegistry'); - invoke(ui.controls.useSkeletonPreset.button); - - pack = video_marker.debug.writeSamplePack(debug); - videoPath = pack.representativeFiles(1); - expected = [10 20; 20 25; 30 30; 40 35; 50 40]; - ui = getappdata(fig, 'labkitUiRegistry'); - ui.controls.videoFile.choosePaths = @(varargin) cellstr(videoPath); - setappdata(fig, 'labkitUiRegistry', ui); - invoke(ui.controls.videoFile.chooseButton); - ui = getappdata(fig, 'labkitUiRegistry'); - registered = getappdata(ui.controls.videoAxes.primaryAxes, ... - 'labkit_ui_activeAnchorEditor'); - for k = 1:size(expected, 1) - registered.editor.insertPoint(expected(k, :)); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + video_marker.definition()); + cleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); + + ids = ["skeletonPreset", "useSkeletonPreset", ... + "keypointTable", "connectionTable", "videoFile", ... + "openProject", "saveAutosave", "newSetup", ... + "currentFrame", "previousFrame", "nextFrame", ... + "undoPoint", "clearFramePoints", ... + "measureScaleReference", "placeScaleBar", ... + "importMarkerCsv", "exportMarkerCsv", ... + "exportCoordinateCsv", "videoPreview.video"]; + for id = ids + testCase.verifyEqual(numel(findall( ... + figure, "Tag", id)), 1); end - invoke(ui.controls.nextFrame.button); - projectPath = fullfile(string(tempname), "recovery.mat"); - mkdir(fileparts(projectPath)); - folderCleanup = onCleanup(@() removeTempFolder(fileparts(projectPath))); - labkit.ui.runtime.saveState(fig, projectPath); - delete(fig); - - recovered = labkit.ui.runtime.launch(@video_marker.definition, ... - "RequestAdapter", @(args) recoveryRequest( ... - args, debug, projectPath)); - runtime = getappdata(recovered, 'labkitUiAppRuntime'); - testCase.verifyEqual(video_marker.frameAnnotations.framePoints( ... - runtime.state.project.annotations.frames, 1), expected); - testCase.verifyEqual( ... - runtime.state.session.selection.currentFrame, 2); - testCase.verifyTrue(runtime.document.dirty, ... - 'Recovered documents should reopen as unsaved work.'); - clear folderCleanup + testCase.verifyEqual(string(one( ... + figure, "skeletonPresetActions").Title), ... + "Start from preset"); + testCase.verifyEqual(string(one( ... + figure, "connectionEndpoints").Title), "Add connection"); + testCase.verifyEqual(string(one( ... + figure, "keypointTable.panel").Title), ... + "Ordered keypoints"); + testCase.verifyEqual(string(one( ... + figure, "connectionTable.panel").Title), "Connections"); + testCase.verifyEqual(string(one( ... + figure, "summaryTable.panel").Title), ... + "Annotation Summary"); + testCase.verifyEqual(string(one( ... + figure, "labkitAppWorkspacePanel").Title), ... + "Video Preview"); + testCase.verifyEqual(string(one( ... + figure, "videoPreview").Title), "Video Preview"); + testCase.verifyEqual(string(one( ... + figure, "videoPreview.video").Title.String), ... + "Frame + Skeleton"); + testCase.verifyTrue(contains(string(class(one( ... + figure, "scaleReferencePixels"))), "Spinner")); + testCase.verifyEqual(numel(findall( ... + figure, "Tag", "scaleReferencePixels.slider")), 1); + testCase.verifyEqual(one( ... + figure, "scaleReferencePixels").Limits, [0 5000]); + testCase.verifyEqual(one( ... + figure, "scaleReferencePixels").Step, 1); + testCase.verifyEqual(one( ... + figure, "scaleReferenceLength").Limits, [0 1e6]); + testCase.verifyEqual(one( ... + figure, "scaleReferenceLength").Step, 10); + testCase.verifyEqual(one( ... + figure, "scaleBarLength").Limits, [0 1e6]); + testCase.verifyEqual(one( ... + figure, "scaleBarLength").Step, 10); + testCase.verifyEqual(string(one( ... + figure, "scaleCalibrationUnit").Items), ... + ["m" "cm" "mm" "um" "nm"]); + testCase.verifyEqual(string(one( ... + figure, "scaleBarPosition").Items), ... + ["Bottom center" "Bottom left" "Bottom right" ... + "Top center" "Top left" "Top right"]); + testCase.verifyEqual(string(one( ... + figure, "videoFile.status").Value), "No video loaded"); + testCase.verifyEqual(string(one( ... + figure, "coordinateStartFrame.label").Enable), "off"); + testCase.verifyEqual(string(one( ... + figure, "coordinateEndFrame.label").Enable), "off"); + testCase.verifyEqual(string(one(figure, "appLog").Value), ... + "Ready."); + testCase.verifyEqual(string(one( ... + figure, "applicationUsage").Value), [ ... + "1. Use an editable preset or add and name ordered keypoints, then add connections."; ... + "2. Open a video, click points in table order, and drag existing points to refine."; ... + "3. Moving forward predicts points automatically; dragging any point creates a new manual anchor."; ... + "4. Export marker CSV for round-trip editing or coordinate CSV for plotting."]); + clear cleanup end - function legacy_project_loads_read_only_and_saves_current_format(testCase) + function videoDrivesMarkingPredictionScaleAndExport(testCase) setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - [fig, debug] = labkit_VideoMarker_app("debug"); - pack = video_marker.debug.writeSamplePack(debug); - videoPath = pack.representativeFiles(1); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); - legacyPath = fullfile(folder, "legacy_video_marker.mat"); - - skeleton = video_marker.skeletonDefinition.fromParts( ... - ["iliac"; "hip"; "knee"; "ankle"; "foot"], ... - [1 2; 2 3; 3 4; 4 5]); - annotations = video_marker.frameAnnotations.emptyAnnotations(6, 5); - expected = [10 20; 20 25; 30 30; 40 35; 50 40]; - annotations = video_marker.frameAnnotations.setFramePoints( ... - annotations, 1, expected, "confirmed"); - [~, legacyName, legacyExtension] = fileparts(videoPath); - legacyReference = struct( ... - "schemaVersion", 1, "relativePath", "", ... - "originalPath", videoPath, ... - "fileName", string(legacyName) + string(legacyExtension)); - videoMarkerProject = struct( ... - "schemaVersion", 1, ... - "videoPath", videoPath, ... - "videoReference", legacyReference, ... - "skeleton", skeleton, ... - "annotations", annotations, ... - "calibration", ... - labkit.ui.interaction.scaleBarCalibration(20, 2, "mm"), ... - "exportPreferences", struct( ... - "unitMode", "calibrated_physical", ... - "originMode", "first_point", ... - "yAxisMode", "up", ... - "startFrame", 1, "endFrame", 6), ... - "currentFrame", 2); - save(legacyPath, 'videoMarkerProject'); - - labkit.ui.runtime.loadState(fig, legacyPath); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(video_marker.frameAnnotations.framePoints( ... - runtime.state.project.annotations.frames, 1), expected); + videoPath = fullfile(folder, "synthetic.avi"); + coordinatePath = fullfile(folder, "coordinates.csv"); + projectPath = fullfile(folder, "video-marker-project.mat"); + writeSyntheticVideo(videoPath); + backend = struct( ... + "chooseOutputFile", @(~, ~) ... + labkit.app.dialog.Choice(coordinatePath), ... + "chooseInputFile", @(~, ~) ... + labkit.app.dialog.Choice(projectPath), ... + "choose", @(~, choices, ~, ~, ~) ... + labkit.app.dialog.Choice(choices(3)), ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + video_marker.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + + runtime.invokeAction("useSkeletonPreset"); testCase.verifyEqual( ... - runtime.state.session.selection.currentFrame, 2); - testCase.verifyEqual(labkit.ui.runtime.sourcePaths( ... - runtime.state.project.inputs.sources, "video"), videoPath); - testCase.verifyEqual(string(who('-file', legacyPath)), ... - "videoMarkerProject"); - - currentPath = fullfile(folder, "current_video_marker.mat"); - labkit.ui.runtime.saveState(fig, currentPath); - testCase.verifyEqual(string(who('-file', currentPath)), ... - "labkitProject"); - clear folderCleanup + runtime.State.project.annotations.skeleton.pointNames, ... + ["iliac"; "hip"; "knee"; "ankle"; "foot"]); + runtime.applyFileSelection("videoFile", videoPath, 1); + testCase.verifyEqual(runtime.State.session.cache.videoInfo.frameCount, 6); + testCase.verifySize(runtime.State.project.annotations.frames.coords, ... + [6 5 2]); + videoResource = runtime.getResource("document", "video"); + testCase.verifyEqual(videoResource.path, string(videoPath)); + + points = [24 34; 32 38; 40 42; 48 46; 56 50]; + runtime.applyInteraction( ... + "framePoints", "interactionChanged", points); + testCase.verifyEqual(video_marker.frameAnnotations.statusName( ... + runtime.State.project.annotations.frames.frameStatus(1)), ... + "confirmed"); + runtime.invokeAction("nextFrame"); + testCase.verifyEqual(runtime.State.session.selection.currentFrame, 2); + predicted = video_marker.frameAnnotations.framePoints( ... + runtime.State.project.annotations.frames, 2); + testCase.verifySize(predicted, [5 2]); + testCase.verifyTrue(all(isfinite(predicted), "all")); + testCase.verifyEqual(video_marker.frameAnnotations.sourceName( ... + runtime.State.project.annotations.frames.frameSource(2)), ... + "predicted"); + nextResource = runtime.getResource("document", "video"); + testCase.verifyTrue(isequal( ... + videoResource.cache.readFrame, nextResource.cache.readFrame)); + + runtime.invokeAction("measureScaleReference"); + runtime.applyInteraction("scaleReference", ... + "interactionChanged", [10 10; 30 10]); + runtime.applyControlValue("scaleReferenceLength", 2); + runtime.applyControlValue("scaleCalibrationUnit", "mm"); + runtime.applyControlValue("scaleBarLength", 5); + runtime.invokeAction("placeScaleBar"); + testCase.verifyTrue( ... + runtime.State.project.annotations.calibration.isCalibrated); + testCase.verifyNotEmpty(runtime.State.session.view.scaleBar); + + runtime.applyControlValue("coordinateEndFrame", 1); + runtime.invokeAction("exportCoordinateCsv"); + testCase.verifyTrue(isfile(coordinatePath)); + testCase.verifyTrue(isfile(fullfile( ... + folder, "video_marker_coordinates.labkit.json"))); + testCase.verifyNotEmpty( ... + runtime.State.project.results.coordinateManifestPath); + runtime.invokeAction("saveAutosave"); + testCase.verifyTrue(isfile( ... + video_marker.autosave.filePath(videoPath))); + + runtime.saveProject(runtime.State, projectPath); + runtime.applyFileSelection( ... + "videoFile", strings(1, 0), zeros(1, 0)); + testCase.verifyEmpty(runtime.State.session.cache.currentImage); + runtime.invokeAction("openProject"); + testCase.verifyEqual( ... + runtime.State.session.selection.currentFrame, 2); + testCase.verifyEqual(video_marker.frameAnnotations.framePoints( ... + runtime.State.project.annotations.frames, 1), points); + runtime.invokeAction("newSetup"); + testCase.verifyEmpty( ... + runtime.State.project.annotations.skeleton.pointNames); + testCase.verifyEmpty(runtime.getResource("document", "video")); + metadata = runtime.documentMetadata(); + testCase.verifyEqual(metadata.path, ""); + testCase.verifyTrue(metadata.dirty); + clear videoResource nextResource + clear runtimeCleanup folderCleanup end end end -function [request, dispatchArgs] = recoveryRequest(~, debug, projectPath) - request = struct("debug", debug, "recoveryFile", projectPath, ... - "autosave", false); - dispatchArgs = {}; +function value = one(figure, tag) +value = findall(figure, "Tag", char(tag)); +assert(isscalar(value), "Expected one component with Tag %s.", tag); end -function invoke(button) - button.ButtonPushedFcn(button, struct()); +function writeSyntheticVideo(filepath) +writer = VideoWriter(char(filepath), "Motion JPEG AVI"); +writer.FrameRate = 10; +open(writer); +cleanup = onCleanup(@() close(writer)); +for k = 1:6 + [x, y] = meshgrid(1:96, 1:72); + frame = uint8(80 + 35 .* sin((x + 2 * k) ./ 7) + ... + 30 .* cos((y - k) ./ 6)); + frame = repmat(frame, 1, 1, 3); + writeVideo(writer, frame); end - -function setChoiceAnswer(fig, answer) - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.choiceDialog = @(varargin) answer; - setappdata(fig, 'labkitUiAppRuntime', runtime); -end - -function editName(tableHandle, row, value) - previous = tableHandle.Data{row, 2}; - tableHandle.Data{row, 2} = value; - tableHandle.CellEditCallback(tableHandle, struct( ... - 'Indices', [row 2], 'PreviousData', previous, ... - 'NewData', value, 'EditData', value)); +clear cleanup end function removeTempFolder(folder) - if exist(folder, 'dir') == 7 - rmdir(folder, 's'); - end +if exist(folder, "dir") == 7 + rmdir(folder, "s"); +end end diff --git a/tests/cases/gui/apps/labkit_core/figure_studio/GuiLayoutFigureStudioTest.m b/tests/cases/gui/apps/labkit_core/figure_studio/GuiLayoutFigureStudioTest.m index c6de08d21..fe5402af8 100644 --- a/tests/cases/gui/apps/labkit_core/figure_studio/GuiLayoutFigureStudioTest.m +++ b/tests/cases/gui/apps/labkit_core/figure_studio/GuiLayoutFigureStudioTest.m @@ -8,100 +8,91 @@ function figure_studio_launches_with_style_library(testCase) h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - fig = labkit_FigureStudio_app(); - assert(~isempty(fig) && isvalid(fig), ... - 'Figure Studio should launch as a LabKit app.'); - driver = labkitWorkflowDriver(fig); - ui = driver.registry(); - assert(isfield(ui.controls, 'stylePreset') && ... - isfield(ui.controls, 'figFiles') && ... - isfield(ui.controls, 'preview'), ... - 'Figure Studio should expose style mode, FIG files, and preview axes.'); - assert(isequal(string(ui.controls.stylePreset.valueHandle.Items), ... - ["LabKit figure", "FIG default"]), ... - 'Figure Studio should expose only the LabKit style and FIG default modes.'); - assert(testui.control.getValue(ui, "canvasWidth") == 720 && ... - testui.control.getValue(ui, "canvasHeight") == 540 && ... - testui.control.getValue(ui, "baseFontSize") == 36 && ... - testui.control.getValue(ui, "dataLineWidth") == 3 && ... - testui.control.getValue(ui, "axesLineWidth") == 3, ... - 'Figure Studio default single-panel style should match the measured reference panel proportions.'); - setNumericControl(fig, 'baseFontSize', 24); - ui = driver.registry(); - assert(testui.control.getValue(ui, "titleFontSize") == 24 && ... - testui.control.getValue(ui, "labelFontSize") == 24 && ... - testui.control.getValue(ui, "tickFontSize") == 24, ... - 'All font should synchronize title, label, and tick font controls.'); - setNumericControl(fig, 'titleFontSize', 32); - assert(testui.control.getValue(driver.registry(), "titleFontSize") == 32, ... - 'Single font controls should be independently adjustable after global sync.'); - setDropdownControl(fig, 'aspectPreset', 'Custom'); - setNumericControl(fig, 'canvasWidth', 1550); - setNumericControl(fig, 'canvasHeight', 777); - ui = driver.registry(); - assert(strcmp(string(testui.control.getValue(ui, "aspectPreset")), "Custom") && ... - testui.control.getValue(ui, "canvasWidth") == 1550 && ... - testui.control.getValue(ui, "canvasHeight") == 777, ... - 'Custom aspect should preserve independently edited canvas dimensions.'); - folder = tempname(); + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); figPath = fullfile(folder, 'probe.fig'); saveProbeFigure(figPath); - driver.chooseFiles('figFiles', folder); - driver.click('Add FIG files or scan folder'); - waitForNotBusy(fig); - pause(2); - drawnow; - assert(any(contains(driver.fileListItems('figFiles'), "probe.fig")), ... - 'Figure Studio should scan selected folders for MATLAB FIG files.'); - assert(driver.previewChildCount('preview') > 0 && ... - driver.enabled('exportCurrent'), ... - 'Figure Studio should auto-open scanned FIG files into a styleable preview.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'Figure Studio must execute through Runtime V2.'); - testCase.verifyEqual(func2str(runtime.definition.start), ... - 'figure_studio.initializeWorkbench', ... - 'Figure Studio should declare its post-layout initializer explicitly.'); - testCase.verifyFalse(containsGraphicsHandle(runtime.state.project), ... - 'Figure Studio projects must not retain axes or other graphics handles.'); - ax = driver.registry().controls.preview.axesById.main; - assert(~contains(join(string(ax.Title.String), " "), " | file "), ... - 'Figure Studio should not style framework file-title context as plot content.'); - assert(isappdata(ax, 'labkitFigureStudioCanvasFrame'), ... - 'Figure Studio preview should track a fixed canvas frame.'); - frameBefore = getappdata(ax, 'labkitFigureStudioCanvasFrame'); - fig.Position(3:4) = max([900 620], fig.Position(3:4) - [420 260]); + pngPath = fullfile(folder, 'styled-probe.png'); + backend = struct( ... + "chooseOutputFile", @(~, ~) ... + labkit.app.dialog.Choice(pngPath), ... + "chooseOutputFolder", @(~) ... + labkit.app.dialog.Choice(folder), ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + figure_studio.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); + assertFigureStudioLayout(h, fig); + + preset = findall(fig, "Tag", "stylePreset"); + testCase.verifyEqual(string(preset.Items), ... + ["LabKit figure", "FIG default"]); + style = runtime.State.project.parameters.style; + testCase.verifyEqual(style.baseFontSize, 36); + testCase.verifyEqual(style.dataLineWidth, 3); + testCase.verifyEqual(style.axesLineWidth, 3); + runtime.applyControlValue("baseFontSize", 24); + style = runtime.State.project.parameters.style; + testCase.verifyEqual([style.baseFontSize, style.titleFontSize, ... + style.labelFontSize, style.tickFontSize], [24 24 24 24]); + runtime.applyControlValue("titleFontSize", 32); + testCase.verifyEqual( ... + runtime.State.project.parameters.style.titleFontSize, 32); + runtime.applyControlValue("aspectPreset", "Custom"); + runtime.applyControlValue("canvasWidth", 1550); + runtime.applyControlValue("canvasHeight", 777); + style = runtime.State.project.parameters.style; + testCase.verifyEqual( ... + runtime.State.project.parameters.aspectPreset, "Custom"); + testCase.verifyEqual( ... + [style.canvasWidth style.canvasHeight], [1550 777]); + + runtime.applyFileSelection("figFiles", figPath, 1); + testCase.verifyNotEmpty(runtime.State.session.cache.plotData); + testCase.verifyFalse(containsGraphicsHandle( ... + runtime.State.project)); + ax = findall(fig, "Tag", "preview.main"); + testCase.verifyNotEmpty(ax.Children); + testCase.verifyEqual(string( ... + findall(fig, "Tag", "exportCurrent").Enable), "on"); + testCase.verifyFalse(contains( ... + join(string(ax.Title.String), " "), " | file ")); + testCase.verifyTrue(isappdata( ... + ax, 'labkitFigureStudioCanvasFrame')); + frameBefore = getappdata( ... + ax, 'labkitFigureStudioCanvasFrame'); + fig.Position(3:4) = max( ... + [900 620], fig.Position(3:4) - [420 260]); pause(0.8); drawnow; - frameAfter = getappdata(ax, 'labkitFigureStudioCanvasFrame'); - assert(isfield(frameAfter, 'scale') && frameAfter.scale <= frameBefore.scale && ... - abs(frameAfter.ratio - frameBefore.ratio) < 1e-12, ... - 'Figure Studio should preserve canvas ratio and avoid enlarging preview scale when the app window resizes.'); - assert(~isfield(driver.registry().controls, 'applyStyle'), ... - 'Figure Studio should apply style changes immediately without an Apply button.'); + frameAfter = getappdata( ... + ax, 'labkitFigureStudioCanvasFrame'); + testCase.verifyTrue(isfield(frameAfter, 'scale')); + testCase.verifyLessThanOrEqual( ... + frameAfter.scale, frameBefore.scale); + testCase.verifyEqual(frameAfter.ratio, ... + frameBefore.ratio, 'AbsTol', 1e-12); - pngPath = fullfile(folder, 'styled-probe.png'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.outputFileChooser = @(~, ~, ~) deal( ... - 'styled-probe.png', folder); - runtime.request.outputFolderChooser = @(~, ~) folder; - setappdata(fig, 'labkitUiAppRuntime', runtime); - driver.click('PNG'); + runtime.invokeAction("exportPng"); testCase.verifyTrue(isfile(pngPath)); - testCase.verifyTrue(isfile(fullfile(folder, ... - 'figure_studio.labkit.json')), ... - 'Quick exports should add the standard Figure Studio manifest.'); - driver.click('Choose output folder'); - driver.click('Export data + script'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - packagePath = string(runtime.state.project.results.lastExport.path); - testCase.verifyTrue(isfolder(packagePath)); - testCase.verifyTrue(isfile(fullfile(packagePath, ... - 'figure_studio.labkit.json')), ... - 'Package exports should add the standard Figure Studio manifest.'); + testCase.verifyTrue(isfile( ... + fullfile(folder, 'figure_studio.labkit.json'))); + runtime.invokeAction("chooseOutputFolder"); + runtime.invokeAction("exportCurrent"); + packagePath = string( ... + runtime.State.project.results.lastExport.path); + testCase.verifyTrue(startsWith(packagePath, folder)); + testCase.verifyNotEqual(packagePath, folder); + testCase.verifyTrue(isfile( ... + fullfile(packagePath, 'plot_data.mat'))); + testCase.verifyTrue(isfile( ... + fullfile(packagePath, 'recreate_plot.m'))); + testCase.verifyTrue(isfile( ... + fullfile(packagePath, 'figure_studio.labkit.json'))); assertNoDuplicateSpecIds(fig); + clear runtimeCleanup folderCleanup cleanup; end function figure_studio_accepts_popout_axes_handoff(testCase) @@ -115,42 +106,42 @@ function figure_studio_accepts_popout_axes_handoff(testCase) plot(sourceAx, 1:3, [1 4 2], 'DisplayName', 'probe'); title(sourceAx, 'Probe'); pbaspect(sourceAx, [2 1 1]); + [initialProject, ~] = figure_studio.launchRequest( ... + {"axes", sourceAx}); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + figure_studio.definition(), initialProject); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); + ax = findall(fig, "Tag", "preview.main"); + testCase.verifyNotEmpty(ax.Children); + testCase.verifyEqual(string( ... + findall(fig, "Tag", "exportCurrent").Enable), "on"); + style = runtime.State.project.parameters.style; + canvasRatio = double(style.canvasWidth) / ... + double(style.canvasHeight); + testCase.verifyEqual(canvasRatio, 2, 'AbsTol', 0.02); + testCase.verifyEqual( ... + runtime.State.project.parameters.aspectPreset, "Custom"); - fig = labkit_FigureStudio_app("axes", sourceAx); - waitForNotBusy(fig); - pause(1); - drawnow; - driver = labkitWorkflowDriver(fig); - assert(driver.previewChildCount('preview') > 0 && ... - driver.enabled('exportCurrent'), ... - 'Figure Studio should enable styling after axes handoff.'); - ui = driver.registry(); - canvasRatio = double(testui.control.getValue(ui, "canvasWidth")) / ... - double(testui.control.getValue(ui, "canvasHeight")); - assert(abs(canvasRatio - 2) < 0.02 && ... - strcmp(string(testui.control.getValue(ui, "aspectPreset")), "Custom"), ... - 'Figure Studio axes handoff should preserve the source plot box ratio as a custom canvas.'); - - folder = tempname; + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); - projectPath = fullfile(folder, 'figure-studio-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + projectPath = fullfile( ... + folder, 'figure-studio-project.mat'); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 1); - testCase.verifyFalse(isfield(saved.labkitProject.payload, 'session'), ... - 'Figure Studio projects must exclude runtime resources and caches.'); - testCase.verifyFalse(containsGraphicsHandle(saved.labkitProject.payload), ... - 'Serialized axes handoff data must not contain graphics handles.'); - testCase.verifyNotEmpty(saved.labkitProject.payload.annotations.embeddedPlot, ... - 'Axes handoff plot data should remain durable inside the project.'); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyNotEmpty(runtime.state.session.cache.plotData, ... - 'Project reopen should rebuild the plot session cache.'); - testCase.verifyGreaterThan(driver.previewChildCount('preview'), 0, ... - 'Project reopen should redraw the embedded axes handoff.'); + testCase.verifyFalse(isfield( ... + saved.labkitProject.payload, 'session')); + testCase.verifyFalse(containsGraphicsHandle( ... + saved.labkitProject.payload)); + testCase.verifyNotEmpty( ... + saved.labkitProject.payload.annotations.embeddedPlot); + runtime.restoreProject(projectPath); + testCase.verifyNotEmpty( ... + runtime.State.session.cache.plotData); + testCase.verifyNotEmpty(ax.Children); + clear runtimeCleanup folderCleanup cleanup; end function figure_studio_waits_for_stable_preview_canvas(~) @@ -159,28 +150,30 @@ function figure_studio_waits_for_stable_preview_canvas(~) h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - fig = uifigure('Visible', 'off', 'Position', [100 100 1200 800]); + fig = uifigure('Visible', 'off', ... + 'Position', [100 100 1200 800]); grid = uigridlayout(fig, [3 3]); ax = uiaxes(grid); ax.Layout.Row = 2; ax.Layout.Column = 2; - plot(ax, linspace(0, 30, 200), sin(linspace(0, 30, 200))); + plot(ax, linspace(0, 30, 200), ... + sin(linspace(0, 30, 200))); - style = figure_studio.styleLibrary.styleForPreset("LabKit figure"); + style = figure_studio.styleLibrary.styleForPreset( ... + "LabKit figure"); style.previewScale = true; figure_studio.resultFiles.applyFigureStyle(ax, style); frame = getappdata(ax, 'labkitFigureStudioCanvasFrame'); - assert(ax.Layout.Row == 2 && ax.Layout.Column == 2, ... - 'Figure Studio should place the managed canvas in the centered preview grid cell.'); + assert(ax.Layout.Row == 2 && ax.Layout.Column == 2); if isfield(frame, 'pixelPosition') frameIsStable = frame.pixelPosition(3) > 100 && ... frame.pixelPosition(4) > 100; else - frameIsStable = frame.position(3) > 0.5 && frame.position(4) > 0.5; + frameIsStable = frame.position(3) > 0.5 && ... + frame.position(4) > 0.5; end - assert(frame.scale > 0.5 && frameIsStable, ... - 'Figure Studio should not freeze preview canvas size from the initial 100x100 uigridlayout measurement.'); + assert(frame.scale > 0.5 && frameIsStable); end function popout_send_to_studio_copies_plot_content(~) @@ -194,126 +187,112 @@ function popout_send_to_studio_copies_plot_content(~) plot(sourceAx, 1:3, [2 1 4], 'DisplayName', 'source'); title(sourceAx, 'Source Plot'); - labkit.ui.interaction.enablePopout(sourceAx); + labkit.app.plot.enablePopout(sourceAx); menu = findall(sourceAx.ContextMenu, 'Type', 'uimenu', ... 'Tag', 'labkitAxesPopoutMenu'); menu(1).MenuSelectedFcn(menu(1), []); - popoutFig = findall(groot, 'Type', 'figure', 'Name', 'Source Plot'); + popoutFig = findall(groot, 'Type', 'figure', ... + 'Name', 'Source Plot'); popoutFig = popoutFig(1); hookCleanup = onCleanup(@() removeStudioHook()); setappdata(groot, 'labkitFigureStudioLauncher', ... @(ax) labkit_FigureStudio_app("axes", ax)); - studioTool = findall(popoutFig, 'Tag', 'labkitAxesPopoutStudioTool'); - assert(~isempty(studioTool), ... - 'Popout should expose a Studio handoff button.'); + studioTool = findall(popoutFig, ... + 'Tag', 'labkitAxesPopoutStudioTool'); + assert(~isempty(studioTool)); h.invokeCallback(studioTool(1), 'Callback'); drawnow; studioFig = figureStudioFigures(); - assert(~isempty(studioFig), ... - 'Popout Studio handoff should launch Figure Studio.'); - driver = labkitWorkflowDriver(studioFig(1)); - assert(driver.previewChildCount('preview') > 0 && ... - driver.enabled('exportCurrent'), ... - 'Popout Studio handoff should copy plot content into Studio.'); + assert(~isempty(studioFig)); + preview = findall(studioFig(1), "Tag", "preview.main"); + export = findall(studioFig(1), "Tag", "exportCurrent"); + assert(~isempty(preview.Children) && ... + string(export.Enable) == "on"); + clear hookCleanup cleanup; end end end -function removeStudioHook() - if isappdata(groot, 'labkitFigureStudioLauncher') - rmappdata(groot, 'labkitFigureStudioLauncher'); - end +function assertFigureStudioLayout(h, fig) +h.assertStartupSucceeded(fig); +ids = ["figFiles", "currentSource", "statusSummary", "stylePreset", ... + "aspectPreset", "canvasWidth", "canvasHeight", "exportScale", ... + "boundaryLines", "baseFontSize", "titleFontSize", "labelFontSize", ... + "tickFontSize", "dataLineWidth", "axesLineWidth", "gridAlpha", ... + "gridVisible", "outputFolder", "saveFig", ... + "exportPng", "exportJpg", "exportSvg", "chooseOutputFolder", ... + "exportCurrent", ... + "preview.main"]; +for id = ids + assert(numel(findall(fig, "Tag", id)) == 1, ... + "Missing Figure Studio semantic target: %s.", id); end - -function figures = figureStudioFigures() - allFigures = findall(groot, 'Type', 'figure'); - keep = false(size(allFigures)); - for k = 1:numel(allFigures) - keep(k) = contains(string(allFigures(k).Name), "Figure Studio"); - end - figures = allFigures(keep); +tabs = findall(fig, "Type", "uitab"); +assert(isequal(sort(string({tabs.Title})), ... + sort(["Figures", "Export", "Log"]))); end -function waitForNotBusy(fig) - deadline = tic; - while isvalid(fig) && isappdata(fig, 'labkitUiBusyDepth') && ... - getappdata(fig, 'labkitUiBusyDepth') > 0 && toc(deadline) < 45 - pause(0.1); - drawnow; - end +function removeStudioHook() +if isappdata(groot, 'labkitFigureStudioLauncher') + rmappdata(groot, 'labkitFigureStudioLauncher'); end - -function setNumericControl(fig, id, value) - ui = getappdata(fig, 'labkitUiRegistry'); - control = ui.controls.(char(id)); - previous = control.valueSpinner.Value; - control.valueSpinner.Value = value; - control.valueSpinner.ValueChangedFcn(control.valueSpinner, ... - struct('PreviousValue', previous)); - pause(0.65); - waitForNotBusy(fig); - drawnow; end -function setDropdownControl(fig, id, value) - ui = getappdata(fig, 'labkitUiRegistry'); - control = ui.controls.(char(id)); - previous = control.valueHandle.Value; - control.valueHandle.Value = value; - control.valueHandle.ValueChangedFcn(control.valueHandle, ... - struct('PreviousValue', previous)); - pause(0.65); - waitForNotBusy(fig); - drawnow; +function figures = figureStudioFigures() +allFigures = findall(groot, 'Type', 'figure'); +keep = false(size(allFigures)); +for k = 1:numel(allFigures) + keep(k) = contains(string(allFigures(k).Name), "Figure Studio"); +end +figures = allFigures(keep); end function saveProbeFigure(filepath) - f = figure('Visible', 'off'); - cleanup = onCleanup(@() delete(f)); - ax = axes('Parent', f); - plot(ax, 1:4, [1 3 2 4], 'DisplayName', 'probe'); - title(ax, 'Probe'); - savefig(f, filepath); +f = figure('Visible', 'off'); +cleanup = onCleanup(@() delete(f)); +ax = axes('Parent', f); +plot(ax, 1:4, [1 3 2 4], 'DisplayName', 'probe'); +title(ax, 'Probe'); +savefig(f, filepath); end function removeTempFolder(folder) - if isfolder(folder) - rmdir(folder, 's'); - end +if isfolder(folder) + rmdir(folder, 's'); +end end function tf = containsGraphicsHandle(value) - tf = isa(value, 'matlab.graphics.Graphics'); - if tf - return; - end - if isstruct(value) - names = fieldnames(value); - for index = 1:numel(value) - for name = names.' - if containsGraphicsHandle(value(index).(name{1})) - tf = true; - return; - end - end - end - elseif iscell(value) - for index = 1:numel(value) - if containsGraphicsHandle(value{index}) +tf = isa(value, 'matlab.graphics.Graphics'); +if tf + return; +end +if isstruct(value) + names = fieldnames(value); + for index = 1:numel(value) + for name = names.' + if containsGraphicsHandle(value(index).(name{1})) tf = true; return; end end end +elseif iscell(value) + for index = 1:numel(value) + if containsGraphicsHandle(value{index}) + tf = true; + return; + end + end +end end function assertNoDuplicateSpecIds(fig) - tags = string(get(findall(fig), 'Tag')); - tags = tags(strlength(tags) > 0); - [uniqueTags, ~, group] = unique(tags); - counts = accumarray(group, 1); - duplicateTags = uniqueTags(counts > 1); - assert(~any(duplicateTags == "figures"), ... - 'Figure Studio should not reuse the figures tab id as a control id.'); +tags = string(get(findall(fig), 'Tag')); +tags = tags(strlength(tags) > 0); +[uniqueTags, ~, group] = unique(tags); +counts = accumarray(group, 1); +duplicateTags = uniqueTags(counts > 1); +assert(~any(duplicateTags == "figures")); end diff --git a/tests/cases/gui/apps/neurophysiology/nerve_response_analysis/GuiLayoutNerveResponseAnalysisTest.m b/tests/cases/gui/apps/neurophysiology/nerve_response_analysis/GuiLayoutNerveResponseAnalysisTest.m index 39cb7717d..1949a1a90 100644 --- a/tests/cases/gui/apps/neurophysiology/nerve_response_analysis/GuiLayoutNerveResponseAnalysisTest.m +++ b/tests/cases/gui/apps/neurophysiology/nerve_response_analysis/GuiLayoutNerveResponseAnalysisTest.m @@ -8,83 +8,97 @@ function nerve_response_analysis_workflow_analyzes_filter_record(testCase) h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - folder = tempname; + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); filterPath = fullfile(folder, 'filter_record.json'); writeFilterRecordJson(filterPath); + protocolPath = fullfile(folder, "protocol.json"); + writeProtocolJson(protocolPath); + alternateFolder = fullfile(folder, "alternate"); + mkdir(alternateFolder); - fig = h.launchFigure('labkit_NerveResponseAnalysis_app', ... - 'Nerve Response Analysis'); - driver = labkitWorkflowDriver(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'Nerve Response Analysis must execute through Runtime V2.'); - driver.chooseFiles('sessionFile', filterPath); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + nerve_response_analysis.definition(), [], struct( ... + "alert", @(~, ~) [], ... + "chooseOutputFolder", @(~) ... + labkit.app.dialog.Choice(alternateFolder))); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); + ids = ["sessionFile", "protocolFile", "maxRecordings", ... + "maxDurationSec", "statusField", "runAnalysis", ... + "resetWorkflow", "summaryTable", "details", ... + "outputFolder", "chooseOutputFolder", ... + "clearOutputFolder", "exportAnalysis", "logPanel", ... + "preview"]; + for id = ids + testCase.verifyEqual(numel(findall(fig, "Tag", id)), 1); + end + runtime.applyFileSelection('sessionFile', filterPath, 1); + testCase.verifyNotEmpty( ... + runtime.State.session.cache.filterRecord); + defaultOutputFolder = fullfile( ... + folder, "nerve_response_analysis"); + testCase.verifyEqual( ... + runtime.State.session.workflow.outputFolder, ... + string(defaultOutputFolder)); + runtime.applyFileSelection("protocolFile", protocolPath, 1); + testCase.verifyEqual( ... + runtime.State.session.cache.protocolPath, ... + string(protocolPath)); - driver.click('Choose filter'); - testCase.verifyTrue(driver.enabled('runAnalysis'), ... - 'Nerve-response analysis should enable after a filter record loads.'); - ui = driver.registry(); - testCase.verifyTrue(contains(string(ui.controls.sessionFile.status.Value), ... - 'filter_record.json'), ... - 'Nerve-response workflow should show the selected filter record.'); + runtime.invokeAction('runAnalysis'); + analysis = runtime.State.session.cache.analysis; + testCase.verifyEqual(analysis.recordingCount, 2); + testCase.verifyEqual(analysis.analyzedCount, 1); + testCase.verifyEqual(height(analysis.issues), 1); + previewAxes = findall(fig, 'Tag', 'preview'); + testCase.verifyNotEmpty(previewAxes.Children); + runtime.applyControlValue("preview", "Issues"); + testCase.verifyEqual( ... + runtime.State.session.view.previewMode, "Issues"); + testCase.verifyNotEmpty(previewAxes.Children); - driver.click('Analyze Filtered Files'); - - ui = driver.registry(); - testCase.verifyTrue(contains(string(ui.controls.statusField.valueHandle.Value), ... - 'Analyzed 1 recording'), ... - 'Nerve-response workflow should report analyzed recordings.'); - data = driver.tableData('summaryTable'); - testCase.verifyTrue(rowHasValue(data, 'Recordings', '2'), ... - 'Nerve-response summary should include all filter-record rows.'); - testCase.verifyTrue(rowHasValue(data, 'Analyzed', '1'), ... - 'Nerve-response summary should include accepted recording count.'); - testCase.verifyTrue(rowHasValue(data, 'Issues', '1'), ... - 'Nerve-response summary should include the missing-RHS issue.'); - testCase.verifyTrue(any(contains(string(driver.textAreaValue('details')), ... - 'First issue')), ... - 'Nerve-response details should show the first analysis issue.'); - testCase.verifyGreaterThan(driver.previewChildCount('preview'), 0, ... - 'Nerve-response workflow should redraw the analysis preview.'); - testCase.verifyTrue(driver.enabled('exportAnalysis'), ... - 'Nerve-response export should enable after analysis with a default output folder.'); - - driver.click('Export Analysis'); - outputPath = fullfile(folder, 'nerve_response_analysis', ... - 'nerve_response_analysis.json'); + runtime.invokeAction('exportAnalysis'); + outputPath = fullfile( ... + defaultOutputFolder, 'nerve_response_analysis.json'); testCase.verifyTrue(exist(outputPath, 'file') == 2, ... 'Nerve-response workflow should export the analysis JSON.'); - manifestPath = fullfile(folder, 'nerve_response_analysis', ... - 'nerve_response_analysis.labkit.json'); + manifestPath = ... + runtime.State.project.results.lastExport.manifestPath; + testCase.verifyEqual(string(manifestPath), string(fullfile( ... + defaultOutputFolder, ... + "nerve_response_analysis.labkit.json"))); testCase.verifyTrue(exist(manifestPath, 'file') == 2, ... 'Nerve-response export should include a standard result manifest.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); testCase.verifyFalse(isfield( ... - runtime.state.project.results.lastExport, 'analysis'), ... + runtime.State.project.results.lastExport, 'analysis'), ... 'Durable export records must not embed analysis cache data.'); - testCase.verifyNotEmpty(runtime.state.session.cache.analysis, ... - 'Analysis tables should remain in the rebuildable session cache.'); projectPath = fullfile(folder, 'nerve-response-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 2); testCase.verifyFalse(isfield(saved.labkitProject.payload, 'cache')); - driver.click('Reset'); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyNotEmpty(runtime.state.session.cache.filterRecord, ... + runtime.invokeAction("clearOutputFolder"); + testCase.verifyEqual( ... + runtime.State.session.workflow.outputFolder, ""); + runtime.invokeAction("chooseOutputFolder"); + testCase.verifyEqual( ... + runtime.State.session.workflow.outputFolder, ... + string(alternateFolder)); + runtime.invokeAction("resetWorkflow"); + testCase.verifyEmpty(runtime.State.project.inputs.sources); + testCase.verifyEmpty(runtime.State.session.cache.filterRecord); + runtime.restoreProject(projectPath); + testCase.verifyNotEmpty(runtime.State.session.cache.filterRecord, ... 'Project reopen should rebuild parsed filter-record cache.'); - testCase.verifyEmpty(runtime.state.session.cache.analysis, ... + testCase.verifyEmpty(runtime.State.session.cache.analysis, ... 'Project reopen should not restore transient analysis tables.'); - driver.click('Analyze Filtered Files'); - ui = driver.registry(); - testCase.verifyTrue(contains(string( ... - ui.controls.statusField.valueHandle.Value), ... - 'Analyzed 1 recording')); + runtime.invokeAction('runAnalysis'); + testCase.verifyEqual( ... + runtime.State.session.cache.analysis.analyzedCount, 1); + clear runtimeCleanup end end end @@ -101,8 +115,10 @@ function writeFilterRecordJson(filepath) fprintf(fid, '%s', jsonencode(payload)); end -function tf = rowHasValue(data, metric, value) - tf = any(strcmp(string(data(:, 1)), metric) & strcmp(string(data(:, 2)), value)); +function writeProtocolJson(filepath) + fid = fopen(filepath, 'w'); + cleaner = onCleanup(@() fclose(fid)); + fprintf(fid, '%s', jsonencode(struct())); end function removeTempFolder(folder) diff --git a/tests/cases/gui/apps/neurophysiology/response_review_stats/GuiLayoutResponseReviewStatsTest.m b/tests/cases/gui/apps/neurophysiology/response_review_stats/GuiLayoutResponseReviewStatsTest.m index abc3b2b41..f0e41a8be 100644 --- a/tests/cases/gui/apps/neurophysiology/response_review_stats/GuiLayoutResponseReviewStatsTest.m +++ b/tests/cases/gui/apps/neurophysiology/response_review_stats/GuiLayoutResponseReviewStatsTest.m @@ -8,73 +8,92 @@ function response_review_stats_workflow_loads_metrics_and_exports(testCase) h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - folder = tempname; + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); segmentPath = fullfile(folder, 'segments.csv'); writeSegmentCsv(segmentPath); + alternateFolder = fullfile(folder, "alternate"); + mkdir(alternateFolder); - fig = h.launchFigure('labkit_ResponseReviewStats_app', ... - 'Response Review Stats'); - driver = labkitWorkflowDriver(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'Response Review Stats must execute through Runtime V2.'); - driver.chooseFiles('inputFile', segmentPath); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + response_review_stats.definition(), [], struct( ... + "alert", @(~, ~) [], ... + "chooseOutputFolder", @(~) ... + labkit.app.dialog.Choice(alternateFolder))); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); + ids = ["inputFile", "baselineWindowSec", "noiseWindowSec", ... + "statusField", "loadMetrics", "resetWorkflow", ... + "summaryTable", "details", "outputFolder", ... + "chooseOutputFolder", "clearOutputFolder", ... + "exportMetrics", "logPanel", "preview"]; + for id = ids + testCase.verifyEqual(numel(findall(fig, "Tag", id)), 1); + end + testCase.verifyEqual( ... + findall(fig, "Tag", "baselineWindowSec").Value, 0.007); + testCase.verifyEqual( ... + findall(fig, "Tag", "baselineWindowSec.end").Value, 0.009); + testCase.verifyEqual( ... + findall(fig, "Tag", "noiseWindowSec").Value, 0.007); + testCase.verifyEqual( ... + findall(fig, "Tag", "noiseWindowSec.end").Value, 0.009); + runtime.applyFileSelection('inputFile', segmentPath, 1); - driver.click('Choose input'); + testCase.verifyEqual( ... + height(runtime.State.session.cache.metrics), 2); + testCase.verifyEqual( ... + height(runtime.State.session.cache.summary), 2); + defaultOutputFolder = fullfile(folder, "response_review_stats"); + testCase.verifyEqual( ... + runtime.State.session.workflow.outputFolder, ... + string(defaultOutputFolder)); + previewAxes = findall(fig, 'Tag', 'preview'); + testCase.verifyNotEmpty(previewAxes.Children); - ui = driver.registry(); - testCase.verifyTrue(contains(string(ui.controls.inputFile.status.Value), ... - 'segments.csv'), ... - 'Response-review workflow should show the selected segment CSV.'); - testCase.verifyTrue(contains(string(ui.controls.statusField.valueHandle.Value), ... - 'Loaded 2 metric row'), ... - 'Response-review workflow should auto-load metrics after input selection.'); - data = driver.tableData('summaryTable'); - testCase.verifyTrue(rowHasValue(data, 'Metrics', '2'), ... - 'Response-review summary should include measured metric rows.'); - testCase.verifyTrue(rowHasValue(data, 'Summary groups', '2'), ... - 'Response-review summary should include one group per segment.'); - testCase.verifyTrue(any(contains(string(driver.textAreaValue('details')), ... - 'Groups: 2')), ... - 'Response-review details should summarize metric groups.'); - testCase.verifyGreaterThan(driver.previewChildCount('preview'), 0, ... - 'Response-review workflow should draw the summary preview.'); - testCase.verifyTrue(driver.enabled('exportMetrics'), ... - 'Response-review export should enable after metrics load with a default output folder.'); + runtime.applyControlValue('preview', 'Aligned'); + testCase.verifyEqual( ... + runtime.State.session.view.previewMode, "Aligned"); + testCase.verifyNotEmpty(previewAxes.Children); - driver.dropdown('Aligned'); - testCase.verifyGreaterThan(driver.previewChildCount('preview'), 0, ... - 'Response-review workflow should redraw the aligned preview.'); - - driver.click('Export Metrics'); - outputPath = fullfile(folder, 'response_review_stats', ... - 'response_review_metrics.csv'); + runtime.invokeAction('exportMetrics'); + outputPath = fullfile( ... + defaultOutputFolder, 'response_review_metrics.csv'); testCase.verifyTrue(exist(outputPath, 'file') == 2, ... 'Response-review workflow should export the metrics CSV.'); - manifestPath = fullfile(folder, 'response_review_stats', ... - 'response_review_metrics.labkit.json'); + manifestPath = ... + runtime.State.project.results.lastExport.manifestPath; + testCase.verifyEqual(string(manifestPath), string(fullfile( ... + defaultOutputFolder, ... + "response_review_metrics.labkit.json"))); testCase.verifyTrue(exist(manifestPath, 'file') == 2, ... 'Response-review export should include a standard manifest.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); testCase.verifyFalse(isfield( ... - runtime.state.project.results.lastExport, 'metrics'), ... + runtime.State.project.results.lastExport, 'metrics'), ... 'Durable export records must not embed metric cache data.'); - testCase.verifyEqual(height(runtime.state.session.cache.metrics), 2); projectPath = fullfile(folder, 'response-review-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 2); testCase.verifyFalse(isfield(saved.labkitProject.payload, 'cache')); - driver.click('Reset'); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(height(runtime.state.session.cache.metrics), 2, ... + runtime.invokeAction("clearOutputFolder"); + testCase.verifyEqual( ... + runtime.State.session.workflow.outputFolder, ""); + runtime.invokeAction("chooseOutputFolder"); + testCase.verifyEqual( ... + runtime.State.session.workflow.outputFolder, ... + string(alternateFolder)); + runtime.invokeAction("resetWorkflow"); + testCase.verifyEmpty(runtime.State.project.inputs.sources); + testCase.verifyEqual( ... + height(runtime.State.session.cache.metrics), 0); + runtime.restoreProject(projectPath); + testCase.verifyEqual(height(runtime.State.session.cache.metrics), 2, ... 'Project reopen should rebuild metric cache from its source.'); - testCase.verifyGreaterThan(driver.previewChildCount('preview'), 0); + testCase.verifyNotEmpty(previewAxes.Children); + clear runtimeCleanup end end end @@ -91,10 +110,6 @@ function writeSegmentCsv(filepath) writetable(T, filepath); end -function tf = rowHasValue(data, metric, value) - tf = any(strcmp(string(data(:, 1)), metric) & strcmp(string(data(:, 2)), value)); -end - function removeTempFolder(folder) if exist(folder, 'dir') == 7 rmdir(folder, 's'); diff --git a/tests/cases/gui/apps/neurophysiology/rhs_preview/GuiLayoutRhsPreviewTest.m b/tests/cases/gui/apps/neurophysiology/rhs_preview/GuiLayoutRhsPreviewTest.m index 53cfc8161..ed251d534 100644 --- a/tests/cases/gui/apps/neurophysiology/rhs_preview/GuiLayoutRhsPreviewTest.m +++ b/tests/cases/gui/apps/neurophysiology/rhs_preview/GuiLayoutRhsPreviewTest.m @@ -2,13 +2,13 @@ %GUILAYOUTRHSPREVIEWTEST Verify RHS Preview GUI workflow contracts. methods (Test, TestTags = {'GUI', 'Workflow'}) - function rhs_preview_workflow_indexes_previews_and_discovers_filter_files(~) + function rhs_preview_workflow_indexes_previews_and_discovers_filter_files(testCase) setupLabKitTestPath(); h = guiTestHelpers(); h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - folder = tempname; + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); primaryPath = fullfile(folder, 'primary.rhs'); @@ -20,79 +20,128 @@ function rhs_preview_workflow_indexes_previews_and_discovers_filter_files(~) "nBlocks", 1, ... "amplifierNames", "C-004")); - fig = h.launchFigure('labkit_RHSPreview_app', 'RHS Preview'); - workflow = labkitWorkflowDriver(fig); - - workflow.chooseFiles('rhsFile', string(primaryPath)); - workflow.click('Choose RHS'); - assert(workflow.enabled('refreshPreviewWindow'), ... - 'Refresh Preview should enable after a readable RHS file is selected.'); - assert(workflow.enabled('saveProtocol'), ... - 'Save Protocol Draft should enable after RHS channels are indexed.'); - assert(workflow.previewChildCount('preview') > 0, ... - 'RHS Preview should draw a waveform preview after indexing the synthetic RHS file.'); - - summary = workflow.tableData('summaryTable'); - assert(any(strcmp(string(summary(:, 1)), 'RHS file')), ... - 'RHS summary table should include the selected file row.'); - channels = workflow.tableData('previewChannelsTable'); - assert(size(channels, 1) >= 3, ... - 'RHS preview channel table should include indexed amplifier channels.'); - assert(any(contains(string(workflow.textAreaValue('details')), 'C-001')), ... - 'RHS details should expose synthetic amplifier channel names.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.definition.contractVersion == 2, ... - 'RHS Preview must execute through Runtime V2.'); - assert(~isfield(runtime.state.project.inputs, 'info'), ... - 'RHS project must not persist decoded header/index data.'); - assert(~isempty(runtime.state.session.cache.index), ... - 'RHS index data should live in session cache.'); - - workflow.chooseFiles('rhsFolder', [string(primaryPath); string(secondaryPath)]); - workflow.click('Add RHS files or folder'); - assert(workflow.enabled('saveFilterRecord'), ... - 'Save Filter Record should enable after RHS filter files are discovered.'); - filterRows = workflow.tableData('fileFilterTable'); - assert(size(filterRows, 1) == 2, ... - 'RHS filter table should include both selected synthetic RHS files.'); - outputFolder = string(tempname); mkdir(outputFolder); outputCleanup = onCleanup(@() removeTempFolder(outputFolder)); - runtime = getappdata(fig, 'labkitUiAppRuntime'); outputs = ["rhs_protocol_draft.json", "rhs_filter_record.json"]; outputIndex = 0; - runtime.request.outputChooser = @chooseOutput; - setappdata(fig, 'labkitUiAppRuntime', runtime); - workflow.click('Save Protocol Draft'); - workflow.click('Save Filter Record'); + backend = struct( ... + "chooseOutputFile", @chooseOutput, ... + "alert", @(~, ~) []); + definition = rhs_preview.definition(); + project = definition.ProjectSchema.Create(); + project.inputs.sources = [ ... + labkit.app.project.sourceRecord( ... + "recording-1", "recording", primaryPath, true); ... + labkit.app.project.sourceRecord( ... + "filterRecording-1", "filterRecording", ... + primaryPath, true); ... + labkit.app.project.sourceRecord( ... + "filterRecording-2", "filterRecording", ... + secondaryPath, true)]; + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + definition, project, backend); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); + ids = ["rhsFile", "rhsFolder", "channelFamily", ... + "windowStartPanner", "windowSummary", ... + "maxPreviewChannels", "statusField", ... + "refreshPreviewWindow", "zoomToRoiWindow", ... + "resetWorkflow", "protocolFile", "saveProtocol", ... + "previewChannelsTable", "refreshFolderFiles", ... + "saveFilterRecord", "fileFilterTable", ... + "summaryTable", "details", "logPanel", "preview"]; + for id = ids + testCase.verifyEqual(numel(findall(fig, "Tag", id)), 1); + end + tabs = findall(fig, "Type", "uitab"); + testCase.verifyEqual(sort(string({tabs.Title})), ... + sort(["Setup", "Protocol", "Filter", "Review", "Log"])); + + testCase.verifyNotEmpty(runtime.State.session.cache.index); + testCase.verifyGreaterThanOrEqual(height( ... + runtime.State.session.cache.previewChannelRows), 3); + testCase.verifyEqual(height( ... + runtime.State.session.cache.filterRows), 2); + testCase.verifyFalse(isfield( ... + runtime.State.project.inputs, 'info')); + previewAxes = findall(fig, 'Tag', 'preview'); + testCase.verifyNotEmpty(previewAxes.Children); + runtime.applyControlValue("maxPreviewChannels", 2); + testCase.verifyEqual( ... + runtime.State.project.parameters.maxPreviewChannels, 2); + channelData = rhs_preview.analysisRun.previewChannelsTableData( ... + struct("previewChannelRows", ... + runtime.State.session.cache.previewChannelRows)); + channelData{1, 2} = "reference"; + runtime.applyTableEdit("previewChannelsTable", ... + labkit.app.event.TableCellEdit( ... + RowIndex=1, ColumnIndex=2, ... + PreviousValue="", NewValue="reference", ... + Data=channelData)); + testCase.verifyEqual( ... + runtime.State.session.cache.previewChannelRows.role(1), ... + "reference"); + timeSec = runtime.State.session.cache.preview.timeSec; + roi = [timeSec(1), timeSec(end)]; + runtime.applyInteraction( ... + "previewRange", "interactionChanged", roi); + testCase.verifyEqual( ... + runtime.State.session.view.roiSec, roi, "AbsTol", 1e-12); + runtime.invokeAction("zoomToRoiWindow"); + filterData = rhs_preview.analysisRun.fileFilterTableData( ... + struct("filterRows", runtime.State.session.cache.filterRows)); + filterData{2, 1} = "bad"; + filterData{2, 3} = "manual reject"; + runtime.applyTableEdit("fileFilterTable", ... + labkit.app.event.TableCellEdit( ... + RowIndex=2, ColumnIndex=1, ... + PreviousValue="good", NewValue="bad", ... + Data=filterData)); + testCase.verifyEqual( ... + runtime.State.session.cache.filterRows.label(2), "bad"); + + runtime.invokeAction('saveProtocol'); + runtime.invokeAction('saveFilterRecord'); assert(isfile(fullfile(outputFolder, outputs(1)))); assert(isfile(fullfile(outputFolder, outputs(2)))); - assert(isfile(fullfile(outputFolder, ... - 'rhs_protocol_draft.labkit.json'))); - assert(isfile(fullfile(outputFolder, ... - 'rhs_filter_record.labkit.json'))); + protocolManifest = fullfile( ... + outputFolder, "rhs_protocol_draft.labkit.json"); + filterManifest = fullfile( ... + outputFolder, "rhs_filter_record.labkit.json"); + testCase.verifyTrue(isfile(protocolManifest)); + testCase.verifyTrue(isfile(filterManifest)); + testCase.verifyEqual(string( ... + runtime.State.project.results.lastProtocolExport.manifestPath), ... + string(protocolManifest)); + testCase.verifyEqual(string( ... + runtime.State.project.results.lastFilterExport.manifestPath), ... + string(filterManifest)); projectPath = fullfile(outputFolder, 'rhs-preview-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); assert(saved.labkitProject.app.payloadVersion == 2); assert(~isfield(saved.labkitProject.payload.inputs, 'index')); - workflow.click('Reset'); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - assert(workflow.previewChildCount('preview') > 0, ... + runtime.applyFileSelection( ... + 'rhsFile', strings(1, 0), zeros(1, 0)); + runtime.restoreProject(projectPath); + assert(~isempty(runtime.State.session.cache.index)); + assert(~isempty(previewAxes.Children), ... 'Project reopen should rebuild the indexed preview cache.'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - filterPaths = rhs_preview.sourceFiles.pathsForRole( ... - runtime.state.project.inputs.sources, "filterRecording"); - assert(numel(filterPaths) == 2); - clear outputCleanup; + filterSources = runtime.State.project.inputs.sources( ... + string({runtime.State.project.inputs.sources.role}) == ... + "filterRecording"); + assert(numel(filterSources) == 2); + runtime.invokeAction("resetWorkflow"); + testCase.verifyEmpty(runtime.State.project.inputs.sources); + testCase.verifyEmpty(runtime.State.session.cache.index); + clear runtimeCleanup outputCleanup; - function [filename, folderPath] = chooseOutput(~, ~, ~) + function choice = chooseOutput(~, ~) outputIndex = outputIndex + 1; - filename = char(outputs(outputIndex)); - folderPath = char(outputFolder); + choice = labkit.app.dialog.Choice( ... + fullfile(outputFolder, outputs(outputIndex))); end end end diff --git a/tests/cases/gui/apps/statistics/ttest_wizard/GuiLayoutTTestWizardTest.m b/tests/cases/gui/apps/statistics/ttest_wizard/GuiLayoutTTestWizardTest.m index 91f70831d..bad1da3f1 100644 --- a/tests/cases/gui/apps/statistics/ttest_wizard/GuiLayoutTTestWizardTest.m +++ b/tests/cases/gui/apps/statistics/ttest_wizard/GuiLayoutTTestWizardTest.m @@ -1,15 +1,14 @@ classdef GuiLayoutTTestWizardTest < matlab.unittest.TestCase - %GUILAYOUTTTESTWIZARDTEST Verify multi-group selection, tests, plot, export. + % Verify typed table callbacks, comparisons, plot, and export end to end. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) - function compares_three_groups_with_first_then_plots_and_exports(testCase) + function comparesThreeGroupsWithFirstThenPlotsAndExports(testCase) setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanupFigures = onCleanup(@() h.closeAllFigures()); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); folder = string(tempname); mkdir(folder); - cleanupFolder = onCleanup(@() rmdir(folder, 's')); + folderCleanup = onCleanup(@() rmdir(folder, "s")); sourcePath = fullfile(folder, "synthetic_groups.csv"); writecell({ ... 'Reference', 'Treatment 1', 'Treatment 2'; ... @@ -18,205 +17,172 @@ function compares_three_groups_with_first_then_plots_and_exports(testCase) 1.3, 2.0, 1.4; ... 1.5, 1.9, 1.3; ... '', 1.6, ''}, sourcePath); + outputNames = ["ttest_group_data.csv", "ttest_results.csv"]; + outputIndex = 0; + backend = struct( ... + "chooseOutputFile", @chooseOutput, ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + ttest_wizard.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); + drawnow; - fig = h.launchFigure( ... - 'labkit_TTestWizard_app', 'T-Test Wizard'); - driver = labkitWorkflowDriver(fig); - h.assertStandardWorkbenchLayout(fig); - h.assertTabTitles(fig, { ... - '1 Data', '2 Test & Plot', '3 Export', 'Log', ... - 'Data', 'Plot'}); - driver.chooseFiles('sourceFile', sourcePath); - driver.click('Open table'); - - ui = driver.registry(); - testCase.verifyEqual(numel(ui.rightGrid.RowHeight), 1); - testCase.verifyEqual(numel(ui.rightGrid.ColumnWidth), 1); - testCase.verifyEqual(ui.rightGrid.RowHeight, {'1x'}); - testCase.verifyEqual( ... - ui.workspace.tabGroup.SelectedTab, ... - ui.workspace.pages.dataPage.tab); - testCase.verifyEqual( ... - ui.workspace.pages.dataPage.grid.RowHeight, {'1x', '1x'}); - sourceTable = ui.controls.sourceGrid.table; - testCase.verifyEmpty(sourceTable.CellSelectionCallback); - testCase.verifyNotEmpty(sourceTable.SelectionChangedFcn); + testCase.verifyEqual(numel(component(figure, "sourceFile")), 1); + testCase.verifyEqual(numel(component(figure, "sourceGrid")), 1); + testCase.verifyEqual(numel(component(figure, "dataTable")), 1); + testCase.verifyEqual(numel(component(figure, "resultPlot.main")), 1); + for title = [ ... + "Data and Plot", ... + "Opened table — select numeric cells here", ... + "Analysis data — type or paste Group and Value", ... + "Statistical comparison plot", ... + "What will run", ... + "Result family", ... + "Each group versus reference", ... + "Fast workflow"] + testCase.verifyEqual(numel(findall( ... + figure, "Title", title)), 1, ... + "Missing T-Test Wizard title: " + title); + end + for label = [ ... + "Table", "Selected cells", "Plot status", ... + "Last data export", "Last result export"] + testCase.verifyEqual(numel(findall( ... + figure, "Text", label)), 1, ... + "Missing T-Test Wizard label: " + label); + end + testCase.verifyEmpty(findall(figure, "Title", "Data tables")); + controlPanel = findall(figure, "Title", "Controls"); + workspacePanel = findall(figure, "Title", "Data and Plot"); + testCase.verifyNumElements(controlPanel, 1); + testCase.verifyNumElements(workspacePanel, 1); + testCase.verifyLessThan( ... + controlPanel.Layout.Column, ... + workspacePanel.Layout.Column); + buttonPosition = getpixelposition( ... + component(figure, "captureGroup"), true); + testCase.verifyGreaterThan(buttonPosition(4), 20); + runtime.applyFileSelection("sourceFile", sourcePath, 1); + + sourceTable = component(figure, "sourceGrid"); testCase.verifyEqual(string(sourceTable.ColumnName), ... ["A"; "B"; "C"]); - selectCells(sourceTable, [2 1; 3 1; 4 1; 5 1]); - h.waitForUiIdle(fig); - driver.click('Add selected source cells'); + click(figure, "captureGroup"); selectCells(sourceTable, [2 2; 3 2; 4 2; 5 2; 6 2]); - h.waitForUiIdle(fig); - driver.click('Add selected source cells'); + click(figure, "captureGroup"); selectCells(sourceTable, [2 3; 3 3; 4 3; 5 3]); - h.waitForUiIdle(fig); - driver.click('Add selected source cells'); + click(figure, "captureGroup"); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - groups = runtime.state.project.inputs.groups; + groups = runtime.State.project.inputs.groups; testCase.verifyEqual(numel(groups), 3); testCase.verifyEqual(groups(1).values, ... - [1.2; 1.4; 1.3; 1.5], 'AbsTol', 1e-12); + [1.2; 1.4; 1.3; 1.5], AbsTol=1e-12); testCase.verifyEqual(groups(2).values, ... - [1.8; 1.7; 2.0; 1.9; 1.6], 'AbsTol', 1e-12); - testCase.verifyEqual( ... - string(ui.controls.captureTarget.valueHandle.Value), ... - "(new group)", ... - 'Repeated captures should default to creating another group.'); + [1.8; 1.7; 2.0; 1.9; 1.6], AbsTol=1e-12); testCase.verifyTrue(all(ismember( ... ["Reference", "Treatment 1", "Treatment 2"], ... - string(ui.controls.captureTarget.valueHandle.Items)))); - testCase.verifyTrue(driver.enabled('runComparisons')); - analysisData = driver.tableData('dataTable'); - testCase.verifyEqual(analysisData(1:4, 1), ... - repmat({'Reference'}, 4, 1)); - - dataTable = ui.controls.dataTable.table; - selectCells(dataTable, [1 1; 2 1]); - h.waitForUiIdle(fig); - testCase.verifyTrue(driver.enabled('deleteSelectedRows')); - driver.click('Delete selected rows'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); + string(component(figure, "captureTarget").Items)))); + testCase.verifyEqual(string( ... + component(figure, "runComparisons").Enable), "on"); + + analysisTable = component(figure, "dataTable"); + selectCells(analysisTable, [1 1; 2 1]); + testCase.verifyEqual(string( ... + component(figure, "deleteSelectedRows").Enable), "on"); + click(figure, "deleteSelectedRows"); testCase.verifyEqual(arrayfun( ... @(group) numel(group.values), ... - runtime.state.project.inputs.groups), [2; 5; 4]); + runtime.State.project.inputs.groups), [2; 5; 4]); selectCells(sourceTable, [2 1; 3 1]); - h.waitForUiIdle(fig); - setControlValue(ui.controls.captureTarget, 'Reference'); - h.waitForUiIdle(fig); - driver.click('Add selected source cells'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(arrayfun( ... - @(group) numel(group.values), ... - runtime.state.project.inputs.groups), [4; 5; 4]); - - selectCells(dataTable, [1 1; 2 1]); - h.waitForUiIdle(fig); - setControlValue(ui.controls.batchGroupTarget, 'Treatment 1'); - h.waitForUiIdle(fig); - driver.click('Change selected rows'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); + changeValue(figure, "captureTarget", "Reference"); + click(figure, "captureGroup"); testCase.verifyEqual(arrayfun( ... @(group) numel(group.values), ... - runtime.state.project.inputs.groups), [2; 7; 4]); - - selectCells(dataTable, [8 1; 9 1]); - h.waitForUiIdle(fig); - setControlValue(ui.controls.batchGroupTarget, 'Reference'); - h.waitForUiIdle(fig); - driver.click('Change selected rows'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(arrayfun( ... - @(group) numel(group.values), ... - runtime.state.project.inputs.groups), [4; 5; 4]); + runtime.State.project.inputs.groups), [4; 5; 4]); - driver.click('Run / refresh comparisons'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - completed = runtime.state.project.results.current; + click(figure, "runComparisons"); + completed = runtime.State.project.results.current; testCase.verifySize(completed, [2 1]); testCase.verifyTrue(all([completed.ok])); testCase.verifyEqual(completed(1).pValue, ... - 0.00222460334889963, 'RelTol', 1e-11); - testCase.verifySize(driver.tableData('resultTable'), [2 5]); + 0.00222460334889963, RelTol=1e-11); + testCase.verifySize(component(figure, "resultTable").Data, [2 5]); - ui.workspace.tabGroup.SelectedTab = ... - ui.workspace.pages.plotPage.tab; - drawnow; - testCase.verifyEqual( ... - ui.workspace.tabGroup.SelectedTab, ... - ui.workspace.pages.plotPage.tab); - axesHandle = ui.controls.resultPlot.primaryAxes; + axesHandle = component(figure, "resultPlot.main"); testCase.verifyGreaterThan(numel(axesHandle.Children), 0); testCase.verifyEqual(string(axesHandle.Box), "on"); - testCase.verifyEqual(string(axesHandle.YGrid), "off"); testCase.verifyEqual(axesHandle.YLim(1), 0); - testCase.verifyGreaterThanOrEqual(numel(axesHandle.YTick), 3); - testCase.verifyEqual(axesHandle.FontSize, 14); testCase.verifyEqual(string(axesHandle.XTickLabel), ... ["Reference"; "Treatment 1"; "Treatment 2"]); - testCase.verifyTrue(contains( ... - string(ui.controls.plotFreshness.valueHandle.Value), ... - "Current")); - - choices = ttest_wizard.userInterface.plotChoices(); - setControlValue(ui.controls.plotType, choices.types(2)); - h.waitForUiIdle(fig); - testCase.verifyTrue(any(contains( ... - string(arrayfun(@class, axesHandle.Children, ... - 'UniformOutput', false)), "BoxChart"))); + testCase.verifyTrue(contains(string( ... + component(figure, "plotFreshness").Value), "Current")); testChoices = ttest_wizard.testRun.choices(); - setControlValue(ui.controls.testMethod, ... - testChoices.methodLabels(2)); - h.waitForUiIdle(fig); - resultStatus = string(driver.textAreaValue('resultStatus')); - testCase.verifyTrue(any(contains( ... - resultStatus, "Data or test settings changed"))); - testCase.verifyFalse(any(~cellfun(@isempty, regexp( ... - cellstr(resultStatus), '^\d+Data', 'once')))); - setControlValue(ui.controls.testMethod, ... - testChoices.methodLabels(1)); - h.waitForUiIdle(fig); - - editedData = dataTable.Data; + changeValue(figure, "testMethod", testChoices.methodLabels(2)); + testCase.verifyTrue(any(contains(string( ... + component(figure, "resultStatus").Value), ... + "Data or test settings changed"))); + changeValue(figure, "testMethod", testChoices.methodLabels(1)); + + editedData = analysisTable.Data; editedData{1, 2} = 1.25; - dataTable.Data = editedData; - editCallback = dataTable.CellEditCallback; - editCallback(dataTable, struct( ... - 'Indices', [1 2], 'PreviousData', 1.2, ... - 'NewData', 1.25, 'EditData', 1.25)); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); + analysisTable.Data = editedData; + analysisTable.CellEditCallback(analysisTable, struct( ... + "Indices", [1 2], "PreviousData", 1.2, ... + "NewData", 1.25)); testCase.verifyEqual( ... - runtime.state.project.results.current(1).pValue, ... - completed(1).pValue, 'AbsTol', 0, ... - 'Manual data edits must not mutate completed results.'); - testCase.verifyTrue(any(contains( ... - string(ui.controls.plotFreshness.valueHandle.Value), ... - "OUT OF DATE"))); + runtime.State.project.results.current(1).pValue, ... + completed(1).pValue, AbsTol=0); + testCase.verifyTrue(contains(string( ... + component(figure, "plotFreshness").Value), "OUT OF DATE")); - outputNames = ["ttest_group_data.csv", "ttest_results.csv"]; - outputIndex = 0; - runtime.request.outputChooser = @chooseOutput; - setappdata(fig, 'labkitUiAppRuntime', runtime); - driver.click('Export group data CSV'); - driver.click('Export comparison results CSV'); + click(figure, "exportData"); + click(figure, "exportResult"); savedData = readcell(fullfile(folder, outputNames(1))); savedResult = readcell(fullfile(folder, outputNames(2))); testCase.verifySize(savedData, [6 4]); testCase.verifyEqual(savedResult{1, 4}, 'Reference Group'); testCase.verifyEqual(savedResult{3, 21}, 'ok'); + clear runtimeCleanup folderCleanup - clear cleanupFolder cleanupFigures; - - function [filename, folderPath] = chooseOutput(~, ~, ~) + function choice = chooseOutput(~, ~) outputIndex = outputIndex + 1; - filename = char(outputNames(outputIndex)); - folderPath = char(folder); + choice = labkit.app.dialog.Choice( ... + fullfile(folder, outputNames(outputIndex))); end end end end -function selectCells(tableHandle, indices) - if isprop(tableHandle, 'SelectionChangedFcn') && ... - ~isempty(tableHandle.SelectionChangedFcn) - callback = tableHandle.SelectionChangedFcn; - callback(tableHandle, struct( ... - 'Selection', indices, 'SelectionType', 'cell')); - else - callback = tableHandle.CellSelectionCallback; - callback(tableHandle, struct('Indices', indices)); - end +function handle = component(figure, id) +handle = findall(figure, "Tag", id); +assert(numel(handle) == 1, "Expected one component tagged %s.", id); +end + +function click(figure, id) +button = component(figure, id); +button.ButtonPushedFcn(button, struct()); +drawnow; end -function setControlValue(control, value) - handle = control.valueHandle; - previous = handle.Value; - handle.Value = char(string(value)); - handle.ValueChangedFcn(handle, struct( ... - 'Value', handle.Value, 'PreviousValue', previous)); +function changeValue(figure, id, value) +control = component(figure, id); +control.Value = string(value); +control.ValueChangedFcn(control, struct()); +drawnow; +end + +function selectCells(tableHandle, indices) +if isprop(tableHandle, "SelectionChangedFcn") && ... + ~isempty(tableHandle.SelectionChangedFcn) + tableHandle.SelectionChangedFcn(tableHandle, struct( ... + "Selection", indices, "SelectionType", "cell")); +else + tableHandle.CellSelectionCallback(tableHandle, ... + struct("Indices", indices)); +end +drawnow; end diff --git a/tests/cases/gui/apps/wearable/ecg_print/GuiLayoutEcgPrintTest.m b/tests/cases/gui/apps/wearable/ecg_print/GuiLayoutEcgPrintTest.m index cb403db37..1b827a147 100644 --- a/tests/cases/gui/apps/wearable/ecg_print/GuiLayoutEcgPrintTest.m +++ b/tests/cases/gui/apps/wearable/ecg_print/GuiLayoutEcgPrintTest.m @@ -2,132 +2,210 @@ %GUILAYOUTECGPRINTTEST Verify ECG Print GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) + function nativeLayoutRestoresEcgProductSurface(testCase) + setupLabKitTestPath(); + h = guiTestHelpers(); + h.assertUifigureAvailable(); + cleanup = onCleanup(@() h.closeAllFigures()); + fig = labkit_ECGPrint_app(); + + assertEcgPrintLayout(h, fig); + clear cleanup + end + function ecg_print_workflow_loads_analyzes_and_plots_recording(testCase) setupLabKitTestPath(); h = guiTestHelpers(); h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - folder = tempname; + folder = string(tempname); mkdir(folder); folderCleanup = onCleanup(@() removeTempFolder(folder)); recordingPath = fullfile(folder, 'synthetic_ecg.csv'); writeSyntheticEcgCsv(recordingPath); - - fig = h.launchFigure('labkit_ECGPrint_app', ... - 'ECG Signal Print + SNR Explorer'); - assertEcgPrintLayout(h, fig); - driver = labkitWorkflowDriver(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(runtime.definition.contractVersion, 2, ... - 'ECG Print must execute through Runtime V2.'); - driver.chooseFiles('recording', recordingPath); - - driver.click('Open recording'); - driver.dropdown('Local peaks'); - driver.click('Analyze current ROI'); - - ui = driver.registry(); - testCase.verifyTrue(contains(string(ui.controls.recording.status.Value), ... - 'synthetic_ecg.csv'), ... - 'ECG Print workflow should show the loaded recording path.'); - importStatus = string(ui.controls.importStatus.valueHandle.Value); - testCase.verifyFalse(contains(importStatus, 'Parse failed'), ... - 'ECG Print workflow should parse the synthetic recording.'); - testCase.verifyFalse(contains(importStatus, 'Open a recording'), ... - 'ECG Print workflow should leave the empty import state after loading.'); - testCase.verifyTrue(any(strcmp(ui.controls.channel.valueHandle.Items, 'ECG')), ... - 'ECG Print workflow should populate the ECG channel choice.'); - - data = driver.tableData('summaryTable'); - metricNames = string(data(:, 1)); - testCase.verifyTrue(any(metricNames == "Detected peaks"), ... - 'ECG Print workflow should report detected peaks after analysis.'); - testCase.verifyTrue(any(metricNames == "Mean SNR (dB)"), ... - 'ECG Print workflow should report SNR summary after analysis.'); - testCase.verifyGreaterThan(numel(ui.controls.previewAxes.axesById.wave.Children), 0, ... - 'ECG Print workflow should draw the waveform plot.'); - testCase.verifyGreaterThan(numel(ui.controls.previewAxes.axesById.noise.Children), 0, ... - 'ECG Print workflow should draw the noise plot.'); - testCase.verifyGreaterThan(numel(ui.controls.previewAxes.axesById.snr.Children), 0, ... - 'ECG Print workflow should draw the SNR plot.'); - testCase.verifyGreaterThan(numel(ui.controls.previewAxes.axesById.template.Children), 0, ... - 'ECG Print workflow should draw the template plot.'); - outputFolder = string(tempname); mkdir(outputFolder); outputCleanup = onCleanup(@() removeTempFolder(outputFolder)); outputs = ["ecg_segment_snr.csv", "ecg_waveform.png"]; outputIndex = 0; - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.outputChooser = @chooseOutput; - setappdata(fig, 'labkitUiAppRuntime', runtime); - driver.click('Export segment SNR CSV'); - driver.click('Export waveform PNG'); + backend = struct( ... + "chooseOutputFile", @chooseOutput, ... + "alert", @(~, ~) []); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + ecg_print.definition(), [], backend); + runtimeCleanup = onCleanup(@() runtime.close()); + fig = runtime.figureHandle(); + h.assertStartupSucceeded(fig); + + runtime.applyFileSelection("recording", recordingPath, 1); + runtime.applyControlValue("peakMethod", "Local peaks"); + runtime.invokeAction("analyze"); + + testCase.verifyTrue(contains( ... + runtime.State.session.cache.filepath, 'synthetic_ecg.csv')); + importStatus = string( ... + findall(fig, "Tag", "importStatus").Value); + testCase.verifyFalse(any(contains(importStatus, 'Parse failed'))); + testCase.verifyFalse(any(contains( ... + importStatus, 'Open a recording'))); + channel = findall(fig, "Tag", "channel"); + testCase.verifyTrue(any(string(channel.Items) == "ECG")); + + summary = findall(fig, "Tag", "summaryTable").Data; + metricNames = string(summary(:, 1)); + testCase.verifyTrue(any(metricNames == "Detected peaks")); + testCase.verifyTrue(any(metricNames == "Mean SNR (dB)")); + for id = ["wave", "noise", "snr", "template"] + ax = findall(fig, "Tag", "previewAxes." + id); + testCase.verifyNotEmpty(ax.Children, ... + "ECG Print should draw the " + id + " preview."); + end + + runtime.invokeAction("exportSegments"); + runtime.invokeAction("exportWaveform"); testCase.verifyTrue(isfile(fullfile(outputFolder, outputs(1)))); testCase.verifyTrue(isfile(fullfile(outputFolder, outputs(2)))); - testCase.verifyTrue(isfile(fullfile(outputFolder, ... - 'ecg_segment_snr.labkit.json'))); - testCase.verifyTrue(isfile(fullfile(outputFolder, ... - 'ecg_waveform.labkit.json'))); + manifests = [ ... + "ecg_segment_snr.labkit.json", ... + "ecg_waveform.labkit.json"]; + for manifest = manifests + manifestPath = fullfile(outputFolder, manifest); + testCase.verifyTrue(isfile(manifestPath)); + decoded = jsondecode(fileread(manifestPath)); + testCase.verifyEqual(string(decoded.format), ... + "labkit.result"); + testCase.verifyEqual(string(decoded.app.id), ... + "ecg_print"); + end + segmentExport = ... + runtime.State.project.results.lastSegmentExport; + waveformExport = ... + runtime.State.project.results.lastWaveformExport; + testCase.verifyEqual(string(segmentExport.manifestPath), ... + string(fullfile(outputFolder, manifests(1)))); + testCase.verifyEqual(string(waveformExport.manifestPath), ... + string(fullfile(outputFolder, manifests(2)))); + assertDisplayGraphicsAreNonPickable(testCase, fig); - projectPath = fullfile(outputFolder, 'ecg-print-project.mat'); - labkit.ui.runtime.saveState(fig, projectPath); + projectPath = fullfile( ... + outputFolder, 'ecg-print-project.mat'); + runtime.saveProject(runtime.State, projectPath); saved = load(projectPath, 'labkitProject'); testCase.verifyEqual(saved.labkitProject.app.payloadVersion, 2); testCase.verifyFalse(isfield(saved.labkitProject.payload, 'cache')); - runtime = getappdata(fig, 'labkitUiAppRuntime'); testCase.verifyFalse(isfield( ... - runtime.state.project.results.lastAnalysis, 'recording')); - labkit.ui.runtime.loadState(fig, projectPath); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyNotEmpty(runtime.state.session.cache.recording, ... - 'Project reopen should rebuild the decoded recording cache.'); - testCase.verifyNotEmpty(runtime.state.session.cache.measurements, ... - 'Project reopen should rebuild analyzed ECG caches.'); - clear outputCleanup; + runtime.State.project.results.lastAnalysis, 'recording')); + runtime.restoreProject(projectPath); + testCase.verifyNotEmpty( ... + runtime.State.session.cache.recording); + testCase.verifyNotEmpty( ... + runtime.State.session.cache.measurements); + delete(recordingPath); + runtime.invokeAction("refreshImport"); + testCase.verifyEqual(numel( ... + runtime.State.project.inputs.sources), 1); + testCase.verifyTrue(contains(string( ... + runtime.State.session.workflow.importStatus), ... + "Parse failed")); + testCase.verifyEqual(string( ... + runtime.State.session.cache.filepath), ... + string(recordingPath)); + testCase.verifyEmpty(fieldnames( ... + runtime.State.project.results.lastAnalysis)); + clear runtimeCleanup outputCleanup folderCleanup cleanup; - function [filename, folderPath] = chooseOutput(~, ~, ~) + function choice = chooseOutput(~, ~) outputIndex = outputIndex + 1; - filename = char(outputs(outputIndex)); - folderPath = char(outputFolder); + choice = labkit.app.dialog.Choice( ... + fullfile(outputFolder, outputs(outputIndex))); end end end end function assertEcgPrintLayout(h, fig) - h.assertStandardWorkbenchLayout(fig); - h.assertButtonContract(fig, {'Open recording', 'Analyze current ROI', ... - 'Preview file header', 'Parse / refresh file', ... - 'Export segment SNR CSV', 'Export waveform PNG'}); - h.assertDropdownGroups(fig, [ ... - h.dropdownGroup({'Auto', 'Yes', 'No'}, 1), ... - h.dropdownGroup({'Auto', 'seconds', 'milliseconds', ... - 'microseconds', 'nanoseconds'}, 1), ... - h.dropdownGroup({'(none)'}, 1), ... - h.dropdownGroup({'QRS streaming', 'Pan-Tompkins', 'Local peaks'}, 1), ... - h.dropdownGroup({'Template + residual band', 'Template + segments'}, 1)]); - h.assertTabTitles(fig, {'Files + Analysis', 'Summary + Results', 'Log'}); +h.assertStartupSucceeded(fig); +ids = ["recording", "recording.choose", "recording.status", ... + "previewHeader", "importStatus", "headerLine", ... + "hasHeader", "timeColumn", "timeUnit", "signalColumns", ... + "fallbackFs", "refreshImport", "channel", "roiStart", "roiEnd", ... + "lowCut", "highCut", "peakMethod", "peakDistance", ... + "segmentWindow", "templateTopN", "smoothBeats", "templateView", ... + "analyze", "exportSegments", "exportWaveform", "summaryTable", ... + "filePreview", "appLog", ... + "previewAxes.wave", "previewAxes.noise", "previewAxes.snr", ... + "previewAxes.template"]; +for id = ids + assert(numel(findall(fig, "Tag", id)) == 1, ... + "Missing ECG Print semantic target: %s.", id); +end +for id = ["headerLine", "fallbackFs", "roiStart", "roiEnd", ... + "lowCut", "highCut", "peakDistance", "segmentWindow", ... + "templateTopN", "smoothBeats"] + assert(numel(findall(fig, "Tag", id + ".slider")) == 1, ... + "ECG numeric control must retain its panner slider: %s.", id); +end +tabs = findall(fig, "Type", "uitab"); +assert(isequal(sort(string({tabs.Title})), ... + sort(["Files + Analysis", "Summary + Results", "Log"]))); +for title = ["Recording", "Import Parsing", "Channel + ROI", ... + "Signal Processing + SNR", "Exports", "Summary", ... + "File Header Preview", "Log", "Workflow Notes"] + assert(~isempty(findall(fig, "Title", title)), ... + "Missing ECG Print titled surface: %s.", title); +end +assert(numel(findall(fig, "Title", "ECG Preview")) >= 2); +assert(string(component(fig, "recording.choose").Text) == ... + "Open recording"); +h.assertAxesContract(fig, { ... + h.axesSpec("Waveform + Peaks", "Time (s)", "Amplitude"), ... + h.axesSpec("Template Noise RMS Over Time | Smooth=15 beats", ... + "Time (s)", "Noise RMS"), ... + h.axesSpec("Template SNR Over Time | Smooth=15 beats", ... + "Time (s)", "SNR (dB)"), ... + h.axesSpec("Template + Residual Band", ... + "Time from peak (s)", "Amplitude")}); end -function writeSyntheticEcgCsv(filepath) - fs = 500; - durationSec = 4; - time = (0:(durationSec * fs - 1)).' ./ fs; - ecg = 0.05 .* sin(2 .* pi .* 1.7 .* time) + ... - 0.02 .* sin(2 .* pi .* 23 .* time); - for peakTime = 1:durationSec - 1 - ecg = ecg + 1.8 .* exp(-((time - peakTime) ./ 0.018).^2); - ecg = ecg - 0.25 .* exp(-((time - peakTime - 0.045) ./ 0.026).^2); +function assertDisplayGraphicsAreNonPickable(testCase, fig) +for id = ["wave", "noise", "snr", "template"] + ax = component(fig, "previewAxes." + id); + graphics = allchild(ax); + for k = 1:numel(graphics) + if isprop(graphics(k), "HitTest") + testCase.verifyEqual(string(graphics(k).HitTest), "off"); + end + if isprop(graphics(k), "PickableParts") + testCase.verifyEqual( ... + string(graphics(k).PickableParts), "none"); + end end - T = table(time, ecg, 'VariableNames', {'time_s', 'ECG'}); - writetable(T, filepath); +end +end + +function value = component(fig, tag) +value = findall(fig, "Tag", char(tag)); +assert(isscalar(value), "Expected one component with Tag %s.", tag); +end + +function writeSyntheticEcgCsv(filepath) +fs = 500; +durationSec = 4; +time = (0:(durationSec * fs - 1)).' ./ fs; +ecg = 0.05 .* sin(2 .* pi .* 1.7 .* time) + ... + 0.02 .* sin(2 .* pi .* 23 .* time); +for peakTime = 1:durationSec - 1 + ecg = ecg + 1.8 .* exp(-((time - peakTime) ./ 0.018).^2); + ecg = ecg - 0.25 .* exp( ... + -((time - peakTime - 0.045) ./ 0.026).^2); +end +T = table(time, ecg, 'VariableNames', {'time_s', 'ECG'}); +writetable(T, filepath); end function removeTempFolder(folder) - if exist(folder, 'dir') == 7 - rmdir(folder, 's'); - end +if exist(folder, 'dir') == 7 + rmdir(folder, 's'); +end end diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAxesWorkbenchTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAxesWorkbenchTest.m deleted file mode 100644 index 5e3d9e2d6..000000000 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAxesWorkbenchTest.m +++ /dev/null @@ -1,308 +0,0 @@ -classdef GuiLayoutUiAxesWorkbenchTest < matlab.unittest.TestCase - %GUILAYOUTUIAXESWORKBENCHTEST Verify declarative shell and axes behavior. - - methods (Test, TestTags = {'GUI', 'Structural'}) - function test_gui_layout_ui_axes_workbench(testCase) - setupLabKitTestPath(); - verify_gui_layout_ui_axes_workbench(); - end - end -end - -function verify_gui_layout_ui_axes_workbench() -%TEST_GUI_LAYOUT_UI_AXES_WORKBENCH Verify declarative workbench axes behavior. - - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - - layout = labkit.ui.layout.workbench('axesWorkbenchProbe', 'Axes Workbench Probe', ... - 'controlTabs', { ... - labkit.ui.layout.tab('setup', 'Setup', { ... - labkit.ui.layout.section('inputs', 'Inputs', { ... - labkit.ui.layout.action('run', 'Run', @noop)})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.previewArea('plotPreview', 'Plots', ... - 'layout', 'stack', ... - 'axisIds', {'plot', 'image'}, ... - 'axisTitles', {'Probe Plot', 'Image Probe'}, ... - 'xLabels', {'Probe X', ''}, ... - 'yLabels', {'Probe Y', ''}), ... - labkit.ui.layout.previewArea('scalePreview', 'Scale Pair', ... - 'layout', 'pair', ... - 'axisIds', {'main', 'scale'}, ... - 'axisTitles', {'Main', 'Scale'}, ... - 'scrollZoomAxes', {'xy', 'y'}, ... - 'columnWidths', {'1x', 90})})); - ui = labkit.ui.runtime.create(layout); - - h.assertStandardWorkbenchLayout(ui.figure); - h.assertTabTitles(ui.figure, {'Setup', 'Preview', 'Inputs'}); - assert(isequal(ui.main.ColumnWidth, {420, 6, '1x'}), ... - 'Declarative app builder should create the standard resizable workbench layout.'); - assert(numel(ui.main.RowHeight) == 3 && isequal(ui.main.RowHeight{1}, 0), ... - 'Declarative workbench shell should put utility actions in native window menus.'); - assert(strcmp(ui.rightPanel.Title, 'Preview'), ... - 'Declarative app builder should preserve the workspace title.'); - - plotAx = ui.controls.plotPreview.axesById.plot; - sourceLine = plot(plotAx, 1:3, [1 4 2], 'DisplayName', 'probe', ... - 'LineWidth', 1.5); - utilityBar = findall(ui.figure, 'Tag', 'labkitUiUtilityBar'); - utilityPlotMenu = findall(ui.figure, 'Tag', 'labkitUiUtilityPlotMenu'); - utilitySavePlot = findall(ui.figure, 'Tag', 'labkitUiUtilitySavePlot'); - utilityScreenshot = findall(ui.figure, 'Tag', 'labkitUiUtilityScreenshot'); - utilitySaveState = findall(ui.figure, 'Tag', 'labkitUiUtilitySaveState'); - utilityLoadState = findall(ui.figure, 'Tag', 'labkitUiUtilityLoadState'); - utilityAppMenu = findall(ui.figure, 'Tag', 'labkitUiUtilityAppMenu'); - assert(~isempty(utilityBar) && ~isempty(utilityPlotMenu) && ... - ~isempty(utilitySavePlot) && isempty(utilityAppMenu) && ... - isTopLevelMenu(utilityScreenshot, ui.figure) && ... - isTopLevelMenu(utilitySaveState, ui.figure) && ... - isTopLevelMenu(utilityLoadState, ui.figure), ... - 'Workbench shell should expose stable native utility menu controls.'); - utilityPlotPath = string(tempname) + ".png"; - setappdata(ui.figure, 'labkitUiUtilityPlotFile', utilityPlotPath); - h.invokeCallback(utilitySavePlot(1), 'MenuSelectedFcn'); - assert(isfile(plotUtilityFile(utilityPlotPath, 1, "ProbePlot")) && ... - isfile(plotUtilityFile(utilityPlotPath, 2, "ImageProbe")), ... - 'Utility menu should export every workbench plot through MATLAB graphics export.'); - assert(~isempty(ui.figure.WindowScrollWheelFcn), ... - 'previewArea should install LabKit-managed scroll-wheel navigation.'); - mainAx = ui.controls.scalePreview.axesById.main; - scaleAx = ui.controls.scalePreview.axesById.scale; - assert(~isappdata(mainAx, 'labkitPreviewScrollZoomAxes'), ... - 'Primary pair axes should keep normal two-axis wheel zoom.'); - assert(isappdata(scaleAx, 'labkitPreviewScrollZoomAxes') && ... - string(getappdata(scaleAx, 'labkitPreviewScrollZoomAxes')) == "y", ... - 'Fixed-width secondary pair axes should wheel-zoom vertically only.'); - assertPreviewScrollNotPrepared(ui.figure, plotAx, ... - 'previewArea should defer built-in axes interaction cleanup until scroll is used.'); - plotAx.XLim = [0 10]; - plotAx.YLim = [0 20]; - didZoomPlot = testui.interaction.zoomAtPoint(plotAx, [5 10], -1); - assert(didZoomPlot && diff(plotAx.XLim) < 10 && diff(plotAx.YLim) < 20, ... - 'Generic plot axes should support cursor-centered scroll zoom.'); - unchangedX = plotAx.XLim; - unchangedY = plotAx.YLim; - didZoomOutside = testui.interaction.zoomAtPoint(plotAx, [50 10], -1); - assert(~didZoomOutside && isequal(plotAx.XLim, unchangedX) && ... - isequal(plotAx.YLim, unchangedY), ... - 'Generic zoom should ignore points outside the current plot limits.'); - xlabel(plotAx, 'Time (s)'); - plotAx.XLim = [0 10]; - plotAx.YLim = [0 20]; - didZoomTime = testui.interaction.zoomAtPoint(plotAx, [5 10], -1); - assert(didZoomTime && diff(plotAx.XLim) < 10 && ... - isequal(plotAx.YLim, [0 20]), ... - 'Time-series axes should scroll-zoom the horizontal time axis only.'); - xlabel(plotAx, 'Probe X'); - plotAx.XScale = 'log'; - plotAx.YScale = 'log'; - plotAx.XLim = [1 1000]; - plotAx.YLim = [1 1000]; - didZoomLog = testui.interaction.zoomAtPoint(plotAx, [10 100], -1); - assert(didZoomLog && diff(log10(plotAx.XLim)) < 3 && ... - diff(log10(plotAx.YLim)) < 3, ... - 'Log axes should scroll-zoom in log space.'); - assert(abs(relativeLogPosition(10, plotAx.XLim) - 1/3) < 1e-12 && ... - abs(relativeLogPosition(100, plotAx.YLim) - 2/3) < 1e-12, ... - 'Log axes scroll zoom should preserve the pointer anchor in log space.'); - plotAx.XScale = 'linear'; - plotAx.YScale = 'linear'; - plotAx.XTick = [1 2 3]; - plotAx.XTickLabel = {'Reference', 'Treatment', 'Control'}; - plotAx.XTickLabelRotation = 12; - plotAx.TickLabelInterpreter = 'none'; - hold(plotAx, 'on'); - bar(plotAx, [1 2 3], [4 5 6], 'FaceColor', [0.7 0.8 0.9]); - errorbar(plotAx, [1 2 3], [4 5 6], [0.4 0.5 0.6], ... - 'LineStyle', 'none', 'Color', 'black'); - hold(plotAx, 'off'); - h.assertAxesPopoutEnabled(plotAx, ... - 'Declarative 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 Plot'); - assert(~isempty(popoutFig), 'Axes popout menu should create a standalone plot figure.'); - if string(getenv('LABKIT_GUI_TEST_MODE')) == "hidden" - assert(strcmp(popoutFig(1).Visible, 'off'), ... - 'Axes popout should stay hidden during hidden GUI test runs.'); - end - 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.'); - sourceChildClasses = string(arrayfun( ... - @class, plotAx.Children, 'UniformOutput', false)); - copiedChildClasses = string(arrayfun( ... - @class, popoutAxes(1).Children, 'UniformOutput', false)); - assert(isequal(copiedChildClasses, sourceChildClasses), ... - 'Axes popout should preserve graphics stacking order.'); - assert(isequal(popoutAxes(1).XTick, plotAx.XTick) && ... - isequal(string(popoutAxes(1).XTickLabel), ... - string(plotAx.XTickLabel)) && ... - popoutAxes(1).XTickLabelRotation == plotAx.XTickLabelRotation && ... - strcmp(popoutAxes(1).TickLabelInterpreter, ... - plotAx.TickLabelInterpreter), ... - 'Axes popout should preserve semantic tick labels and presentation.'); - assert(isequal(popoutAxes(1).XLim, plotAx.XLim) && ... - isequal(popoutAxes(1).YLim, plotAx.YLim), ... - 'Axes popout should preserve explicit source limits.'); - assert(strcmp(popoutAxes(1).DataAspectRatioMode, 'auto') && ... - strcmp(popoutAxes(1).PlotBoxAspectRatioMode, 'auto'), ... - 'Plot axes popout should keep copied plots freely resizable.'); - toolbar = findall(popoutFig(1), 'Tag', 'labkitAxesPopoutToolbar'); - assert(~isempty(toolbar), ... - 'Enhanced axes popout should install copied-figure text controls.'); - fontTool = findall(popoutFig(1), 'Tag', 'labkitAxesPopoutFontIncreaseTool'); - lineTool = findall(popoutFig(1), 'Tag', 'labkitAxesPopoutLineIncreaseTool'); - axesTool = findall(popoutFig(1), 'Tag', 'labkitAxesPopoutAxesIncreaseTool'); - gridTool = findall(popoutFig(1), 'Tag', 'labkitAxesPopoutGridIncreaseTool'); - xLabelRotationTool = findall(popoutFig(1), ... - 'Tag', 'labkitAxesPopoutXLabelRotationTool'); - studioTool = findall(popoutFig(1), 'Tag', 'labkitAxesPopoutStudioTool'); - assert(~isempty(fontTool) && ~isempty(lineTool) && ~isempty(axesTool) && ... - ~isempty(gridTool) && ~isempty(xLabelRotationTool) && ... - ~isempty(studioTool), ... - 'Enhanced axes popout should expose stable tool tags.'); - sourceFontSize = plotAx.FontSize; - copiedFontSize = popoutAxes(1).FontSize; - toolFontSize = fontTool(1).FontSize; - assert(strcmp(fontTool(1).String, 'Font +'), ... - 'Popout font control should use visible text.'); - h.invokeCallback(fontTool(1), 'Callback'); - assert(popoutAxes(1).FontSize > copiedFontSize && ... - plotAx.FontSize == sourceFontSize, ... - 'Popout font tools should edit the copied axes only.'); - assert(fontTool(1).FontSize == toolFontSize, ... - 'Popout font tools should not resize their own controls.'); - copiedLine = findobj(popoutAxes(1), 'Type', 'line'); - copiedLineWidth = copiedLine(1).LineWidth; - copiedAxesLineWidth = popoutAxes(1).LineWidth; - sourceLineWidth = sourceLine.LineWidth; - h.invokeCallback(lineTool(1), 'Callback'); - assert(copiedLine(1).LineWidth > copiedLineWidth && ... - sourceLine.LineWidth == sourceLineWidth, ... - 'Popout line tools should edit copied graphics only.'); - assert(popoutAxes(1).LineWidth == copiedAxesLineWidth, ... - 'Popout line tools should not change axes box or grid line width.'); - h.invokeCallback(axesTool(1), 'Callback'); - assert(popoutAxes(1).LineWidth > copiedAxesLineWidth && ... - copiedLine(1).LineWidth > copiedLineWidth, ... - 'Popout axes tools should edit axes width without undoing data-line edits.'); - gridAlpha = popoutAxes(1).GridAlpha; - h.invokeCallback(gridTool(1), 'Callback'); - assert(strcmp(popoutAxes(1).XGrid, 'on') && ... - strcmp(popoutAxes(1).YGrid, 'on') && popoutAxes(1).GridAlpha > gridAlpha, ... - 'Popout grid tools should enable and strengthen grid visibility.'); - sourceXLabelRotation = plotAx.XTickLabelRotation; - assert(strcmp(xLabelRotationTool(1).String, 'X labels -'), ... - 'An angled copied axis should offer a horizontal-label action.'); - h.invokeCallback(xLabelRotationTool(1), 'Callback'); - assert(popoutAxes(1).XTickLabelRotation == 0 && ... - plotAx.XTickLabelRotation == sourceXLabelRotation && ... - strcmp(xLabelRotationTool(1).String, 'X labels /'), ... - 'Popout label rotation should switch to horizontal without editing the source axes.'); - h.invokeCallback(xLabelRotationTool(1), 'Callback'); - assert(popoutAxes(1).XTickLabelRotation == 45 && ... - strcmp(xLabelRotationTool(1).String, 'X labels -'), ... - 'Popout label rotation should switch horizontal labels to a readable angle.'); - for k = 1:8 - h.invokeCallback(fontTool(1), 'Callback'); - end - drawnow; - axesBounds = decoratedAxesBounds(popoutAxes(1)); - toolbarPosition = toolbar(1).Position; - assert(all(axesBounds(1:2) >= -0.01) && axesBounds(3) <= 1.01 && ... - axesBounds(4) <= toolbarPosition(2) + 0.01, ... - 'Popout layout should keep enlarged ticks, axis labels, and title inside the figure below the toolbar.'); - assert(strcmp(studioTool(1).String, 'Send to Studio'), ... - 'Popout should expose one Studio handoff command instead of export buttons.'); - - imgAx = ui.controls.plotPreview.axesById.image; - image(imgAx, zeros(12, 16, 3, 'uint8')); - imgAx.XLim = [0.5 16.5]; - imgAx.YLim = [0.5 12.5]; - title(imgAx, 'Image Probe'); - assertPreviewScrollNotPrepared(ui.figure, imgAx, ... - 'plot.image should not force eager built-in axes interaction cleanup.'); - didZoomImage = testui.interaction.zoomAtPoint(imgAx, [8 6], -2); - assert(didZoomImage && imgAx.XLim(1) >= 0.5 && imgAx.XLim(2) <= 16.5 && ... - imgAx.YLim(1) >= 0.5 && imgAx.YLim(2) <= 12.5, ... - 'Image axes scroll zoom should infer and clamp to displayed image bounds.'); - plotAx.XLim = [0 0.1]; - plotAx.YLim = [0 0.1]; - plot(plotAx, [0 10], [0 20]); - labkit.ui.plot.fit(plotAx); - assert(plotAx.XLim(1) <= 0 && plotAx.XLim(2) >= 10 && ... - plotAx.YLim(1) <= 0 && plotAx.YLim(2) >= 20, ... - 'Curve viewport policy should fit refreshed plot data instead of preserving stale limits.'); - 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 should create a standalone figure.'); - if string(getenv('LABKIT_GUI_TEST_MODE')) == "hidden" - assert(strcmp(imagePopoutFig(1).Visible, 'off'), ... - 'Image axes popout should stay hidden during hidden GUI test runs.'); - end - 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.'); - - function noop(varargin) - end -end - -function tf = isTopLevelMenu(menu, fig) - tf = isscalar(menu) && isequal(menu.Parent, fig) && ... - isa(menu.MenuSelectedFcn, 'function_handle'); -end - -function value = relativeLogPosition(anchor, limits) - value = (log10(anchor) - log10(limits(1))) ./ diff(log10(limits)); -end - -function filepath = plotUtilityFile(basePath, index, label) - [folder, name, ext] = fileparts(string(basePath)); - filepath = fullfile(folder, sprintf('%s_%02d_%s%s', ... - char(name), index, char(label), char(ext))); -end - -function bounds = decoratedAxesBounds(ax) - priorUnits = ax.Units; - cleanup = onCleanup(@() set(ax, 'Units', priorUnits)); - ax.Units = 'normalized'; - position = ax.Position; - inset = ax.TightInset; - bounds = [position(1) - inset(1), position(2) - inset(2), ... - position(1) + position(3) + inset(3), ... - position(2) + position(4) + inset(4)]; -end - -function assertPreviewScrollNotPrepared(fig, ax, message) - state = getappdata(fig, 'labkitPreviewScrollNavigation'); - assert(isstruct(state) && isfield(state, 'preparedAxes'), ... - 'previewArea should track lazy scroll preparation state.'); - assert(~axesContains(state.preparedAxes, ax), message); -end - -function tf = axesContains(axesHandles, ax) - tf = false; - if isempty(axesHandles) - return; - end - axesHandles = axesHandles(:).'; - for k = 1:numel(axesHandles) - if isvalid(axesHandles(k)) && isequal(axesHandles(k), ax) - tf = true; - return; - end - end -end diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBasicControlsTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBasicControlsTest.m deleted file mode 100644 index 8619fd70d..000000000 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBasicControlsTest.m +++ /dev/null @@ -1,147 +0,0 @@ -classdef GuiLayoutUiBasicControlsTest < matlab.unittest.TestCase - %GUILAYOUTUIBASICCONTROLSTEST Verify declarative control and plot helper contracts. - - methods (Test, TestTags = {'GUI', 'Structural'}) - function test_gui_layout_ui_basic_controls(testCase) - setupLabKitTestPath(); - verify_gui_layout_ui_basic_controls(); - end - end -end - -function verify_gui_layout_ui_basic_controls() -%TEST_GUI_LAYOUT_UI_BASIC_CONTROLS Verify final reusable UI control and plot helpers. - - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - - layout = labkit.ui.layout.workbench('basicControlsProbe', 'Basic Controls Probe', ... - 'controlTabs', { ... - labkit.ui.layout.tab('setup', 'Setup', { ... - labkit.ui.layout.section('filesSection', 'Files', { ... - labkit.ui.layout.filePanel('files', 'Files', ... - 'selectionMode', 'multiple', ... - 'status', 'No files loaded'), ... - labkit.ui.layout.field('gain', 'Gain', ... - 'kind', 'spinner', ... - 'value', 1, ... - 'limits', [0 10]), ... - labkit.ui.layout.field('mode', 'Mode', ... - 'kind', 'dropdown', ... - 'items', {'first', 'second'}, ... - 'value', 'second'), ... - labkit.ui.layout.action('run', 'Run', @noop)})}), ... - labkit.ui.layout.tab('results', 'Results', { ... - labkit.ui.layout.section('resultsSection', 'Results', { ... - labkit.ui.layout.resultTable('table', 'Table', ... - 'columns', {'Name', 'Value'}), ... - labkit.ui.layout.statusPanel('details', 'Details', ... - 'value', {'Ready.'})})}), ... - labkit.ui.layout.tab('log', 'Log', { ... - labkit.ui.layout.section('logSection', 'Log', { ... - labkit.ui.layout.logPanel('logPanel', 'Log', ... - 'value', {'Started.'})})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.previewArea('preview', 'Preview', ... - 'layout', 'single', ... - 'axisIds', {'main'}, ... - 'axisTitles', {'Preview'})}), ... - 'usage', {'Open files.', 'Run analysis.'}); - ui = labkit.ui.runtime.create(layout); - - testui.control.setValue(ui, 'files', ["a.dat"; "b.dat"]); - files = testui.control.getFiles(ui, 'files'); - assert(numel(files) == 2 && startsWith(ui.controls.files.listbox.Items{1}, '01 a.dat'), ... - 'setValue/getFiles should populate filePanel entries with framework labels.'); - usageId = 'basicControlsProbeUsage'; - assert(isfield(ui.controls, usageId) && ... - any(strcmp(ui.controls.(usageId).textArea.Value, 'Run analysis.')), ... - 'usagePanel should create a reusable read-only workflow help panel.'); - testui.control.setFileSelection(ui, 'files', files(2)); - selectedFilePaths = testui.control.filePaths(testui.control.getValue(ui, 'files')); - assert(isequal(selectedFilePaths, "b.dat"), ... - 'setFileSelection should apply valid filePanel selection.'); - assert(contains(string(ui.figure.Name), "file 2/2: b.dat"), ... - 'setFileSelection should surface the selected file in the app title.'); - title(ui.controls.preview.primaryAxes, 'file 2/2: b.dat'); - testui.control.setFileSelection(ui, 'files', files(1)); - ui.controls.files.listbox.ValueChangedFcn( ... - ui.controls.files.listbox, struct()); - actualPreviewTitle = ... - char(ui.controls.preview.primaryAxes.Title.String); - assert(strcmp(actualPreviewTitle, 'file 1/2: a.dat'), ... - 'Refreshing a standalone file title should replace its suffix. Actual: %s', ... - actualPreviewTitle); - testui.control.setFileSelection(ui, 'files', files(2)); - ui.controls.files.listbox.ValueChangedFcn( ... - ui.controls.files.listbox, struct()); - - testui.control.setValue(ui, 'gain', 4); - assert(testui.control.getValue(ui, 'gain') == 4, ... - 'setValue/getValue should round-trip field values by id.'); - testui.control.setItems(ui, 'mode', {'second', 'third'}); - assert(strcmp(testui.control.getValue(ui, 'mode'), 'second'), ... - 'setItems should preserve a selectable value that remains valid.'); - testui.control.setItems(ui, 'mode', {'fourth', 'fifth'}); - assert(strcmp(testui.control.getValue(ui, 'mode'), 'fourth'), ... - 'setItems should select the first item when the old value is removed.'); - testui.control.setEnabled(ui, 'run', false); - assert(strcmp(ui.controls.run.button.Enable, 'off'), ... - 'setEnabled should target action buttons by id.'); - testui.control.appendLog(ui, 'logPanel', 'Completed.'); - assert(any(contains(string(ui.controls.logPanel.textArea.Value), 'Completed.')), ... - 'appendLog should append to a semantic log panel.'); - assertLogPanelFollowLatestControls(ui.controls.logPanel); - assertSinglePanelTitle(ui.figure, 'Log'); - assertSinglePanelTitle(ui.figure, 'Usage'); - testui.plot.image(ui, 'preview', zeros(8, 9, 3, 'uint8'), ... - 'axis', 'main', 'title', 'Preview'); - ax = ui.controls.preview.primaryAxes; - assert(~isempty(ax.Children), 'plot.image should draw into a semantic preview axes.'); - assert(strcmp(char(ax.Title.String), 'Preview | file 2/2: b.dat'), ... - 'plot.image should include selected file context in preview titles.'); - testui.plot.clearPreview(ui, 'preview', 'main'); - assert(isempty(ax.Children), 'plot.clearPreview should remove preview axes children.'); - testui.plot.reset(ui, 'preview', 'Preview Reset', true, 'main'); - assert(strcmp(char(ax.Title.String), 'Preview Reset | file 2/2: b.dat'), ... - 'plot.reset should preserve selected file context in preview titles.'); - - function noop(varargin) - end - -end - -function assertLogPanelFollowLatestControls(control) - textArea = control.textArea; - assert(isappdata(textArea, 'labkitLogFollowLatest') && ... - getappdata(textArea, 'labkitLogFollowLatest'), ... - 'logPanel should follow the latest log lines by default.'); - menu = control.followLatestMenu; - assert(isvalid(menu) && strcmp(menu.Text, 'Pause auto-scroll') && ... - strcmp(menu.Checked, 'on'), ... - 'logPanel should expose a follow-latest context menu.'); - button = control.followLatestButton; - assert(isvalid(button) && strcmp(button.Text, 'Pause auto-scroll'), ... - 'logPanel should expose a visible follow-latest button.'); - - button.ButtonPushedFcn(button, []); - assert(~getappdata(textArea, 'labkitLogFollowLatest') && ... - strcmp(menu.Text, 'Follow latest') && strcmp(menu.Checked, 'off') && ... - strcmp(button.Text, 'Follow latest'), ... - 'logPanel button should pause automatic tail scrolling.'); - - testui.control.appendLog(struct('controls', struct('logPanel', control)), ... - 'logPanel', 'Paused line'); - menu.MenuSelectedFcn(menu, []); - assert(getappdata(textArea, 'labkitLogFollowLatest') && ... - strcmp(menu.Text, 'Pause auto-scroll') && strcmp(menu.Checked, 'on') && ... - strcmp(button.Text, 'Pause auto-scroll'), ... - 'logPanel context menu should restore automatic tail scrolling.'); -end - -function assertSinglePanelTitle(fig, titleText) - panels = findall(fig, 'Type', 'uipanel', 'Title', titleText); - assert(numel(panels) == 1, ... - 'Single full-panel controls should not be wrapped in a duplicate titled section.'); -end diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBusyStateTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBusyStateTest.m deleted file mode 100644 index 96b018cc2..000000000 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBusyStateTest.m +++ /dev/null @@ -1,402 +0,0 @@ -classdef GuiLayoutUiBusyStateTest < matlab.unittest.TestCase - %GUILAYOUTUIBUSYSTATETEST Verify LabKit behavior through official MATLAB tests. - - methods (Test, TestTags = {'GUI', 'Structural'}) - function test_gui_layout_ui_busy_state(testCase) - setupLabKitTestPath(); - verify_gui_layout_ui_busy_state(); - end - end -end - -function verify_gui_layout_ui_busy_state() -%TEST_GUI_LAYOUT_UI_BUSY_STATE Verify app-wide semantic busy transactions. - - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - - verifyBusyActionWrapper(); - verifyBusyNonActionWrappers(); - verifyDebouncedParameterWrappers(); - verifyDefaultCloseGuard(); - verifyBusyCloseGuard(); - verifyCloseKeyboardShortcut(); - verifyDefaultClosePromptShortcut(); - -end - -function verifyDefaultCloseGuard() - confirmCalls = 0; - lastMessage = ""; - layout = labkit.ui.layout.workbench('defaultCloseGuardProbe', ... - 'Default Close Guard Probe', ... - 'controlTabs', {labkit.ui.layout.tab('main', 'Main', { ... - labkit.ui.layout.section('actions', 'Actions', { ... - labkit.ui.layout.action('noop', 'Noop', @(~, ~) [])})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.statusPanel('status', 'Status')})); - ui = labkit.ui.runtime.create(layout); - cleaner = onCleanup(@() deleteIfValid(ui.figure)); - setappdata(ui.figure, 'labkitUiCloseConfirmFcn', @confirmCancel); - closeFcn = ui.figure.CloseRequestFcn; - - closeFcn(ui.figure, struct()); - assert(isvalid(ui.figure), ... - 'LabKit runtime figures should keep the figure open when default close confirmation is cancelled.'); - assert(confirmCalls == 1 && lastMessage == "Close this LabKit app?", ... - 'Default close guard should confirm even when the app has not marked dirty state.'); - - setappdata(ui.figure, 'labkitUiCloseConfirmFcn', @confirmClose); - closeFcn(ui.figure, struct()); - assert(~isvalid(ui.figure), ... - 'Default close guard should close the figure when the user confirms.'); - - function response = confirmCancel(~, message) - confirmCalls = confirmCalls + 1; - lastMessage = string(message); - response = "Cancel"; - end - - function response = confirmClose(~, message) - confirmCalls = confirmCalls + 1; - lastMessage = string(message); - response = "Close"; - end -end - -function verifyCloseKeyboardShortcut() - confirmCalls = 0; - layout = labkit.ui.layout.workbench('closeShortcutProbe', 'Close Shortcut Probe', ... - 'controlTabs', {labkit.ui.layout.tab('main', 'Main', { ... - labkit.ui.layout.section('actions', 'Actions', { ... - labkit.ui.layout.action('noop', 'Noop', @(~, ~) [])})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.statusPanel('status', 'Status')})); - ui = labkit.ui.runtime.create(layout); - cleaner = onCleanup(@() deleteIfValid(ui.figure)); - keyFcn = ui.figure.WindowKeyPressFcn; - assert(isa(keyFcn, 'function_handle'), ... - 'LabKit runtime figures should install a close keyboard shortcut callback.'); - - setappdata(ui.figure, 'labkitUiCloseConfirmFcn', @confirmCancel); - keyFcn(ui.figure, struct('Key', 'w', 'Modifier', {{'command'}})); - assert(isvalid(ui.figure), ... - 'Cmd+W should use close guard and keep the figure open when close is cancelled.'); - assert(confirmCalls == 1, ... - 'Cmd+W should call the close confirmation path.'); - - setappdata(ui.figure, 'labkitUiCloseConfirmFcn', @confirmClose); - keyFcn(ui.figure, struct('Key', 'w', 'Modifier', {{'control'}})); - assert(~isvalid(ui.figure), ... - 'Ctrl+W should use the same close shortcut path and close when confirmed.'); - - function response = confirmCancel(~, ~) - confirmCalls = confirmCalls + 1; - response = "Cancel"; - end - - function response = confirmClose(~, ~) - confirmCalls = confirmCalls + 1; - response = "Close"; - end -end - -function verifyDefaultClosePromptShortcut() - layout = labkit.ui.layout.workbench('defaultClosePromptShortcutProbe', ... - 'Default Close Prompt Shortcut Probe', ... - 'controlTabs', {labkit.ui.layout.tab('main', 'Main', { ... - labkit.ui.layout.section('actions', 'Actions', { ... - labkit.ui.layout.action('noop', 'Noop', @(~, ~) [])})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.statusPanel('status', 'Status')})); - ui = labkit.ui.runtime.create(layout); - cleaner = onCleanup(@() deleteIfValid(ui.figure)); - keyFcn = ui.figure.WindowKeyPressFcn; - - keyFcn(ui.figure, struct('Key', 'w', 'Modifier', {{'command'}})); - prompt = findall(ui.figure, 'Tag', 'labkitUiClosePrompt'); - assert(isvalid(ui.figure) && ~isempty(prompt), ... - 'First Cmd+W should show an in-window close prompt and keep the figure open.'); - - keyFcn(ui.figure, struct('Key', 'w', 'Modifier', {{'command'}})); - assert(~isvalid(ui.figure), ... - 'Default close prompt should treat repeated Cmd+W as close confirmation.'); - drawnow; - prompt = findall(groot, 'Tag', 'labkitUiClosePrompt'); - assert(isempty(prompt), ... - 'Default close prompt should be deleted after shortcut-confirmed close.'); -end - -function verifyBusyCloseGuard() - confirmCalls = 0; - lastMessage = ""; - layout = labkit.ui.layout.workbench('closeGuardProbe', 'Close Guard Probe', ... - 'controlTabs', {labkit.ui.layout.tab('main', 'Main', { ... - labkit.ui.layout.section('actions', 'Actions', { ... - labkit.ui.layout.action('noop', 'Noop', @(~, ~) [])})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.statusPanel('status', 'Status')})); - ui = labkit.ui.runtime.create(layout); - cleaner = onCleanup(@() deleteIfValid(ui.figure)); - setappdata(ui.figure, 'labkitUiCloseConfirmFcn', @confirmCancel); - closeFcn = ui.figure.CloseRequestFcn; - - setappdata(ui.figure, 'labkitUiBusy', true); - closeFcn(ui.figure, struct()); - assert(isvalid(ui.figure), ... - 'Busy close guard should keep the figure open when the user cancels.'); - assert(confirmCalls == 1 && contains(lastMessage, "still working"), ... - 'Close guard should prefer the framework busy message while busy.'); - - setappdata(ui.figure, 'labkitUiCloseConfirmFcn', @confirmClose); - closeFcn(ui.figure, struct()); - assert(~isvalid(ui.figure), ... - 'Close guard should close the figure when the user confirms.'); - assert(confirmCalls == 2, ... - 'Busy close guard should call the confirmation path again before closing.'); - - function response = confirmCancel(~, message) - confirmCalls = confirmCalls + 1; - lastMessage = string(message); - response = "Cancel"; - end - - function response = confirmClose(~, message) - confirmCalls = confirmCalls + 1; - lastMessage = string(message); - response = "Close"; - end -end - -function cleanup = setGuiTestModeForTest(mode) - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', char(mode)); - cleanup = onCleanup(@() setenv('LABKIT_GUI_TEST_MODE', oldMode)); -end - -function verifyBusyNonActionWrappers() - h = guiTestHelpers(); - pathCount = 0; - tableCount = 0; - selectionCount = 0; - selectedIndices = zeros(0, 2); - duplicateCount = 0; - layout = labkit.ui.layout.workbench('busyNonActionProbe', 'Busy Non-Action Probe', ... - 'controlTabs', {labkit.ui.layout.tab('main', 'Main', { ... - labkit.ui.layout.section('inputs', 'Inputs', { ... - labkit.ui.layout.filePanel('pathProbe', 'Inputs', ... - 'selectionMode', 'multiple', ... - 'dialogProvider', @(~) {'/tmp/a.txt', "/tmp/b.txt"}, ... - 'onChoose', @onPathChoose), ... - labkit.ui.layout.action('otherProbe', 'Other probe', @onOtherProbe)})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.resultTable('tableProbe', 'Table', ... - 'columns', {'A'}, ... - 'data', {1; 2; 3}, ... - 'onCellEdit', @onTableEdit, ... - 'onSelectionChange', @onTableSelection)})); - ui = labkit.ui.runtime.create(layout); - cleaner = onCleanup(@() delete(ui.figure)); - - chooseCallback = ui.controls.pathProbe.chooseButton.ButtonPushedFcn; - chooseCallback(ui.controls.pathProbe.chooseButton, struct()); - assert(pathCount == 1, ... - 'filePanel callbacks should run once inside a busy transaction.'); - assert(duplicateCount == 0, ... - 'filePanel busy transaction should drop duplicate action callbacks.'); - assert(~isappdata(ui.figure, 'labkitUiBusy'), ... - 'filePanel busy transaction should clear busy state after completion.'); - - tableCallback = ui.controls.tableProbe.table.CellEditCallback; - tableCallback(ui.controls.tableProbe.table, struct('Indices', [1 1], ... - 'PreviousData', 1, 'NewData', 2, 'EditData', '2')); - assert(tableCount == 1, ... - 'Table edit callbacks should run once inside a busy transaction.'); - assert(duplicateCount == 0, ... - 'Table edit busy transaction should drop duplicate action callbacks.'); - assert(~isappdata(ui.figure, 'labkitUiBusy'), ... - 'Table edit busy transaction should clear busy state after completion.'); - - if isprop(ui.controls.tableProbe.table, 'SelectionChangedFcn') - selectionCallback = ui.controls.tableProbe.table.SelectionChangedFcn; - selectionField = 'Selection'; - else - selectionCallback = ui.controls.tableProbe.table.CellSelectionCallback; - selectionField = 'Indices'; - end - selectionCallback(ui.controls.tableProbe.table, ... - struct(selectionField, [1 1])); - selectionCallback(ui.controls.tableProbe.table, ... - struct(selectionField, [1 1; 2 1])); - selectionCallback(ui.controls.tableProbe.table, ... - struct(selectionField, [1 1; 2 1; 3 1])); - assert(selectionCount == 0, ... - 'Rapid table selection changes should not commit synchronously.'); - h.waitForUiIdle(ui.figure); - assert(selectionCount == 1 && ... - isequal(selectedIndices, [1 1; 2 1; 3 1]), ... - 'Table selection should commit only the final coalesced range.'); - - function onPathChoose(~, event) - pathCount = pathCount + 1; - addedFilePaths = testui.control.filePaths(event.addedFiles); - allPaths = testui.control.filePaths(event.files); - assert(isstring(addedFilePaths) && iscolumn(addedFilePaths) && ... - numel(addedFilePaths) == 2 && isequal(addedFilePaths, allPaths), ... - 'filePanel event should expose selected files.'); - assert(isappdata(ui.figure, 'labkitUiBusy') && ... - getappdata(ui.figure, 'labkitUiBusy'), ... - 'filePanel callback should run while the figure is marked busy.'); - otherCallback = ui.controls.otherProbe.button.ButtonPushedFcn; - otherCallback(ui.controls.otherProbe.button, struct()); - end - - function onTableEdit(~, event) - tableCount = tableCount + 1; - assert(isequal(event.indices, [1 1]), ... - 'Table edit event should preserve edit indices.'); - assert(isappdata(ui.figure, 'labkitUiBusy') && ... - getappdata(ui.figure, 'labkitUiBusy'), ... - 'Table callback should run while the figure is marked busy.'); - otherCallback = ui.controls.otherProbe.button.ButtonPushedFcn; - otherCallback(ui.controls.otherProbe.button, struct()); - end - - function onTableSelection(~, event) - selectionCount = selectionCount + 1; - selectedIndices = event.indices; - end - - function onOtherProbe(~, ~) - duplicateCount = duplicateCount + 1; - end -end - -function verifyDebouncedParameterWrappers() - h = guiTestHelpers(); - count = 0; - values = []; - layout = labkit.ui.layout.workbench('debouncedParameterProbe', ... - 'Debounced Parameter Probe', ... - 'controlTabs', {labkit.ui.layout.tab('main', 'Main', { ... - labkit.ui.layout.section('params', 'Params', { ... - labkit.ui.layout.field('gain', 'Gain', ... - 'kind', 'number', ... - 'value', 1, ... - 'onChange', @onGainChanged)})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Preview', {})); - ui = labkit.ui.runtime.create(layout); - cleaner = onCleanup(@() delete(ui.figure)); - - ui.controls.gain.handle.Value = 2; - ui.controls.gain.handle.ValueChangedFcn(ui.controls.gain.handle, struct()); - ui.controls.gain.handle.Value = 3; - ui.controls.gain.handle.ValueChangedFcn(ui.controls.gain.handle, struct()); - ui.controls.gain.handle.Value = 4; - ui.controls.gain.handle.ValueChangedFcn(ui.controls.gain.handle, struct()); - h.waitForUiIdle(ui.figure); - - assert(count == 1 && isequal(values, 4), ... - 'Parameter callbacks should debounce rapid value changes and submit only the latest value.'); - - function onGainChanged(~, event) - count = count + 1; - values(end + 1) = event.value; - end -end - -function verifyBusyActionWrapper() - count = 0; - duplicateCount = 0; - layout = labkit.ui.layout.workbench('busyActionProbe', 'Busy Action Probe', ... - 'controlTabs', {labkit.ui.layout.tab('main', 'Main', { ... - labkit.ui.layout.section('actions', 'Actions', { ... - labkit.ui.layout.action('runProbe', 'Run probe', @onRunProbe), ... - labkit.ui.layout.action('otherProbe', 'Other probe', @onOtherProbe)})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.statusPanel('status', 'Status')})); - ui = labkit.ui.runtime.create(layout); - cleaner = onCleanup(@() delete(ui.figure)); - ui.figure.Pointer = 'arrow'; - ui.figure.WindowButtonDownFcn = @beforeActionClick; - - button = ui.controls.runProbe.button; - callback = button.ButtonPushedFcn; - callback(button, struct()); - assert(count == 1, ... - 'Action transaction should run the app callback once.'); - assert(strcmp(ui.figure.Pointer, 'crosshair'), ... - 'Action transaction should not roll back app-owned pointer changes.'); - assert(sameCallback(ui.figure.WindowButtonDownFcn, @afterActionClick), ... - 'Action transaction should not roll back app-owned figure callbacks.'); - callback(button, struct()); - assert(count == 2, ... - 'Action transaction should clear busy state after completion.'); - assert(duplicateCount == 0, ... - 'Action transaction should drop other actions while the figure is busy.'); - - function onRunProbe(~, ~) - count = count + 1; - otherCallback = ui.controls.otherProbe.button.ButtonPushedFcn; - otherCallback(ui.controls.otherProbe.button, struct()); - ui.figure.Pointer = 'crosshair'; - ui.figure.WindowButtonDownFcn = @afterActionClick; - assert(strcmp(button.Enable, 'on'), ... - 'Action busy wrapper should not mutate app-owned Enable state.'); - assert(isappdata(ui.figure, 'labkitUiBusy') && ... - getappdata(ui.figure, 'labkitUiBusy'), ... - 'Action busy wrapper should mark the figure busy during work.'); - assert(contains(ui.figure.Name, '[Working: Run probe]'), ... - 'Action busy wrapper should expose the action label in the figure title.'); - end - - function onOtherProbe(~, ~) - duplicateCount = duplicateCount + 1; - end - - function beforeActionClick(~, ~) - end - - function afterActionClick(~, ~) - end -end - -function assertThrows(fn, expectedIdentifier, label) - try - fn(); - catch ME - assert(strcmp(ME.identifier, expectedIdentifier), ... - '%s Expected %s but caught %s.', ... - label, expectedIdentifier, ME.identifier); - return; - end - error('%s Expected an error with identifier %s.', label, expectedIdentifier); -end - -function tf = sameCallback(actual, expected) - if iscell(actual) && iscell(expected) && numel(actual) == numel(expected) - tf = sameCallback(actual{1}, expected{1}); - for k = 2:numel(actual) - tf = tf && sameCallbackArgument(actual{k}, expected{k}); - end - return; - end - tf = isa(actual, 'function_handle') && ... - isa(expected, 'function_handle') && ... - strcmp(func2str(actual), func2str(expected)); -end - -function tf = sameCallbackArgument(actual, expected) - tf = isequaln(actual, expected); - if ~tf && (ischar(actual) || isstring(actual)) && ... - (ischar(expected) || isstring(expected)) - tf = isequal(string(actual), string(expected)); - end -end - -function deleteIfValid(fig) - if ~isempty(fig) && isvalid(fig) - delete(fig); - end -end diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDebugTraceTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDebugTraceTest.m deleted file mode 100644 index d80cb7049..000000000 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDebugTraceTest.m +++ /dev/null @@ -1,278 +0,0 @@ -classdef GuiLayoutUiDebugTraceTest < matlab.unittest.TestCase - %GUILAYOUTUIDEBUGTRACETEST Verify LabKit behavior through official MATLAB tests. - - methods (Test, TestTags = {'GUI', 'Structural'}) - function test_gui_layout_ui_debug_trace(testCase) - setupLabKitTestPath(); - verify_gui_layout_ui_debug_trace(); - end - end -end - -function verify_gui_layout_ui_debug_trace() -%TEST_GUI_LAYOUT_UI_DEBUG_TRACE Verify GUI callback instrumentation for debug logs. - - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - - checkDefaultInstrumentationSkipsScroll(h); - checkExplicitInstrumentation(h); - checkAttachedTextLogReceivesTraceLines(h); - checkDiagnosticReports(); - checkFilePanelSemanticTrace(); -end - -function checkDefaultInstrumentationSkipsScroll(h) - fig = uifigure('Visible', 'off', 'Name', 'labkit_debug_default_trace_probe'); - cleaner = onCleanup(@() delete(fig)); - grid = uigridlayout(fig, [1 1]); - - buttonCalls = 0; - scrollFcn = @(~,~) setappdata(fig, 'scrollCalled', true); - fig.WindowScrollWheelFcn = scrollFcn; - btnAction = uibutton(grid, 'Text', 'Default action', ... - 'ButtonPushedFcn', @onAction); - - debug = labkit.ui.debug.context('probe_app', struct()); - count = debug.instrumentFigure(fig); - assert(count >= 1, 'Default debug instrumentation should wrap component callbacks.'); - assert(isequal(fig.WindowScrollWheelFcn, scrollFcn), ... - 'Default debug instrumentation should not wrap figure scroll callbacks.'); - - h.invokeCallback(btnAction, 'ButtonPushedFcn'); - lines = string(debug.getLog()); - assert(buttonCalls == 1, 'Default instrumentation should call the original component callback.'); - assert(any(contains(lines, 'Default action')), ... - 'Default instrumentation should trace component callbacks.'); - assert(~any(contains(lines, 'WindowScrollWheelFcn')), ... - 'Default instrumentation should not add scroll traces while users read logs.'); - - function onAction(varargin) - buttonCalls = buttonCalls + 1; - end -end - -function checkExplicitInstrumentation(h) - fig = uifigure('Visible', 'off', 'Name', 'labkit_debug_trace_probe'); - cleaner = onCleanup(@() delete(fig)); - grid = uigridlayout(fig, [2 1]); - - buttonCalls = 0; - cellCallbackArg = ""; - btnAction = uibutton(grid, 'Text', 'Action', ... - 'ButtonPushedFcn', @onAction); - btnAction.Layout.Row = 1; - btnCell = uibutton(grid, 'Text', 'Cell action', ... - 'ButtonPushedFcn', {@onCellAction, 'extra'}); - btnCell.Layout.Row = 2; - - debug = labkit.ui.debug.context('probe_app', struct()); - count = debug.instrumentFigure(fig, ... - struct('callbackProperties', {{'ButtonPushedFcn'}})); - assert(count == 2, 'Debug instrumentation should wrap both button callbacks.'); - - secondCount = debug.instrumentFigure(fig, ... - struct('callbackProperties', {{'ButtonPushedFcn'}})); - assert(secondCount == 0, 'Debug instrumentation should not wrap callbacks twice.'); - - h.invokeCallback(btnAction, 'ButtonPushedFcn'); - h.invokeCallback(btnCell, 'ButtonPushedFcn'); - - lines = string(debug.getLog()); - assert(buttonCalls == 1, 'Instrumented function-handle callbacks should call the original callback.'); - assert(cellCallbackArg == "extra", 'Instrumented cell callbacks should preserve extra callback arguments.'); - assert(any(contains(lines, 'BEGIN ButtonPushedFcn') & contains(lines, '"Action"')), ... - 'Instrumented callbacks should trace the control label.'); - assert(any(contains(lines, 'BEGIN ButtonPushedFcn') & contains(lines, 'onAction')), ... - 'Instrumented callbacks should trace the original callback function name.'); - assert(any(contains(lines, 'BEGIN ButtonPushedFcn') & contains(lines, '"Cell action"')), ... - 'Instrumented cell callbacks should trace the cell-callback control label.'); - assert(any(contains(lines, 'END ButtonPushedFcn') & contains(lines, '"Cell action"')), ... - 'Instrumented cell callbacks should trace END messages.'); - - disabled = labkit.ui.debug.context('probe_app', struct('traceEnabled', false)); - disabledCount = disabled.instrumentFigure(fig, ... - struct('callbackProperties', {{'ButtonPushedFcn'}})); - assert(disabledCount == 0, 'traceEnabled=false should skip GUI instrumentation.'); - - fig2 = uifigure('Visible', 'off', 'Name', 'labkit_debug_explicit_scroll_probe'); - cleaner2 = onCleanup(@() delete(fig2)); - scrollCalls = 0; - fig2.WindowScrollWheelFcn = @onScroll; - explicitDebug = labkit.ui.debug.context('probe_app', struct()); - scrollCount = explicitDebug.instrumentFigure(fig2, ... - struct('callbackProperties', "WindowScrollWheelFcn")); - assert(scrollCount == 1, ... - 'Explicit debug instrumentation should still be able to wrap scroll callbacks.'); - fig2.WindowScrollWheelFcn(fig2, []); - scrollLines = string(explicitDebug.getLog()); - assert(scrollCalls == 1 && any(contains(scrollLines, 'WindowScrollWheelFcn')), ... - 'Explicit scroll instrumentation should trace and call the original scroll callback.'); - - function onAction(varargin) - buttonCalls = buttonCalls + 1; - end - - function onCellAction(varargin) - cellCallbackArg = string(varargin{end}); - end - - function onScroll(varargin) - scrollCalls = scrollCalls + 1; - end -end - -function checkAttachedTextLogReceivesTraceLines(h) - fig = uifigure('Visible', 'off', 'Name', 'labkit_debug_text_log_probe'); - cleaner = onCleanup(@() delete(fig)); - grid = uigridlayout(fig, [1 1]); - txt = uitextarea(grid, 'Value', {'Started.'}, 'Editable', 'off'); - - debug = labkit.ui.debug.context('probe_app', struct()); - debug.attachTextLog(txt); - debug.trace('loader', 'first trace', 'test'); - debug.trace('loader', 'second trace', 'test'); - - lines = string(debug.getLog()); - values = string(txt.Value); - assert(numel(lines) == 2, ... - 'Attached text logs should not change the in-memory debug log.'); - assert(numel(values) == 3, ... - 'Attached text logs should append exactly one UI row per trace line.'); - assert(contains(values(end - 1), 'component=loader') && ... - contains(values(end - 1), 'event=first trace') && ... - contains(values(end), 'event=second trace'), ... - 'Attached text logs should preserve trace order and message content.'); -end - -function checkDiagnosticReports() - folder = tempname; - mkdir(folder); - cleanup = onCleanup(@() removeFolder(folder)); - logFile = fullfile(folder, 'probe.log'); - crashFile = fullfile(folder, 'probe_crash.txt'); - activeFile = fullfile(folder, 'probe_active.txt'); - debug = labkit.ui.debug.context('probe_app', struct( ... - 'logFile', logFile, ... - 'crashReportFile', crashFile, ... - 'activeOperationFile', activeFile, ... - 'stallTimeoutSeconds', 5)); - - wrappedOk = debug.wrapCallback('normal callback', @normalCallback); - wrappedOk(); - assert(exist(activeFile, 'file') ~= 2, ... - 'Completed callbacks should clear the active-operation report.'); - - wrappedError = debug.wrapCallback('failing callback', @failingCallback); - assertExpectedFailure(@() wrappedError()); - report = string(fileread(crashFile)); - assert(contains(report, 'status=error') && ... - contains(report, 'operation=failing callback') && ... - contains(report, 'error_id=probe:ExpectedFailure') && ... - contains(report, 'recent_operations:') && ... - contains(report, 'BEGIN failing callback') && ... - contains(report, 'ERROR failing callback'), ... - 'Unhandled callback errors should write exact errors and recent operation repro steps.'); - - try - failingCallback(); - catch ME - debug.reportException('loader', 'caught failure', ME); - end - report = string(fileread(crashFile)); - assert(contains(report, 'status=caught_error') && ... - contains(report, 'operation=loader caught failure') && ... - contains(report, 'error_id=probe:ExpectedFailure'), ... - 'Caught app exceptions should be reportable through the debug context.'); - - modalCrashFile = fullfile(folder, 'modal_crash.txt'); - modalDebug = labkit.ui.debug.context('probe_app', struct( ... - 'logFile', fullfile(folder, 'modal.log'), ... - 'crashReportFile', modalCrashFile, ... - 'activeOperationFile', fullfile(folder, 'modal_active.txt'), ... - 'stallTimeoutSeconds', 0.1)); - wrappedModal = modalDebug.wrapCallback('modal file chooser callback', @modalFileChooserCallback); - wrappedModal(); - assert(exist(modalCrashFile, 'file') ~= 2, ... - 'File chooser modal time should not be reported as a stalled app callback.'); - - function normalCallback() - assert(exist(activeFile, 'file') == 2, ... - 'Running callbacks should leave an active-operation report on disk.'); - end - - function failingCallback() - error('probe:ExpectedFailure', 'Expected diagnostic failure.'); - end - - function modalFileChooserCallback() - modalDebug.trace('filePanel', 'inputs file chooser start', 'mode=multi'); - nestedCallback = modalDebug.wrapCallback( ... - 'nested key callback', @noop); - nestedCallback(); - pause(0.2); - drawnow; - modalDebug.trace('filePanel', 'inputs file chooser end', 'count=1'); - end -end - -function checkFilePanelSemanticTrace() - layout = labkit.ui.layout.workbench('filePanelTraceProbe', 'FilePanel Trace Probe', ... - 'controlTabs', {labkit.ui.layout.tab('setup', 'Setup', { ... - labkit.ui.layout.section('inputSection', 'Inputs', { ... - labkit.ui.layout.filePanel('inputs', 'Inputs', ... - 'dialogProvider', @(~) [string(fullfile(tempdir, 'a.png')); ... - string(fullfile(tempdir, 'b.png'))], ... - 'onChoose', @noop)})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.previewArea('preview', 'Preview')})); - debug = labkit.ui.debug.context('probe_app', struct()); - ui = labkit.ui.runtime.create(layout, 'debug', debug); - cleaner = onCleanup(@() delete(ui.figure)); - - ui.controls.inputs.chooseButton.ButtonPushedFcn( ... - ui.controls.inputs.chooseButton, struct()); - - lines = string(debug.getLog()); - assert(any(contains(lines, 'component=filePanel') & ... - contains(lines, 'event=inputs choose requested') & ... - contains(lines, 'reason=user')), ... - 'filePanel should trace the start of a choose request.'); - assert(any(contains(lines, 'component=filePanel') & ... - contains(lines, 'event=inputs paths selected') & ... - contains(lines, 'reason=count=2')), ... - 'filePanel should trace the accepted path count before app callbacks run.'); - assert(any(contains(lines, 'component=filePanel') & ... - contains(lines, 'event=inputs selection updated') & ... - contains(lines, 'total=2 added=2')), ... - 'filePanel should trace framework selection updates.'); - assert(any(contains(lines, 'component=filePanel') & ... - contains(lines, 'event=inputs callback start') & ... - contains(lines, 'reason=action=choose')), ... - 'filePanel should trace before handing control to app code.'); - assert(any(contains(lines, 'component=filePanel') & ... - contains(lines, 'event=inputs callback end') & ... - contains(lines, 'reason=action=choose')), ... - 'filePanel should trace after app callbacks return.'); -end - -function noop(varargin) -end - -function assertExpectedFailure(callback) - failedAsExpected = false; - try - callback(); - catch ME - failedAsExpected = strcmp(ME.identifier, 'probe:ExpectedFailure'); - end - assert(failedAsExpected, ... - 'Diagnostic failure probe should throw the expected error id.'); -end - -function removeFolder(folder) - if exist(folder, 'dir') == 7 - rmdir(folder, 's'); - end -end diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDeclarativeAppTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDeclarativeAppTest.m deleted file mode 100644 index 9be21737f..000000000 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDeclarativeAppTest.m +++ /dev/null @@ -1,649 +0,0 @@ -classdef GuiLayoutUiDeclarativeAppTest < matlab.unittest.TestCase - %GUILAYOUTUIDECLARATIVEAPPTEST Verify declarative app builder contracts. - - methods (Test, TestTags = {'GUI', 'Structural'}) - function test_gui_layout_ui_declarative_app(testCase) - setupLabKitTestPath(); - verify_gui_layout_ui_declarative_app(); - end - - function app_builder_respects_hidden_gui_test_mode(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanupMode = setGuiTestModeForTest("hidden"); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - - layout = labkit.ui.layout.workbench('hiddenModeProbe', 'Hidden Mode Probe', ... - 'controlTabs', {labkit.ui.layout.tab('setup', 'Setup', { ... - labkit.ui.layout.section('actions', 'Actions', { ... - labkit.ui.layout.action('run', 'Run', @noop)})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', ... - 'Preview', {labkit.ui.layout.previewArea('preview', 'Preview')})); - - ui = labkit.ui.runtime.create(layout); - drawnow; - testCase.verifyEqual(string(ui.figure.Visible), "off", ... - "LABKIT_GUI_TEST_MODE=hidden should keep app test figures off screen."); - clear cleanupMode cleanupFigures - h.closeAllFigures(); - end - end -end - -function verify_gui_layout_ui_declarative_app() -%TEST_GUI_LAYOUT_UI_DECLARATIVE_APP Verify declarative builder and registry helpers. - - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - - events = {}; - dialogCalls = 0; - folderWarningCalls = 0; - folderWarningCount = NaN; - firstDialogPaths = [ ... - string(fullfile(tempdir, 'a.png')); ... - string(fullfile(tempdir, 'b.png'))]; - recursiveFolder = tempname; - recursiveNestedFolder = fullfile(recursiveFolder, 'nested'); - mkdir(recursiveNestedFolder); - cleanupRecursiveFolder = onCleanup(@() removeTempFolder(recursiveFolder)); - secondDialogPaths = string(fullfile(recursiveNestedFolder, 'c.png')); - directFolderPath = string(fullfile(recursiveFolder, 'direct.png')); - writeEmptyFile(directFolderPath); - writeEmptyFile(secondDialogPaths); - writeEmptyFile(fullfile(recursiveNestedFolder, 'notes.txt')); - duplicateDialogCalls = 0; - duplicateDialogPaths = [ - string(fullfile(tempdir, 'alpha', 'same.png')); - string(fullfile(tempdir, 'beta', 'same.png'))]; - singleDialogCalls = 0; - singleDialogPaths = [ - string(fullfile(tempdir, 'single', 'first.png')); - string(fullfile(tempdir, 'single', 'second.png'))]; - layout = labkit.ui.layout.workbench('probeApp', 'Declarative UI Probe', ... - 'controlTabs', { ... - labkit.ui.layout.tab('setup', 'Setup', { ... - labkit.ui.layout.section('inputs', 'Inputs', { ... - labkit.ui.layout.filePanel('sourceImages', 'Source images', ... - 'selectionMode', 'single', ... - 'status', 'No images loaded', ... - 'emptyText', "No selection", ... - 'filters', {'*.png;*.jpg', 'Images (*.png, *.jpg)'; ... - '*.*', 'All files (*.*)'}, ... - 'folderWarningThreshold', 1, ... - 'folderWarningProvider', @folderWarningProvider, ... - 'dialogProvider', @dialogProvider, ... - 'folderDialogProvider', @folderDialogProvider, ... - 'onChoose', @captureEvent, ... - 'onClear', @captureEvent, ... - 'onSelectionChange', @captureEvent), ... - labkit.ui.layout.filePanel('fileQueue', 'Files', ... - 'filters', {'*.png;*.jpg', 'Images (*.png, *.jpg)'}, ... - 'dialogProvider', @duplicateDialogProvider, ... - 'selectionMode', 'single', ... - 'status', 'No files loaded', ... - 'emptyText', 'No files loaded', ... - 'onChoose', @captureEvent, ... - 'onRemove', @captureEvent, ... - 'onClear', @captureEvent, ... - 'onSelectionChange', @captureEvent), ... - labkit.ui.layout.filePanel('singleFile', 'Single file', ... - 'mode', 'single', ... - 'filters', {'*.png;*.jpg', 'Images (*.png, *.jpg)'}, ... - 'dialogProvider', @singleDialogProvider, ... - 'chooseLabel', 'Choose file', ... - 'emptyText', 'No file selected', ... - 'onChoose', @captureEvent), ... - labkit.ui.layout.field('defaultOutputFolder', ... - 'Output folder', ... - 'kind', 'readonly', ... - 'value', 'No output folder selected'), ... - labkit.ui.layout.field('longStatus', ... - 'Long status label with enough words to wrap', ... - 'kind', 'readonly', ... - 'value', ['Long status text should wrap and shrink ' ... - 'inside the control instead of being clipped']), ... - labkit.ui.layout.field('gain', 'Gain', ... - 'kind', 'spinner', ... - 'limits', [0 10], ... - 'value', 2, ... - 'onChange', @captureEvent), ... - labkit.ui.layout.panner('pan', 'Pan', ... - 'limits', [0 10], ... - 'value', 3, ... - 'step', 0.5, ... - 'onChange', @captureEvent), ... - labkit.ui.layout.panner('unboundedPan', 'Unbounded pan', ... - 'limits', [0 Inf], ... - 'value', 4, ... - 'step', 1, ... - 'onChange', @captureEvent), ... - labkit.ui.layout.rangeField('displayLimits', ... - 'Display limits', ... - 'limits', [0 1], ... - 'value', [0.1 0.9], ... - 'onChange', @captureEvent), ... - labkit.ui.layout.group('runActions', "", { ... - labkit.ui.layout.action('run', 'Run', @captureEvent, ... - 'priority', 'primary'), ... - labkit.ui.layout.action('reset', 'Reset', @captureEvent)})}), ... - labkit.ui.layout.section('notes', 'Notes', { ... - labkit.ui.layout.statusPanel('notesText', 'Notes', ... - 'value', {'Ready'})}), ... - labkit.ui.layout.section('extra', 'Extra', { ... - labkit.ui.layout.statusPanel('extraText', 'Extra', ... - 'value', {'Ready'})})}), ... - labkit.ui.layout.tab('review', 'Review', { ... - labkit.ui.layout.section('resultsSection', 'Results', { ... - labkit.ui.layout.resultTable('results', 'Results', ... - 'columns', {'Name', 'Status'}), ... - labkit.ui.layout.statusPanel('status', 'Status', ... - 'value', {'Idle'})})}), ... - labkit.ui.layout.tab('log', 'Log', { ... - labkit.ui.layout.section('logSection', 'Log', { ... - labkit.ui.layout.logPanel('logPanel', 'Log')})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.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.runtime.create(layout); - dialogPaths = cellstr(testui.control.fileLabels(firstDialogPaths).'); - drawnow; - h.assertStandardWorkbenchLayout(ui.figure); - h.assertTabTitles(ui.figure, {'Setup', 'Review', 'Log', 'Preview', ... - 'Inputs', 'Results'}); - h.assertButtonContract(ui.figure, {'Add...', 'Add folder', ... - 'Add folder tree', 'Remove selected', 'Clear', ... - 'Choose file', '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.'); - assert(numel(ui.setupResizeHandles) == 3 && all(isvalid(ui.setupResizeHandles)), ... - 'Multi-section control tabs should expose a height separator after every section.'); - assert(strcmp(testui.control.getValue(ui, 'defaultOutputFolder'), 'No output folder selected'), ... - 'Output folder should use a plain readonly field outside file input.'); - assertReadonlyTextFit(ui.controls.longStatus); - assertSectionsDoNotOverlapResizeHandles(ui); - assertTextPanelsHaveDefaultRoom(ui); - assertDefaultResizeHandleDrags(ui); - - ui.controls.sourceImages.chooseButton.ButtonPushedFcn(ui.controls.sourceImages.chooseButton, []); - assert(isequal(ui.controls.sourceImages.listbox.Items, dialogPaths), ... - 'filePanel choose should populate numbered file labels.'); - assert(strcmp(ui.controls.sourceImages.listbox.Multiselect, 'off') && ... - strcmp(ui.controls.sourceImages.listbox.Value, dialogPaths{1}), ... - 'filePanel selectionMode single should keep a single current selection.'); - assert(strcmp(char(string(ui.controls.sourceImages.status.Value)), '2 files'), ... - 'filePanel choose should update status text.'); - assert(~isempty(events) && strcmp(events{end}.id, 'sourceImages') && strcmp(events{end}.action, 'choose') && ... - isequal(testui.control.filePaths(events{end}.files), firstDialogPaths) && ... - isequal(testui.control.filePaths(events{end}.addedFiles), firstDialogPaths) && ... - isequal(testui.control.filePaths(events{end}.selectedFiles), firstDialogPaths(1)), ... - 'filePanel choose should report file entries and the 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(testui.control.filePaths(events{end}.value), firstDialogPaths(2)), ... - 'filePanel selection changes should emit selected file entries.'); - assertThrows(@() testui.control.setListItems(ui, 'sourceImages', ... - {'Alpha', 'Beta', 'Gamma'}), ... - 'labkit:ui:control:FilePanelListItems', ... - 'filePanel list labels should be owned by the framework.'); - - ui.controls.sourceImages.clearButton.ButtonPushedFcn(ui.controls.sourceImages.clearButton, []); - assert(strcmp(char(string(ui.controls.sourceImages.status.Value)), 'No images loaded'), ... - 'filePanel clear should restore the configured empty status.'); - assert(strcmp(ui.controls.sourceImages.listbox.Items{1}, 'No selection'), ... - 'filePanel clear should restore the placeholder item.'); - assert(strcmp(ui.controls.sourceImages.listbox.Value, 'No selection'), ... - 'filePanel clear should select the placeholder item.'); - - ui.controls.sourceImages.folderButton.ButtonPushedFcn(ui.controls.sourceImages.folderButton, []); - assert(isequal(testui.control.filePaths(events{end}.files), directFolderPath) && ... - folderWarningCalls == 0, ... - 'Add folder should include supported files directly under the chosen folder only.'); - ui.controls.sourceImages.clearButton.ButtonPushedFcn(ui.controls.sourceImages.clearButton, []); - ui.controls.sourceImages.recursiveFolderButton.ButtonPushedFcn( ... - ui.controls.sourceImages.recursiveFolderButton, []); - recursivePaths = sort([directFolderPath; secondDialogPaths]); - assert(isequal(sort(testui.control.filePaths(events{end}.files)), recursivePaths) && ... - folderWarningCalls == 1 && folderWarningCount == 2, ... - ['Add folder tree should include supported files below the chosen folder ' ... - 'and apply the large-folder guard once.']); - ui.controls.sourceImages.clearButton.ButtonPushedFcn(ui.controls.sourceImages.clearButton, []); - - ui.controls.fileQueue.chooseButton.ButtonPushedFcn(ui.controls.fileQueue.chooseButton, []); - fileItems = string(ui.controls.fileQueue.listbox.Items); - assert(startsWith(fileItems(1), '01 same.png') && ... - contains(fileItems(1), '(alpha)') && ... - contains(fileItems(2), '(beta)'), ... - 'filePanel choose should display numbered short labels with parent disambiguators.'); - assert(strcmp(events{end}.id, 'fileQueue') && strcmp(events{end}.action, 'choose') && ... - isequal(testui.control.filePaths(events{end}.files), duplicateDialogPaths), ... - 'filePanel choose should report full current file entries.'); - ui.controls.fileQueue.listbox.Value = char(fileItems(2)); - ui.controls.fileQueue.listbox.ValueChangedFcn(ui.controls.fileQueue.listbox, []); - assert(strcmp(events{end}.action, 'select') && ... - isequal(testui.control.filePaths(events{end}.value), duplicateDialogPaths(2)), ... - 'filePanel selection should map visible file labels to file entries.'); - ui.controls.fileQueue.removeButton.ButtonPushedFcn(ui.controls.fileQueue.removeButton, []); - assert(strcmp(events{end}.action, 'remove') && ... - isequal(testui.control.filePaths(events{end}.removedFiles), duplicateDialogPaths(2)) && ... - isequal(testui.control.filePaths(events{end}.files), duplicateDialogPaths(1)), ... - 'filePanel remove should report removed and remaining file entries.'); - ui.controls.fileQueue.clearButton.ButtonPushedFcn(ui.controls.fileQueue.clearButton, []); - assert(strcmp(char(string(ui.controls.fileQueue.status.Value)), 'No files loaded') && ... - strcmp(ui.controls.fileQueue.listbox.Items{1}, 'No files loaded'), ... - 'filePanel clear should restore the empty prompt.'); - - ui.controls.singleFile.chooseButton.ButtonPushedFcn(ui.controls.singleFile.chooseButton, []); - assert(strcmp(char(string(ui.controls.singleFile.displayField.Value)), 'first.png') && ... - ~isfield(ui.controls.singleFile, 'listbox') && ... - ~isfield(ui.controls.singleFile, 'removeButton') && ... - ~isfield(ui.controls.singleFile, 'clearButton'), ... - 'single-mode filePanel should expose one read-only filename field only.'); - assert(strcmp(events{end}.id, 'singleFile') && ... - isequal(testui.control.filePaths(events{end}.files), singleDialogPaths(1)), ... - 'single-mode filePanel should emit the selected file entry.'); - ui.controls.singleFile.chooseButton.ButtonPushedFcn(ui.controls.singleFile.chooseButton, []); - assert(strcmp(char(string(ui.controls.singleFile.displayField.Value)), 'second.png') && ... - isequal(testui.control.filePaths(events{end}.files), singleDialogPaths(2)), ... - 'single-mode filePanel should replace the previous file on choose.'); - - testui.control.setValue(ui, 'gain', 4); - assert(testui.control.getValue(ui, 'gain') == 4, ... - 'setValue/getValue should target controls by semantic id.'); - testui.control.setValue(ui, 'displayLimits', [0.2 0.8]); - assert(isequal(testui.control.getValue(ui, 'displayLimits'), [0.2 0.8]), ... - 'rangeField values should round-trip through named helpers.'); - testui.control.setLimits(ui, 'unboundedPan', [0 Inf]); - testui.control.setValue(ui, 'unboundedPan', 12); - assert(isempty(ui.controls.unboundedPan.slider) && ... - ui.controls.unboundedPan.valueSpinner.Value == 12 && ... - testui.control.getValue(ui, 'unboundedPan') == 12, ... - 'panner should support spinner-only infinite limits for numeric inputs.'); - previousEventCount = numel(events); - testui.control.setLimits(ui, 'pan', [0 2]); - assert(isequal(ui.controls.pan.handle.Limits, [0 2]) && ... - isequal(ui.controls.pan.slider.Limits, [0 2]) && ... - isequal(ui.controls.pan.valueSpinner.Limits, [0 2]) && ... - ui.controls.pan.slider.Value == 2 && ... - ui.controls.pan.valueSpinner.Value == 2, ... - 'setLimits should update panner slider/spinner limits and clamp current value.'); - testui.control.setLimits(ui, 'displayLimits', [0.3 0.6]); - assert(isequal(ui.controls.displayLimits.startHandle.Limits, [0.3 0.6]) && ... - isequal(ui.controls.displayLimits.endHandle.Limits, [0.3 0.6]) && ... - isequal(testui.control.getValue(ui, 'displayLimits'), [0.3 0.6]), ... - 'setLimits should update both handles of a rangeField.'); - assert(numel(events) == previousEventCount, ... - 'setLimits should suppress synchronous value-change callbacks.'); - ui.controls.pan.valueSpinner.Value = 1.5; - ui.controls.pan.valueSpinner.ValueChangedFcn(ui.controls.pan.valueSpinner, ... - struct('PreviousValue', 2)); - h.waitForUiIdle(ui.figure); - assert(strcmp(events{end}.id, 'pan') && ... - strcmp(events{end}.kind, 'panner') && ... - strcmp(events{end}.action, 'edit') && ... - ui.controls.pan.slider.Value == 1.5 && ... - testui.control.getValue(ui, 'pan') == 1.5, ... - 'panner spinner edits should update the linked slider and emit one semantic event.'); - eventCountBeforeDrag = numel(events); - ui.controls.pan.slider.ValueChangingFcn(ui.controls.pan.slider, ... - struct('Value', 1.25)); - h.waitForUiIdle(ui.figure); - assert(numel(events) == eventCountBeforeDrag + 1 && ... - ui.controls.pan.valueSpinner.Value == 1.25 && ... - strcmp(events{end}.action, 'slide') && ... - testui.control.getValue(ui, 'pan') == 1.25, ... - 'panner slider drags should sync the numeric readout and emit a debounced semantic event.'); - eventCountBeforeRelease = numel(events); - ui.controls.pan.slider.Value = 1.25; - ui.controls.pan.slider.ValueChangedFcn(ui.controls.pan.slider, struct()); - h.waitForUiIdle(ui.figure); - assert(numel(events) == eventCountBeforeRelease + 1 && ... - strcmp(events{end}.action, 'slide') && ... - testui.control.getValue(ui, 'pan') == 1.25, ... - 'panner slider release should submit one semantic value-change event.'); - testui.control.setValue(ui, 'results', {"Alpha", "ok"; "Beta", string("ready")}); - assert(ischar(ui.controls.results.table.Data{2, 2}) && ... - strcmp(ui.controls.results.table.Data{2, 2}, 'ready'), ... - 'resultTable should normalize string cell data for MATLAB uitable.'); - testui.control.setEnabled(ui, 'run', false); - assert(strcmp(ui.controls.run.button.Enable, 'off'), ... - 'setEnabled should update action buttons.'); - testui.control.setValue(ui, 'sourceImages', ["a.png"; "b.png"]); - sourceFiles = testui.control.getFiles(ui, 'sourceImages'); - testui.control.setFileSelection(ui, 'sourceImages', sourceFiles(2)); - selection = testui.control.filePaths(testui.control.getValue(ui, 'sourceImages')); - assert(isequal(selection, "b.png"), ... - 'File helpers should apply semantic filePanel selection.'); - malformedFiles = struct('id', "", 'path', "", 'status', ""); - malformedFiles(1).id = strings(0, 1); - malformedFiles(1).path = string(fullfile(tempdir, 'folder_scan_a.png')); - malformedFiles(1).status = "needs center"; - malformedFiles(2).id = ["file1", "extra"]; - malformedFiles(2).path = string(fullfile(tempdir, 'folder_scan_b.png')); - malformedFiles(2).status = ["ready", "ignored"]; - testui.control.setValue(ui, 'sourceImages', malformedFiles); - normalizedFiles = testui.control.getFiles(ui, 'sourceImages'); - normalizedIds = string({normalizedFiles.id}).'; - normalizedStatus = string({normalizedFiles.status}).'; - assert(isequal(normalizedIds, ["file1"; "file2"]) && ... - isequal(normalizedStatus, ["needs center"; "ready"]), ... - 'filePanel setValue should scalarize malformed entry ids/status and regenerate duplicates.'); - testui.control.appendLog(ui, 'logPanel', 'Completed.'); - assert(any(contains(string(ui.controls.logPanel.textArea.Value), 'Completed.')), ... - 'appendLog should append to the requested log panel.'); - testui.plot.image(ui, 'preview', zeros(8, 8, 3, 'uint8'), ... - 'axis', 'filtered', 'title', 'Filtered'); - testui.plot.reset(ui, 'preview', 'Raw', true, 'raw'); - testui.plot.clearPreview(ui, 'preview', 'difference'); - testui.control.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, []); - h.waitForUiIdle(ui.figure); - 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(~) - dialogCalls = dialogCalls + 1; - assert(dialogCalls == 1, ... - 'The file dialog provider should be called only for Add file.'); - paths = firstDialogPaths; - end - - function folder = folderDialogProvider(~) - folder = string(recursiveFolder); - end - - function tf = folderWarningProvider(~, fileCount, threshold) - folderWarningCalls = folderWarningCalls + 1; - folderWarningCount = fileCount; - tf = fileCount > threshold; - end - - function paths = duplicateDialogProvider(~) - duplicateDialogCalls = duplicateDialogCalls + 1; - assert(duplicateDialogCalls == 1, ... - 'Duplicate-file dialog provider should be called only once in this probe.'); - paths = duplicateDialogPaths; - end - - function paths = singleDialogProvider(~) - singleDialogCalls = singleDialogCalls + 1; - paths = singleDialogPaths(min(singleDialogCalls, numel(singleDialogPaths))); - end -end - -function assertTextPanelsHaveDefaultRoom(ui) - settleLayout(ui.figure); - assertStatusPanelContract(ui.controls.notesText); - ui.logTab.Parent.SelectedTab = ui.logTab; - settleLayout(ui.figure); - assertLogPanelContract(ui.controls.logPanel); - assertLogTabFillsAvailableHeight(ui); - ui.setupTab.Parent.SelectedTab = ui.setupTab; - settleLayout(ui.figure); - assertMultiFilePanelContract(ui.controls.sourceImages); - assertMultiFilePanelContract(ui.controls.fileQueue); - assertSingleFilePanelContract(ui.controls.singleFile); -end - -function noop(varargin) -end - -function assertStatusPanelContract(control) - assert(isvalid(control.textArea) && strcmp(control.kind, 'statusPanel'), ... - 'statusPanel controls should expose a valid read-only text area.'); -end - -function assertLogPanelContract(control) - assert(isvalid(control.textArea) && strcmp(control.kind, 'logPanel'), ... - 'logPanel controls should expose a valid read-only text area.'); -end - -function assertReadonlyTextFit(control) - assert(isvalid(control.handle) && isprop(control.handle, 'Value'), ... - 'Readonly field values should render through a text-bearing handle.'); - assert(strcmp(char(string(control.handle.Value)), ... - 'Long status text should wrap and shrink inside the control instead of being clipped'), ... - 'Readonly field text should preserve the complete display value.'); - if isprop(control.handle, 'WordWrap') - assert(strcmp(control.handle.WordWrap, 'on'), ... - 'Readonly field text should wrap by default when MATLAB supports WordWrap.'); - end - assert(control.handle.FontSize <= 12 && control.handle.FontSize >= 10, ... - 'Readonly field text should shrink within the framework readability range.'); -end - -function assertLogTabFillsAvailableHeight(ui) - logicalRowMap = ui.logGrid.UserData.LabKitLogicalRowMap; - logRowHeight = ui.logGrid.RowHeight{logicalRowMap(1)}; - assert(strcmp(char(string(logRowHeight)), '1x'), ... - 'A single growable log section should fill the log tab height.'); - assert(isequal(ui.sections.logSection.grid.RowHeight, {'1x'}), ... - 'A logPanel section should let the log text area grow vertically.'); -end - -function assertMultiFilePanelContract(control) - assert(isvalid(control.listbox) && strcmp(control.kind, 'filePanel') && ... - isvalid(control.folderButton) && ... - isvalid(control.recursiveFolderButton) && ... - isvalid(control.removeButton), ... - ['filePanel controls should expose direct file/folder actions, a ' ... - 'selectable list, and a remove button.']); -end -function assertSingleFilePanelContract(control) - assert(strcmp(control.kind, 'filePanel') && ... - isvalid(control.displayField) && isvalid(control.chooseButton) && ... - ~isfield(control, 'listbox') && ~isfield(control, 'removeButton') && ... - ~isfield(control, 'clearButton'), ... - 'single-mode filePanel controls should expose only choose and display handles.'); - assert(strcmp(control.displayField.Type, 'uieditfield'), ... - 'single-mode filePanel should use a read-only single-line text field.'); - assert(control.chooseButton.Position(4) <= 36 && control.displayField.Position(4) <= 36, ... - 'single-mode filePanel controls should not stretch vertically with the section.'); - assert(isequal(control.chooseButton.Parent, control.displayField.Parent) && ... - isequal([control.chooseButton.Layout.Row control.chooseButton.Layout.Column], [1 1]) && ... - isequal([control.displayField.Layout.Row control.displayField.Layout.Column], [1 2]), ... - 'single-mode filePanel controls should share a row with choose before filename.'); - columns = control.chooseButton.Parent.ColumnWidth; - assert(numel(columns) == 2 && isnumeric(columns{1}) && ... - columns{1} <= 160 && strcmp(char(string(columns{2})), '1x'), ... - 'single-mode filePanel should reserve a fixed action column and a growable filename column.'); - rowHeight = control.panel.Parent.RowHeight{control.panel.Layout.Row}; - assert(isnumeric(rowHeight) && rowHeight <= 72, ... - 'single-mode filePanel should use a compact section row height.'); -end -function settleLayout(fig) - timeoutSeconds = 0.5; - stablePassesNeeded = 2; - drawnow; - previous = layoutSignature(fig); - stablePasses = 0; - startTime = tic; - while isvalid(fig) && toc(startTime) < timeoutSeconds && ... - stablePasses < stablePassesNeeded - pause(0.025); - drawnow; - current = layoutSignature(fig); - if isequal(current, previous) - stablePasses = stablePasses + 1; - else - stablePasses = 0; - previous = current; - end - end - drawnow; -end - -function signature = layoutSignature(fig) - objects = findall(fig, '-property', 'Position'); - signature = zeros(numel(objects), 4); - for k = 1:numel(objects) - try - position = objects(k).Position; - catch - continue; - end - if isnumeric(position) && numel(position) == 4 - signature(k, :) = round(double(position(:).') .* 10) ./ 10; - end - end -end - -function assertDefaultResizeHandleDrags(ui) - grid = ui.setupGrid; - for k = 1:numel(ui.setupResizeHandles) - resizeHandle = ui.setupResizeHandles(k); - assert(resizeHandle.Position(4) >= 5, ... - 'Resize handle should expose a visible, clickable height.'); - resizeHandle.ButtonDownFcn(resizeHandle, struct('CurrentPoint', [0 300])); - assert(~isempty(ui.figure.WindowButtonMotionFcn) && ... - ~isempty(ui.figure.WindowButtonUpFcn), ... - 'Resize handle press should install figure drag callbacks.'); - - if k == 1 - assertFilePanelRowCanGrowWithSection(ui); - end - ui.figure.WindowButtonMotionFcn(ui.figure, struct('CurrentPoint', [0 240])); - rowHeight = grid.RowHeight; - topRow = resizeHandle.Layout.Row - 1; - assert(isnumeric(rowHeight{topRow}), ... - 'Resize handle drag should convert the section above to fixed height.'); - assert(rowHeight{topRow} > 80, ... - 'Resize handle drag should resize only the section above while preserving minimum height.'); - - ui.figure.WindowButtonUpFcn(ui.figure, struct()); - assert(isempty(ui.figure.WindowButtonMotionFcn) && ... - isempty(ui.figure.WindowButtonUpFcn), ... - 'Resize handle release should clear temporary figure drag callbacks.'); - assert(strcmp(ui.figure.Pointer, 'arrow'), ... - 'Resize handle release should restore the default pointer.'); - - ui.figure.WindowButtonDownFcn = @beforeResizePointerDown; - ui.figure.WindowKeyPressFcn = @beforeResizeKeyPress; - resizeHandle.ButtonDownFcn(resizeHandle, struct('CurrentPoint', [0 300])); - cancelCallback = ui.figure.WindowButtonDownFcn; - cancelCallback(ui.figure, struct()); - assert(isempty(ui.figure.WindowButtonMotionFcn) && ... - isempty(ui.figure.WindowButtonUpFcn), ... - 'Resize cancel should clear temporary motion and release callbacks.'); - assert(sameCallback(ui.figure.WindowButtonDownFcn, @beforeResizePointerDown) && ... - sameCallback(ui.figure.WindowKeyPressFcn, @beforeResizeKeyPress), ... - 'Resize cancel should restore prior pointer and key callbacks.'); - assert(strcmp(ui.figure.Pointer, 'arrow'), ... - 'Resize cancel should restore the default pointer.'); - - resizeHandle.ButtonDownFcn(resizeHandle, struct('CurrentPoint', [0 300])); - keyCallback = ui.figure.WindowKeyPressFcn; - keyCallback(ui.figure, struct('Key', 'escape')); - assert(isempty(ui.figure.WindowButtonMotionFcn) && ... - isempty(ui.figure.WindowButtonUpFcn), ... - 'Escape should cancel a stuck resize drag.'); - end - - function beforeResizePointerDown(~, ~) - end - - function beforeResizeKeyPress(~, ~) - end -end - -function assertFilePanelRowCanGrowWithSection(ui) - fileRowHeight = ui.sections.inputs.grid.RowHeight{1}; - assert(strcmp(char(string(fileRowHeight)), '1x'), ... - ['filePanel row inside mixed sections should be growable so ' ... - 'section resize space flows into the file list, found %s.'], ... - char(string(fileRowHeight))); -end - -function assertSectionsDoNotOverlapResizeHandles(ui) - handleRows = arrayfun(@(h) h.Layout.Row, ui.setupResizeHandles); - panels = directSectionPanels(ui.setupGrid, ui.setupResizeHandles); - sectionPanelCount = 0; - for k = 1:numel(panels) - row = panels(k).Layout.Row; - if any(handleRows == row) - error('Section panels should not overlap physical resize-handle rows.'); - end - sectionPanelCount = sectionPanelCount + 1; - end - assert(sectionPanelCount >= 3, ... - 'Declarative test probe should include at least three section panels.'); -end - -function panels = directSectionPanels(grid, resizeHandles) - panels = gobjects(0); - children = grid.Children; - for k = 1:numel(children) - if isa(children(k), 'matlab.ui.container.Panel') && ... - ~any(children(k) == resizeHandles) - panels(end + 1) = children(k); - end - end -end - -function tf = sameCallback(actual, expected) - tf = isa(actual, 'function_handle') && ... - isa(expected, 'function_handle') && ... - strcmp(func2str(actual), func2str(expected)); -end - -function writeEmptyFile(filepath) - fid = fopen(char(filepath), 'w'); - assert(fid > 0, 'Failed to create test file: %s', char(filepath)); - cleaner = onCleanup(@() fclose(fid)); - clear cleaner; -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 removeTempFolder(folder) - if isfolder(folder) - rmdir(folder, 's'); - end -end - -function cleanup = setGuiTestModeForTest(mode) - previous = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', char(mode)); - cleanup = onCleanup(@() setenv('LABKIT_GUI_TEST_MODE', previous)); -end diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiPlotHelpersTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiPlotHelpersTest.m deleted file mode 100644 index b3fa0297b..000000000 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiPlotHelpersTest.m +++ /dev/null @@ -1,68 +0,0 @@ -classdef GuiLayoutUiPlotHelpersTest < matlab.unittest.TestCase - %GUILAYOUTUIPLOTHELPERSTEST Verify declarative plot helper contracts. - - methods (Test, TestTags = {'GUI', 'Structural'}) - function test_gui_layout_ui_plot_helpers(testCase) - setupLabKitTestPath(); - verify_gui_layout_ui_plot_helpers(); - end - end -end - -function verify_gui_layout_ui_plot_helpers() -%TEST_GUI_LAYOUT_UI_PLOT_HELPERS Verify reusable plot clearing, fitting, and coordinates. - - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - - layout = labkit.ui.layout.workbench('plotHelperProbe', 'Plot Helper Probe', ... - 'controlTabs', { ... - labkit.ui.layout.tab('setup', 'Setup', { ... - labkit.ui.layout.section('actions', 'Actions', { ... - labkit.ui.layout.action('noop', 'No-op', @noop)})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.previewArea('preview', 'Preview', ... - 'layout', 'single', ... - 'axisIds', {'main'}, ... - 'axisTitles', {'Main Plot'})})); - ui = labkit.ui.runtime.create(layout); - - ax = testui.plot.getAxes(ui, 'preview', 'main'); - assert(isequal(ax, ui.controls.preview.axesById.main), ... - 'plot.getAxes should resolve a semantic preview axes id.'); - - mainLine = plot(ax, [0 10], [0 20], 'DisplayName', 'main'); - xline(ax, 1000, '--', 'far annotation'); - limits = labkit.ui.plot.fit(ax, mainLine, 'Padding', 0); - assert(isequal(limits.x, [0 10]) && isequal(limits.y, [0 20]), ... - 'plot.fit should use caller-provided data handles instead of annotation handles.'); - - ax.XLim = [1 100]; - ax.YLim = [-10 10]; - ax.XScale = 'log'; - ax.YDir = 'reverse'; - offsetXY = labkit.ui.plot.offsetData(ax, [10 0], [0.1 -0.1]); - assert(max(abs(offsetXY - [10^1.2 2])) < 1e-12, ... - 'plot.offsetData should apply visual axes-fraction offsets.'); - clampedXY = labkit.ui.plot.clampData(ax, [0.01 100], 'Padding', 0.1); - assert(clampedXY(1) >= 10^0.2 - eps && ... - clampedXY(1) <= 10^1.8 + eps && abs(clampedXY(2)) <= 8 + eps, ... - 'plot.clampData should keep labels inside the visible axes area.'); - - labkit.ui.plot.message(ax, 'No data', 'Title', 'Empty'); - assert(isempty(findobj(ax, 'Type', 'line')) && isequal(ax.XLim, [0 1]) && ... - isequal(ax.YLim, [0 1]) && strcmp(char(ax.Title.String), 'Empty'), ... - 'plot.message should clear the axes and install an empty-state viewport.'); - - plot(ax, [1 2 3], [3 2 1], 'DisplayName', 'clear me'); - legend(ax, 'show'); - labkit.ui.plot.clear(ax, 'ResetScale', true); - assert(isempty(ax.Children) && strcmp(ax.XLimMode, 'auto') && ... - strcmp(ax.YLimMode, 'auto') && strcmp(ax.XScale, 'linear') && ... - strcmp(ax.YScale, 'linear'), ... - 'plot.clear should remove graphics and restore automatic linear scaling.'); - - function noop(varargin) - end -end diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2InteractionHubTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2InteractionHubTest.m deleted file mode 100644 index 5b1019c30..000000000 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2InteractionHubTest.m +++ /dev/null @@ -1,559 +0,0 @@ -classdef GuiLayoutUiRuntimeV2InteractionHubTest < matlab.unittest.TestCase - %GUILAYOUTUIRUNTIMEV2INTERACTIONHUBTEST Verify figure-scoped V2 routing. - - methods (Test, TestTags = {'GUI', 'Structural'}) - function hub_routes_targets_groups_and_drag_cleanup(testCase) - setupLabKitTestPath(); - verifyHubMechanics(); - end - - function controlled_interactions_suppress_programmatic_events(testCase) - setupLabKitTestPath(); - verifyControlledInteraction(); - end - - function controlled_rectangle_receives_hit_graphic(testCase) - setupLabKitTestPath(); - verifyControlledRectangleHitRouting(); - end - - function paired_anchor_cell_payload_stays_one_semantic_event(testCase) - setupLabKitTestPath(); - verifyPairedAnchorCellPayload(); - end - - function controlled_interval_routes_semantic_wheel_events(testCase) - setupLabKitTestPath(); - verifyControlledInterval(); - end - - function controlled_region_selection_registers_transient_gesture(testCase) - setupLabKitTestPath(); - verifyControlledRegionSelection(); - end - - function controlled_point_slots_preserve_fixed_indices(testCase) - setupLabKitTestPath(); - verifyControlledPointSlots(); - end - end -end - -function verifyControlledRectangleHitRouting() - h = guiTestHelpers(); - h.assertUifigureAvailable(); - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', 'hidden'); - cleanupMode = onCleanup(@() setenv('LABKIT_GUI_TEST_MODE', oldMode)); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - fig = labkit.ui.runtime.launch(@rectangleDefinition); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - resource = interactionResource(runtime.resources, "cropRectangle"); - graphics = resource.editors{1}.graphics(); - box = graphics(find(arrayfun(@(item) isa(item, ... - 'matlab.graphics.primitive.Rectangle'), graphics), 1, 'first')); - assert(~isempty(box), ... - 'A controlled rectangle should expose its editable box graphic.'); - - ui = getappdata(fig, 'labkitUiRegistry'); - ax = ui.controls.image.primaryAxes; - assertAxesContextMenuSurvivesRenderer(ax); - ax.XLim = [25 75]; - ax.YLim = [20 70]; - expectedView = [ax.XLim ax.YLim]; - runtime.interactionHub.dispatch("cropRectMoved", ... - "cropRectangle", [30 30 40 40], "commit"); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCaseView = [ax.XLim ax.YLim]; - assert(isequal(testCaseView, expectedView) && ... - isequal(runtime.state.project.annotations.cropRect, [30 30 40 40]), ... - 'An overlay edit should redraw its renderer without resetting zoom.'); - resource = interactionResource(runtime.resources, "cropRectangle"); - graphics = resource.editors{1}.graphics(); - box = graphics(find(arrayfun(@(item) isa(item, ... - 'matlab.graphics.primitive.Rectangle'), graphics), 1, 'first')); - assertAxesContextMenuSurvivesRenderer(ax); - - fig.WindowButtonDownFcn(fig, struct('HitObject', box)); - assert(runtime.interactionHub.isDragging(), ... - 'The figure hub must route the hit rectangle graphic into a drag session.'); - fig.WindowButtonUpFcn(fig, struct()); - assert(~runtime.interactionHub.isDragging(), ... - 'Releasing the pointer should finish the rectangle drag session.'); - delete(fig); - clear cleanupFigures cleanupMode; -end - -function assertAxesContextMenuSurvivesRenderer(ax) - menu = ax.ContextMenu; - assert(~isempty(menu) && isvalid(menu) && ... - ~isempty(findall(menu, 'Type', 'uimenu', ... - 'Tag', 'labkitAxesPopoutMenu')), ... - ['A renderer that resets its axes must not remove the framework ' ... - 'right-click popout action.']); - imageHandle = findobj(ax, 'Type', 'image'); - assert(~isempty(imageHandle) && isequal(imageHandle(1).ContextMenu, menu), ... - 'Rendered image children should route right-clicks to the axes menu.'); -end - -function verifyControlledPointSlots() - h = guiTestHelpers(); - h.assertUifigureAvailable(); - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', 'hidden'); - cleanupMode = onCleanup(@() setenv('LABKIT_GUI_TEST_MODE', oldMode)); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - fig = labkit.ui.runtime.launch(@pointSlotsDefinition); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - resource = interactionResource(runtime.resources, "slots"); - ui = getappdata(fig, 'labkitUiRegistry'); - assert(contains(string(ui.controls.image.primaryAxes.Subtitle.String), ... - 'place the selected marker'), ... - 'Point slots should explain placement directly on the preview.'); - resource.editors{1}.insertPoint([30 40]); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - value = runtime.state.project.annotations.slots; - assert(isequal(value.points(2, :), [30 40]) && ... - value.selectedIndex == 3 && value.changedIndex == 2 && ... - value.reason == "place", ... - 'Point slots should fill the selected empty slot and retain its index.'); - resource = interactionResource(runtime.resources, "slots"); - resource.editors{1}.insertPoint([45 50]); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - resource = interactionResource(runtime.resources, "slots"); - resource.editors{1}.insertPoint([60 65]); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - value = runtime.state.project.annotations.slots; - assert(isequal(value.points(3, :), [60 65]), ... - 'Configured point slots should replace the selected point on background clicks.'); - delete(fig); - clear cleanupFigures cleanupMode; -end - -function verifyPairedAnchorCellPayload() - h = guiTestHelpers(); - h.assertUifigureAvailable(); - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', 'hidden'); - cleanupMode = onCleanup(@() setenv('LABKIT_GUI_TEST_MODE', oldMode)); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - fig = labkit.ui.runtime.launch(@pairedDefinition); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - resource = interactionResource(runtime.resources, "pointPairs"); - ui = getappdata(fig, 'labkitUiRegistry'); - leftAxes = ui.controls.pair.axesById.left; - assert(contains(string(leftAxes.Subtitle.String), ... - 'Click each preview in matching order'), ... - 'Paired anchors should explain their corresponding-click workflow.'); - leftAxes.XLim = [0.5 100.5]; - leftAxes.YLim = [0.5 100.5]; - beforeZoom = [leftAxes.XLim leftAxes.YLim]; - runtime.interactionHub.routeWheel("pair.left", ... - struct("VerticalScrollCount", -1, "Point", [50 50])); - assert(~isequal(beforeZoom, [leftAxes.XLim leftAxes.YLim]), ... - 'A paired-anchor editor should route wheel input to shared image zoom.'); - resource.editors{1}.insertPoint([30 40]); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(size(runtime.state.project.annotations.referencePoints, 1) == 1 && ... - isempty(runtime.state.project.annotations.movingPoints) && ... - runtime.state.project.annotations.pairEditCount == 1, ... - 'The first paired-anchor edit should commit one scalar cell-valued event.'); - resource = interactionResource(runtime.resources, "pointPairs"); - resource.editors{2}.insertPoint([32 41]); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(size(runtime.state.project.annotations.referencePoints, 1) == 1 && ... - size(runtime.state.project.annotations.movingPoints, 1) == 1 && ... - runtime.state.project.annotations.pairEditCount == 2 && ... - ~runtime.processing && isempty(runtime.queue), ... - 'A complete point pair should commit without expanding the event struct.'); - delete(fig); - clear cleanupFigures cleanupMode; -end - -function verifyControlledRegionSelection() - h = guiTestHelpers(); - h.assertUifigureAvailable(); - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', 'hidden'); - cleanupMode = onCleanup(@() setenv('LABKIT_GUI_TEST_MODE', oldMode)); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - fig = labkit.ui.runtime.launch(@regionDefinition); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - resource = interactionResource(runtime.resources, "region"); - assert(resource.spec.Kind == "regionSelection" && ... - resource.spec.Event == "regionSelected" && ... - resource.spec.BackgroundEvent == "pointSelected", ... - 'Region selection should normalize drag and click semantic events.'); - assert(isfield(resource.editors{1}, 'delete') && ... - ~isfield(resource.editors{1}, 'setPosition'), ... - 'Transient region selection must not expose a durable ROI editor.'); - delete(fig); - clear cleanupFigures cleanupMode; -end - -function verifyControlledInterval() - h = guiTestHelpers(); - h.assertUifigureAvailable(); - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', 'hidden'); - cleanupMode = onCleanup(@() setenv('LABKIT_GUI_TEST_MODE', oldMode)); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - fig = labkit.ui.runtime.launch(@intervalDefinition); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - resource = interactionResource(runtime.resources, "timeRange"); - assert(resource.spec.Kind == "interval" && ... - resource.spec.ScrollEvent == "windowScrolled", ... - 'Interval interactions should normalize their semantic wheel event.'); - runtime.interactionHub.routeWheel("image", ... - struct("VerticalScrollCount", 1)); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.state.project.annotations.scrollCount == 1, ... - 'A routed interval wheel gesture should enqueue one semantic event.'); - delete(fig); - clear cleanupFigures cleanupMode; -end - -function verifyHubMechanics() - h = guiTestHelpers(); - h.assertUifigureAvailable(); - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', 'hidden'); - cleanupMode = onCleanup(@() setenv('LABKIT_GUI_TEST_MODE', oldMode)); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - fig = labkit.ui.runtime.launch(@hubDefinition); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - ui = getappdata(fig, 'labkitUiRegistry'); - hub = runtime.interactionHub; - assert(isequal(sort(hub.targetIds()), ... - sort(["pair.left", "pair.right", "third"])), ... - 'Every preview axis should register one semantic hub target.'); - - leftCount = 0; - rightCount = 0; - left = hub.adapter("pair.left", "paired"); - right = hub.adapter("pair.right", "paired"); - leftSession = left.createSession(struct( ... - "onScroll", @(~, ~) incrementLeft())); - rightSession = right.createSession(struct( ... - "onScroll", @(~, ~) incrementRight())); - leftSession.activate(); - assert(leftSession.isActive() && rightSession.isActive(), ... - 'Activating a grouped session should acquire every registered target.'); - event = struct("VerticalScrollCount", -1, "Point", [50 50]); - hub.routeWheel("pair.left", event); - hub.routeWheel("pair.right", event); - assert(leftCount == 1 && rightCount == 1, ... - 'Wheel input should route directly to the hovered semantic target.'); - - thirdAxes = ui.controls.third.primaryAxes; - thirdAxes.XLim = [0 100]; - thirdAxes.YLim = [0 100]; - beforeLimits = [thirdAxes.XLim thirdAxes.YLim]; - hub.routeWheel("third", event); - assert(~isequal(beforeLimits, [thirdAxes.XLim thirdAxes.YLim]), ... - 'An unclaimed preview target should retain hub-owned default zoom.'); - afterZoom = [thirdAxes.XLim thirdAxes.YLim]; - hub.routeWheel("", event); - assert(isequal(afterZoom, [thirdAxes.XLim thirdAxes.YLim]), ... - 'Controls and empty figure areas must not consume preview zoom.'); - - leftSession.deactivate(); - assert(~leftSession.isActive() && ~rightSession.isActive() && ... - strlength(hub.activeGroup()) == 0, ... - 'Releasing one grouped session should release the group atomically.'); - - leftSession.activate(); - stableMotion = fig.WindowButtonMotionFcn; - leftSession.captureDrag(@failDrag, []); - assertThrows(@() stableMotion(fig, struct()), 'hubProbe:DragFailure'); - assert(isequal(fig.WindowButtonMotionFcn, stableMotion), ... - 'Drag errors must preserve the hub-owned figure callback.'); - stableMotion(fig, struct()); - - delete(thirdAxes); - third = hub.adapter("third", "thirdGroup"); - thirdSession = third.createSession(struct()); - assertThrows(@() thirdSession.activate(), ... - 'labkit:ui:runtime:InvalidInteractionTarget'); - hub.delete(); - hub.delete(); - delete(fig); - clear cleanupFigures cleanupMode; - - function incrementLeft() - leftCount = leftCount + 1; - end - - function incrementRight() - rightCount = rightCount + 1; - end -end - -function verifyControlledInteraction() - h = guiTestHelpers(); - h.assertUifigureAvailable(); - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', 'hidden'); - cleanupMode = onCleanup(@() setenv('LABKIT_GUI_TEST_MODE', oldMode)); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - fig = labkit.ui.runtime.launch(@controlledDefinition); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.state.project.annotations.editCount == 0, ... - 'Programmatic controlled-tool synchronization must not emit edits.'); - resource = interactionResource(runtime.resources, "editPoints"); - assert(isfield(resource.spec, 'BackgroundEvent') && ... - strlength(resource.spec.BackgroundEvent) == 0, ... - 'Controlled interactions should normalize an optional semantic background event.'); - resource.editors{1}.insertPoint([30 30]); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.state.project.annotations.editCount == 1 && ... - size(runtime.state.project.annotations.points, 1) == 3, ... - 'A user tool edit should enqueue exactly one configured semantic event.'); - ui = getappdata(fig, 'labkitUiRegistry'); - delete(ui.controls.image.primaryAxes); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(~hasInteractionResource(runtime.resources, "editPoints"), ... - 'Deleting a semantic target should dispose its controlled resource.'); - delete(fig); - clear cleanupFigures cleanupMode; -end - -function tf = hasInteractionResource(resources, id) - tf = ~isempty(resources) && any([resources.scope] == "interaction" & ... - [resources.id] == string(id)); -end - -function def = hubDefinition() - def = definitionBase(@hubLayout, struct("noop", @noop), @hubPresentation); -end - -function def = controlledDefinition() - actions = struct("pointsEdited", @pointsEdited); - def = definitionBase(@controlledLayout, actions, @controlledPresentation); -end - -function def = pairedDefinition() - actions = struct("pointPairsEdited", @pointPairsEdited); - def = definitionBase(@hubLayout, actions, @pairedPresentation); -end - -function def = rectangleDefinition() - actions = struct("cropRectMoved", @cropRectMoved); - def = definitionBase(@controlledLayout, actions, @rectanglePresentation); -end - -function def = intervalDefinition() - actions = struct("rangeEdited", @rangeEdited, ... - "windowScrolled", @windowScrolled); - def = definitionBase(@controlledLayout, actions, @intervalPresentation); -end - -function def = regionDefinition() - actions = struct("regionSelected", @noop, "pointSelected", @noop); - def = definitionBase(@controlledLayout, actions, @regionPresentation); -end - -function def = pointSlotsDefinition() - actions = struct("slotsEdited", @slotsEdited); - def = definitionBase(@controlledLayout, actions, @pointSlotsPresentation); -end - -function def = definitionBase(layout, actions, presenter) - project = struct("Version", 1, "Create", @createProject, ... - "Validate", @(~) true); - def = labkit.ui.runtime.define( ... - "Command", "runtime_v2_hub_probe", ... - "Id", "runtime_v2_hub_probe", ... - "Title", "Runtime V2 Hub Probe", ... - "Family", "Test", ... - "AppVersion", "1.0.0", ... - "Updated", "2026-07-14", ... - "Requirements", labkit.contract.requirements(), ... - "Project", project, ... - "CreateSession", @createSession, ... - "Layout", layout, ... - "Actions", actions, ... - "Present", presenter, ... - "Renderers", struct("resettingImage", @renderResettingImage)); -end - -function project = createProject() - project = struct( ... - "inputs", struct(), ... - "parameters", struct(), ... - "annotations", struct("points", [10 10; 20 20], ... - "editCount", 0, "range", [NaN NaN], "scrollCount", 0, ... - "referencePoints", zeros(0, 2), ... - "movingPoints", zeros(0, 2), "pairEditCount", 0, ... - "cropRect", [20 20 40 40], ... - "slots", struct("points", [10 10; NaN NaN; NaN NaN], ... - "selectedIndex", 2, "locked", false)), ... - "results", struct(), ... - "extensions", struct()); -end - -function session = createSession(~) - session = struct("selection", struct(), "workflow", struct(), ... - "view", struct(), "cache", struct()); -end - -function layout = hubLayout(~) - pair = labkit.ui.layout.previewArea("pair", "Pair", ... - "layout", "pair", "axisIds", {"left", "right"}); - third = labkit.ui.layout.previewArea("third", "Third"); - layout = workbenchLayout({pair, third}); -end - -function layout = controlledLayout(~) - layout = workbenchLayout( ... - {labkit.ui.layout.previewArea("image", "Image")}); -end - -function layout = workbenchLayout(previews) - layout = labkit.ui.layout.workbench("hubProbe", ... - "Runtime V2 Hub Probe", ... - "controlTabs", {labkit.ui.layout.tab("controls", "Controls", {})}, ... - "workspace", labkit.ui.layout.workspace( ... - "workspace", "Previews", previews)); -end - -function view = hubPresentation(~) - view = struct(); -end - -function view = controlledPresentation(state) - view = struct(); - view.interactions.editPoints = struct( ... - "Kind", "anchors", ... - "Targets", "image", ... - "Value", state.project.annotations.points, ... - "Event", "pointsEdited", ... - "ImageSize", [100 100], ... - "ChangePolicy", "commit"); -end - -function view = pairedPresentation(state) - view = struct(); - view.interactions.pointPairs = struct( ... - "Kind", "pairedAnchors", ... - "Targets", ["pair.left", "pair.right"], ... - "Value", {{state.project.annotations.referencePoints, ... - state.project.annotations.movingPoints}}, ... - "Event", "pointPairsEdited", ... - "ImageSize", {{[100 100], [100 100]}}, ... - "ChangePolicy", "commit", ... - "Options", struct("mode", "points")); -end - -function view = rectanglePresentation(state) - view = struct(); - view.previews.image = struct("Renderer", "resettingImage", ... - "Model", state.project.annotations.cropRect); - view.interactions.cropRectangle = struct( ... - "Kind", "rectangle", "Targets", "image", ... - "Value", state.project.annotations.cropRect, ... - "Event", "cropRectMoved", "ImageSize", [100 100], ... - "ChangePolicy", "commit", ... - "Options", struct("fixedAspectRatio", true)); -end - -function renderResettingImage(ax, ~) - cla(ax, 'reset'); - image(ax, zeros(100, 100, 3)); - axis(ax, 'image'); -end - -function view = intervalPresentation(state) - view = struct(); - view.interactions.timeRange = struct( ... - "Kind", "interval", "Targets", "image", ... - "Value", state.project.annotations.range, ... - "Event", "rangeEdited", "ScrollEvent", "windowScrolled"); -end - -function view = regionPresentation(~) - view = struct(); - view.interactions.region = struct( ... - "Kind", "regionSelection", "Targets", "image", ... - "Value", [], "Event", "regionSelected", ... - "BackgroundEvent", "pointSelected", ... - "ImageSize", [100 100], "ChangePolicy", "commit"); -end - -function view = pointSlotsPresentation(state) - view = struct(); - value = state.project.annotations.slots; - if isfield(value, 'changedIndex') - value = rmfield(value, {'changedIndex', 'reason'}); - end - view.interactions.slots = struct( ... - "Kind", "pointSlots", "Targets", "image", ... - "Value", value, "Event", "slotsEdited", ... - "ImageSize", [100 100], "ChangePolicy", "commit"); - view.interactions.slots.Options = struct( ... - "placeSelectedOnBackground", true); -end - -function state = pointsEdited(state, event, ~) - state.project.annotations.points = event.value; - state.project.annotations.editCount = ... - state.project.annotations.editCount + 1; -end - -function state = pointPairsEdited(state, event, ~) - assert(iscell(event.value) && numel(event.value) == 2, ... - 'Paired-anchor events must retain one two-cell payload.'); - state.project.annotations.referencePoints = event.value{1}; - state.project.annotations.movingPoints = event.value{2}; - state.project.annotations.pairEditCount = ... - state.project.annotations.pairEditCount + 1; -end - -function state = cropRectMoved(state, event, ~) - state.project.annotations.cropRect = event.value; -end - -function state = rangeEdited(state, event, ~) - state.project.annotations.range = event.value; -end - -function state = windowScrolled(state, ~, ~) - state.project.annotations.scrollCount = ... - state.project.annotations.scrollCount + 1; -end - -function state = slotsEdited(state, event, ~) - state.project.annotations.slots = event.value; -end - -function state = noop(state, ~, ~) -end - -function resource = interactionResource(resources, id) - index = find([resources.scope] == "interaction" & ... - [resources.id] == string(id), 1, 'first'); - assert(~isempty(index), 'Expected controlled interaction resource.'); - resource = resources(index).value; -end - -function failDrag(~, ~) - error('hubProbe:DragFailure', 'Expected drag callback failure.'); -end - -function assertThrows(callback, identifier) - try - callback(); - catch ME - assert(strcmp(ME.identifier, identifier), ... - 'Expected %s but caught %s.', identifier, ME.identifier); - return; - end - error('Expected an error with identifier %s.', identifier); -end diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2ProjectTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2ProjectTest.m deleted file mode 100644 index 5fa49b718..000000000 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2ProjectTest.m +++ /dev/null @@ -1,591 +0,0 @@ -classdef GuiLayoutUiRuntimeV2ProjectTest < matlab.unittest.TestCase - %GUILAYOUTUIRUNTIMEV2PROJECTTEST Verify durable V2 project documents. - - methods (Test, TestTags = {'GUI', 'Structural'}) - function project_roundtrip_migration_and_failures_are_atomic(~) - setupLabKitTestPath(); - verifyProjectDocuments(); - end - - function minimal_definition_needs_no_app_lifecycle_callbacks(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - info = labkit.ui.runtime.launch(@minimalDefinition, "version"); - req = labkit.ui.runtime.launch(@minimalDefinition, "requirements"); - testCase.verifyEqual(info.name, "minimal_definition_probe"); - testCase.verifyEqual(req.type, "labkit.requirements"); - fig = labkit.ui.runtime.launch(@minimalDefinition); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - testCase.verifyEqual(string(fieldnames(runtime.state.project)), ... - ["inputs"; "parameters"; "annotations"; "results"; "extensions"]); - testCase.verifyEqual(string(fieldnames(runtime.state.session)), ... - ["selection"; "workflow"; "view"; "cache"]); - testCase.verifyEmpty(fieldnames(runtime.definition.actions)); - clear cleanup - end - - function project_migration_contract_rejects_missing_or_retired_callbacks(testCase) - setupLabKitTestPath(); - missing = struct("Version", 2, "Create", @createProject, ... - "Validate", @validateProject); - retired = struct("Version", 2, "Create", @createProject, ... - "Validate", @validateProject, ... - "Migrations", {{@migrateOneToTwo}}); - testCase.verifyError(@() projectDefinition(missing), ... - 'labkit:ui:runtime:InvalidDefinition'); - testCase.verifyError(@() projectDefinition(retired), ... - 'labkit:ui:runtime:InvalidDefinition'); - end - end -end - -function def = projectDefinition(project) - def = labkit.ui.runtime.define( ... - "Id", "project_definition_probe", ... - "Title", "Project Definition Probe", ... - "Project", project, ... - "Layout", @() struct()); -end - -function def = minimalDefinition() - def = labkit.ui.runtime.define( ... - "Command", "minimal_definition_probe", ... - "Id", "minimal_definition_probe", ... - "Title", "Minimal Definition Probe", ... - "Family", "Test", ... - "AppVersion", "1.0.0", ... - "Updated", "2026-07-16", ... - "Requirements", labkit.contract.requirements(), ... - "Layout", @minimalLayout); -end - -function layout = minimalLayout() - layout = labkit.ui.layout.workbench( ... - "minimal_definition_probe", "Minimal Definition Probe", ... - "controlTabs", {labkit.ui.layout.tab("main", "Main", { ... - labkit.ui.layout.section("content", "Content", { ... - labkit.ui.layout.field("message", "Message", ... - "value", "Ready")})})}, ... - "workspace", labkit.ui.layout.workspace( ... - "workspace", "Workspace", {})); -end - -function verifyProjectDocuments() - h = guiTestHelpers(); - h.assertUifigureAvailable(); - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', 'hidden'); - cleanupMode = onCleanup(@() setenv('LABKIT_GUI_TEST_MODE', oldMode)); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - folder = string(tempname); - mkdir(folder); - cleanupFolder = onCleanup(@() rmdir(folder, 's')); - fig = labkit.ui.runtime.launch(@definition); - h.waitForUiIdle(fig); - ui = getappdata(fig, 'labkitUiRegistry'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.recoveryRoot = fullfile(folder, "recovery"); - runtime.request.autosaveDelay = 0.05; - runtime.request.resultFolder = fullfile(folder, "result"); - setappdata(fig, 'labkitUiAppRuntime', runtime); - invoke(ui.controls.increment.button); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.document.dirty && endsWith(string(fig.Name), " *"), ... - 'A durable project edit should mark the document and title dirty.'); - pause(0.15); - drawnow; - recoveryPath = string(getappdata(fig, 'labkitV2RecoveryFile')); - assert(isfile(recoveryPath), ... - 'Idle successful project commits should write debounced recovery.'); - [documentFolder, ~, ~] = fileparts(recoveryPath); - [~, appStorageFolder, ~] = fileparts(fileparts(documentFolder)); - assert(startsWith(string(appStorageFolder), "app_") && ... - string(appStorageFolder) ~= "runtime_v2_project_probe", ... - 'Recovery storage must use a collision-free app identity key.'); - - invoke(ui.controls.export.button); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - manifestPath = runtime.state.project.results.manifestPath; - manifest = jsondecode(fileread(manifestPath)); - assert(string(manifest.format) == "labkit.result" && ... - string(manifest.run.status) == "partial" && ... - manifest.outputs(1).bytes == 4 && ... - strlength(string(manifest.outputs(1).sha256)) == 64 && ... - string(manifest.outputs(2).status) == "failed", ... - 'Result service should record hashes, sizes, and partial failures.'); - pause(0.15); - drawnow; - assert(isfile(fullfile(fileparts(recoveryPath), "previous.mat")), ... - 'Recovery policy should retain one bounded previous generation.'); - beforeBadExport = getappdata(fig, 'labkitUiAppRuntime'); - assertThrows(@() invoke(ui.controls.badExport.button), ... - 'labkit:ui:runtime:InvalidResultManifest'); - assertRuntimeUnchanged(fig, beforeBadExport, ... - 'Result path traversal rejection must not commit semantic state.'); - assertThrows(@() invoke(ui.controls.duplicateExport.button), ... - 'labkit:ui:runtime:InvalidResultManifest'); - - currentPath = fullfile(folder, "current.mat"); - labkit.ui.runtime.saveState(fig, currentPath); - stored = load(currentPath, 'labkitProject'); - assert(string(stored.labkitProject.format) == "labkit.project" && ... - stored.labkitProject.app.payloadVersion == 3 && ... - ~isfield(stored.labkitProject.payload, 'session') && ... - stored.labkitProject.resume.generation == 1, ... - ['V2 save should write durable project state plus only the ' ... - 'app-declared best-effort resume payload.']); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(~runtime.document.dirty && runtime.document.path == currentPath, ... - 'Successful explicit save should register the path and mark clean.'); - - portableRoot = fullfile(folder, "portable-root"); - portableStateFolder = fullfile(portableRoot, "state"); - portableSourceFolder = fullfile(portableRoot, "source"); - mkdir(portableStateFolder); - mkdir(portableSourceFolder); - sourcePath = fullfile(portableSourceFolder, "portable-input.dat"); - writeBytes(sourcePath, uint8([5 6 7])); - source = struct("id", "requiredInput", "required", true, ... - "role", "input", "reference", struct( ... - "schemaVersion", 1, "relativePath", "", ... - "originalPath", sourcePath, "fileName", "portable-input.dat", ... - "futureReferenceField", "preserved")); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.state.project.inputs.sources = source; - setappdata(fig, 'labkitUiAppRuntime', runtime); - portablePath = fullfile(portableStateFolder, "portable.mat"); - labkit.ui.runtime.saveState(fig, portablePath); - portable = load(portablePath, 'labkitProject'); - savedReference = portable.labkitProject.payload.inputs.sources.reference; - assert(savedReference.relativePath == "../source/portable-input.dat" && ... - savedReference.futureReferenceField == "preserved", ... - ['Saving must derive the relative source path from the actual ' ... - 'destination and preserve additive reference fields.']); - movedRoot = fullfile(folder, "moved-portable-root"); - movefile(portableRoot, movedRoot); - movedPortablePath = fullfile(movedRoot, "state", "portable.mat"); - movedSourcePath = fullfile(movedRoot, "source", "portable-input.dat"); - labkit.ui.runtime.loadState(fig, movedPortablePath); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.state.project.inputs.sources.reference.originalPath == ... - movedSourcePath && ... - runtime.state.session.cache.sourcePath == movedSourcePath, ... - ['A save-generated relative reference must survive moving the ' ... - 'project/source directory tree before fresh session creation.']); - - invoke(ui.controls.increment.button); - assert(getappdata(fig, 'labkitUiAppRuntime').state.project.parameters.count == 2); - labkit.ui.runtime.loadState(fig, currentPath); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.state.project.parameters.count == 1 && ... - runtime.state.session.cache.generation == 1 && ... - runtime.state.session.cache.resumedGeneration == 1 && ... - ~runtime.document.dirty, ... - 'Open should replace project, construct a fresh session, and mark clean.'); - previewAxes = ui.controls.preview.axesById.axis1; - cla(previewAxes); - labkit.ui.runtime.loadState(fig, currentPath); - assert(~isempty(previewAxes.Children), ... - ['Opening the same project must repaint previews instead of treating ' ... - 'the previous document presentation as a current render cache.']); - - recoveredFig = labkit.ui.runtime.launch(@definition, ... - "RequestAdapter", @(args) recoveryRequest( ... - args, recoveryPath)); - recoveredRuntime = getappdata(recoveredFig, 'labkitUiAppRuntime'); - assert(recoveredRuntime.document.dirty && ... - strlength(recoveredRuntime.document.path) == 0, ... - 'Recovered documents should open dirty and never own the explicit path.'); - delete(recoveredFig); - - additive = stored.labkitProject; - additive.formatVersion.minor = 9; - additive.futureEnvelopeField = struct("preserved", true); - additive.payload.extensions.futureMinor = struct("preserved", true); - additivePath = fullfile(folder, "additive.mat"); - saveProject(additivePath, additive); - labkit.ui.runtime.loadState(fig, additivePath); - preservedPath = fullfile(folder, "preserved.mat"); - labkit.ui.runtime.saveState(fig, preservedPath); - preserved = load(preservedPath, 'labkitProject'); - assert(isfield(preserved.labkitProject, 'futureEnvelopeField') && ... - preserved.labkitProject.futureEnvelopeField.preserved && ... - preserved.labkitProject.payload.extensions.futureMinor.preserved, ... - 'Unknown additive envelope fields should survive read-save.'); - - legacy = stored.labkitProject; - legacy.app.payloadVersion = 1; - legacy.payload = legacyPayload(); - legacyPath = fullfile(folder, "legacy.mat"); - saveProject(legacyPath, legacy); - labkit.ui.runtime.loadState(fig, legacyPath); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.state.project.parameters.count == 7 && ... - runtime.state.project.parameters.migratedTo == 3 && ... - ~isfield(runtime.state.project.parameters, 'defaultOnly') && ... - runtime.document.dirty, ... - 'Ordered migrations should produce a complete payload without merging defaults.'); - - snapshot = struct("app", struct("id", "runtime_v2_project_probe"), ... - "state", struct("project", runtime.state.project)); - snapshot.state.project.parameters.count = 11; - snapshotPath = fullfile(folder, "snapshot.mat"); - save(snapshotPath, 'snapshot'); - labkit.ui.runtime.loadState(fig, snapshotPath); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.state.project.parameters.count == 11 && ... - runtime.document.dirty, ... - 'The named v1 snapshot adapter should remain read-only compatible.'); - labkit.ui.runtime.saveState(fig); - upgraded = load(snapshotPath, 'labkitProject'); - assert(upgraded.labkitProject.app.payloadVersion == 3 && ... - string(who('-file', snapshotPath)) == "labkitProject", ... - ['Saving an imported old document should atomically replace it ' ... - 'with the current project format at the opened path.']); - - before = getappdata(fig, 'labkitUiAppRuntime'); - newer = stored.labkitProject; - newer.formatVersion.major = 2; - newerPath = fullfile(folder, "newer.mat"); - saveProject(newerPath, newer); - assertThrows(@() labkit.ui.runtime.loadState(fig, newerPath), ... - 'labkit:ui:runtime:NewerProjectFormat'); - assertRuntimeUnchanged(fig, before, 'Newer major rejection must be atomic.'); - - wrong = stored.labkitProject; - wrong.app.id = "another_app"; - wrongPath = fullfile(folder, "wrong.mat"); - saveProject(wrongPath, wrong); - assertThrows(@() labkit.ui.runtime.loadState(fig, wrongPath), ... - 'labkit:ui:runtime:WrongProjectApp'); - assertRuntimeUnchanged(fig, before, 'Wrong app rejection must be atomic.'); - - malformed = stored.labkitProject; - malformed.payload = rmfield(malformed.payload, 'extensions'); - malformedPath = fullfile(folder, "malformed.mat"); - saveProject(malformedPath, malformed); - assertThrows(@() labkit.ui.runtime.loadState(fig, malformedPath), ... - 'labkit:ui:runtime:InvalidState'); - assertRuntimeUnchanged(fig, before, ... - 'Runtime project-shape rejection must precede App validation.'); - - corruptSourcePath = fullfile(folder, "corrupt-input.dat"); - writeBytes(corruptSourcePath, uint8([13 37])); - corruptSource = labkit.ui.runtime.sourceRecord( ... - "requiredInput", "input data", corruptSourcePath, true); - corrupt = stored.labkitProject; - corrupt.payload.parameters.count = 13; - corrupt.payload.inputs.sources = corruptSource; - corrupt.sources = corruptSource; - corruptPath = fullfile(folder, "corrupt-session.mat"); - saveProject(corruptPath, corrupt); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.debug.reportException = @(appId, context, exception) ... - setappdata(fig, 'projectLoadDiagnostic', struct( ... - "appId", string(appId), "context", string(context), ... - "exception", exception)); - setappdata(fig, 'labkitUiAppRuntime', runtime); - beforeCorrupt = runtime; - caught = []; - try - labkit.ui.runtime.loadState(fig, corruptPath); - catch ME - caught = ME; - end - assert(isa(caught, 'MException') && ... - string(caught.identifier) == ... - "labkit:ui:runtime:ProjectSessionRestoreFailed" && ... - contains(string(caught.message), 'inputs.sources') && ... - contains(string(caught.message), 'corrupt-input.dat'), ... - ['An existing but undecodable source should identify its project ' ... - 'field and filename instead of appearing unresolved.']); - diagnostic = getappdata(fig, 'projectLoadDiagnostic'); - assert(diagnostic.appId == "runtime_v2_project_probe" && ... - contains(diagnostic.context, "corrupt-session.mat") && ... - diagnostic.exception.identifier == ... - "labkit:ui:runtime:ProjectSessionRestoreFailed", ... - 'Project session reconstruction failures should reach diagnostics.'); - assertRuntimeUnchanged(fig, beforeCorrupt, ... - 'Existing-source decode failure must leave live state unchanged.'); - - unresolved = stored.labkitProject; - missingSource = struct("id", "requiredInput", ... - "required", true, "role", "input data", "reference", struct( ... - "schemaVersion", 1, ... - "relativePath", "missing/input.dat", ... - "originalPath", "", "fileName", "input.dat")); - unresolved.sources = missingSource; - unresolved.payload.inputs.sources = missingSource; - unresolvedPath = fullfile(folder, "unresolved.mat"); - saveProject(unresolvedPath, unresolved); - - replacementPath = fullfile(folder, "replacement.dat"); - writeBytes(replacementPath, uint8([8 9])); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.choiceDialog = ... - @(varargin) "Locate file"; - runtime.request.inputFileChooser = ... - @(varargin) chooseKnownFile(replacementPath); - setappdata(fig, 'labkitUiAppRuntime', runtime); - labkit.ui.runtime.loadState(fig, unresolvedPath); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - reference = runtime.state.project.inputs.sources.reference; - assert(reference.originalPath == replacementPath && ... - reference.relativePath == "replacement.dat" && ... - runtime.state.session.cache.sourcePath == replacementPath && ... - runtime.document.dirty, ... - ['The default relinker should update the missing source before ' ... - 'session creation and mark the repaired project for saving.']); - - runtime.request.choiceDialog = @(varargin) "Cancel"; - runtime.request = rmfield(runtime.request, 'inputFileChooser'); - setappdata(fig, 'labkitUiAppRuntime', runtime); - beforeCancel = runtime; - assertThrows(@() labkit.ui.runtime.loadState(fig, unresolvedPath), ... - 'labkit:ui:runtime:ProjectLoadCancelled'); - assertRuntimeUnchanged(fig, beforeCancel, ... - 'Default relink cancellation must leave live state unchanged.'); - - runtime.definition.project.RelinkSources = @cancelRelink; - setappdata(fig, 'labkitUiAppRuntime', runtime); - beforeCancel = runtime; - assertThrows(@() labkit.ui.runtime.loadState(fig, unresolvedPath), ... - 'labkit:ui:runtime:ProjectLoadCancelled'); - assertRuntimeUnchanged(fig, beforeCancel, ... - 'Relink cancellation must leave live state unchanged.'); - - protectedPath = fullfile(folder, "protected.mat"); - copyfile(currentPath, protectedPath); - priorBytes = readBytes(protectedPath); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - runtime.request.projectBeforeReplace = @failBeforeReplace; - setappdata(fig, 'labkitUiAppRuntime', runtime); - assertThrows(@() labkit.ui.runtime.saveState(fig, protectedPath), ... - 'projectProbe:WriteFailure'); - assert(isequal(readBytes(protectedPath), priorBytes), ... - 'Atomic write failure should preserve the prior project bytes.'); - - delete(fig); - clear cleanupFolder cleanupFigures cleanupMode; -end - -function [request, dispatchArgs] = recoveryRequest(~, recoveryPath) - request = struct("recoveryFile", recoveryPath, "autosave", false); - dispatchArgs = {}; -end - -function def = definition() - project = struct("Version", 3, "Create", @createProject, ... - "Validate", @validateProject, "Migrate", @migrateProject, ... - "CreateResume", @createResume, "ApplyResume", @applyResume); - def = labkit.ui.runtime.define( ... - "Command", "runtime_v2_project_probe", ... - "Id", "runtime_v2_project_probe", ... - "Title", "Runtime V2 Project Probe", ... - "Family", "Test", ... - "AppVersion", "1.0.0", ... - "Updated", "2026-07-14", ... - "Requirements", labkit.contract.requirements(), ... - "Project", project, ... - "CreateSession", @createSession, ... - "Layout", @layout, ... - "Actions", struct("increment", @increment, ... - "export", @exportResult, "badExport", @badExport, ... - "duplicateExport", @duplicateExport), ... - "Present", @present, ... - "Renderers", struct("probe", @renderProbe)); -end - -function project = migrateProject(project, fromVersion) - switch fromVersion - case 1 - project = migrateOneToTwo(project); - case 2 - project = migrateTwoToThree(project); - otherwise - error('runtimeV2ProjectTest:UnsupportedProjectVersion', ... - 'No migration is defined from project version %d.', fromVersion); - end -end - -function project = createProject() - project = buckets(struct("count", 0, "defaultOnly", true, ... - "migratedTo", 3)); -end - -function project = legacyPayload() - project = buckets(struct("count", 7)); - project.extensions.future = struct("keep", true); -end - -function project = migrateOneToTwo(project) - project.parameters.migratedTo = 2; -end - -function project = migrateTwoToThree(project) - project.parameters.migratedTo = 3; -end - -function project = buckets(parameters) - project = struct("inputs", struct(), "parameters", parameters, ... - "annotations", struct(), "results", struct(), ... - "extensions", struct()); -end - -function accepted = validateProject(project) - accepted = isfield(project.parameters, 'count') && ... - project.parameters.count >= 0; -end - -function session = createSession(project) - if project.parameters.count == 13 - error('projectProbe:CorruptSource', ... - 'Could not decode the selected source bytes.'); - end - sourcePath = ""; - if isfield(project.inputs, 'sources') && ~isempty(project.inputs.sources) - sourcePath = string( ... - project.inputs.sources(1).reference.originalPath); - end - session = struct("selection", struct(), "workflow", struct(), ... - "view", struct(), "cache", struct( ... - "generation", project.parameters.count, ... - "sourcePath", sourcePath, ... - "resumedGeneration", 0)); -end - -function resume = createResume(~, project) - resume = struct("generation", project.parameters.count); -end - -function session = applyResume(session, resume, ~) - if isstruct(resume) && isfield(resume, 'generation') - session.cache.resumedGeneration = double(resume.generation); - end -end - -function tree = layout(callbacks) - action = labkit.ui.layout.action("increment", "Increment", ... - callbacks.increment); - exportAction = labkit.ui.layout.action("export", "Export", ... - callbacks.export); - badExportAction = labkit.ui.layout.action("badExport", "Bad export", ... - callbacks.badExport); - duplicateExportAction = labkit.ui.layout.action( ... - "duplicateExport", "Duplicate export", callbacks.duplicateExport); - tree = labkit.ui.layout.workbench("projectProbe", ... - "Runtime V2 Project Probe", ... - "controlTabs", {labkit.ui.layout.tab("controls", "Controls", ... - {labkit.ui.layout.section("actions", "Actions", ... - {action, exportAction, badExportAction, ... - duplicateExportAction})})}, ... - "workspace", labkit.ui.layout.workspace("workspace", "Preview", ... - {labkit.ui.layout.previewArea("preview", "Preview")})); -end - -function state = increment(state, ~, ~) - state.project.parameters.count = state.project.parameters.count + 1; -end - -function state = exportResult(state, ~, services) - folder = string(services.request.resultFolder); - if ~isfolder(folder) - mkdir(folder); - end - writeBytes(fullfile(folder, "data.bin"), uint8([1 2 3 4])); - outputs = [ ... - struct("Id", "data", "Role", "primary", "Path", "data.bin", ... - "MediaType", "application/octet-stream", "Status", "success", ... - "Message", ""), ... - struct("Id", "missing", "Role", "secondary", "Path", "missing.csv", ... - "MediaType", "text/csv", "Status", "success", "Message", "")]; - spec = struct("Outputs", outputs, ... - "Parameters", state.project.parameters, ... - "Summary", struct("count", state.project.parameters.count)); - [path, ~] = services.results.writeManifest(folder, spec); - state.project.results.manifestPath = path; -end - -function state = badExport(state, ~, services) - outputs = struct("Id", "escape", "Role", "primary", ... - "Path", "../outside.csv"); - services.results.writeManifest(services.request.resultFolder, ... - struct("Outputs", outputs)); -end - -function state = duplicateExport(state, ~, services) - outputs = [ ... - struct("Id", "duplicate", "Role", "primary", ... - "Path", "first.csv", "Status", "failed"), ... - struct("Id", "duplicate", "Role", "secondary", ... - "Path", "second.csv", "Status", "failed")]; - services.results.writeManifest(services.request.resultFolder, ... - struct("Outputs", outputs)); -end - -function writeBytes(filepath, bytes) - file = fopen(filepath, 'wb'); - cleanup = onCleanup(@() fclose(file)); - fwrite(file, bytes, 'uint8'); - clear cleanup; -end - -function view = present(state) - view = struct("previews", struct("preview", struct( ... - "Renderer", "probe", "Model", state.project.parameters.count))); -end - -function renderProbe(ax, count) - cla(ax); - plot(ax, [0 1], [count count + 1]); -end - -function saveProject(filepath, value) - labkitProject = value; - save(filepath, 'labkitProject'); -end - -function bytes = readBytes(filepath) - file = fopen(filepath, 'rb'); - cleanup = onCleanup(@() fclose(file)); - bytes = fread(file, inf, '*uint8'); - clear cleanup; -end - -function assertRuntimeUnchanged(fig, expected, message) - actual = getappdata(fig, 'labkitUiAppRuntime'); - assert(isequaln(actual.state, expected.state) && ... - isequaln(actual.document, expected.document), message); -end - -function failBeforeReplace(~, ~) - error('projectProbe:WriteFailure', 'Expected write failure.'); -end - -function project = cancelRelink(~, ~, ~) - project = []; -end - -function [file, folder] = chooseKnownFile(filepath) - [folder, name, extension] = fileparts(filepath); - file = char(string(name) + string(extension)); - folder = char(folder); -end - -function invoke(button) - button.ButtonPushedFcn(button, struct()); -end - -function assertThrows(callback, identifier) - try - callback(); - catch ME - assert(strcmp(ME.identifier, identifier), ... - 'Expected %s but caught %s.', identifier, ME.identifier); - return; - end - error('Expected an error with identifier %s.', identifier); -end diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2Test.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2Test.m deleted file mode 100644 index 1d27bfa2d..000000000 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2Test.m +++ /dev/null @@ -1,618 +0,0 @@ -classdef GuiLayoutUiRuntimeV2Test < matlab.unittest.TestCase - %GUILAYOUTUIRUNTIMEV2TEST Verify queued v2 semantic runtime contracts. - - methods (Test, TestTags = {'GUI', 'Structural'}) - function runtime_v2_commits_queued_events_atomically(~) - setupLabKitTestPath(); - verifyRuntimeV2(); - end - - function minimal_definition_launches_without_optional_components(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', 'hidden'); - cleanupMode = onCleanup( ... - @() setenv('LABKIT_GUI_TEST_MODE', oldMode)); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - - fig = labkit.ui.runtime.launch(@minimalDefinition); - h.waitForUiIdle(fig); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - - testCase.verifyEmpty(fieldnames(runtime.definition.actions), ... - 'A static App should not need an action registry.'); - testCase.verifyEmpty(runtime.definition.createSession, ... - 'A static App should not need a session factory.'); - testCase.verifyEmpty(runtime.definition.start, ... - 'A static App should not need a startup callback.'); - testCase.verifyTrue(all(isfield(runtime.state.project, ... - {'inputs', 'parameters', 'annotations', 'results', ... - 'extensions'})), ... - 'Runtime should supply the canonical empty project.'); - testCase.verifyTrue(all(isfield(runtime.state.session, ... - {'selection', 'workflow', 'view', 'cache'})), ... - 'Runtime should supply the canonical empty session.'); - testCase.verifyEqual(string(runtime.definition.product.version), ... - "1.0.0"); - testCase.verifyNotEmpty(findall(fig, ... - 'Tag', 'labkitUiUtilitySaveState'), ... - 'Default project state should support framework persistence.'); - - clear cleanupFigures cleanupMode; - end - - function unbound_layout_events_fail_at_launch(testCase) - setupLabKitTestPath(); - testCase.verifyError(@() labkit.ui.runtime.launch( ... - @unboundEventDefinition), ... - 'labkit:ui:runtime:UnboundLayoutEvent'); - end - - function unknown_layout_actions_fail_at_launch(testCase) - setupLabKitTestPath(); - testCase.verifyError(@() labkit.ui.runtime.launch( ... - @unknownLayoutActionDefinition), ... - 'labkit:ui:runtime:UnknownLayoutAction'); - end - - function invalid_presentation_references_fail_at_launch(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - testCase.verifyError(@() labkit.ui.runtime.launch( ... - @unknownControlDefinition), ... - 'labkit:ui:runtime:UnknownPresentationControl'); - testCase.verifyError(@() labkit.ui.runtime.launch( ... - @unknownInteractionActionDefinition), ... - 'labkit:ui:runtime:UnknownInteractionAction'); - clear cleanupFigures; - end - - function duplicate_durable_source_ids_fail_before_commit(testCase) - setupLabKitTestPath(); - testCase.verifyError(@() labkit.ui.runtime.launch( ... - @duplicateSourceDefinition), ... - 'labkit:ui:runtime:InvalidSourceRecords'); - end - - function retired_three_factory_launch_is_rejected(testCase) - setupLabKitTestPath(); - testCase.verifyError(@() labkit.ui.runtime.launch( ... - @minimalDefinition, @unboundEventRequirements, ... - @unboundEventVersion), ... - 'labkit:ui:runtime:InvalidLaunchFactory'); - end - end -end - -function def = minimalDefinition() - def = labkit.ui.runtime.define( ... - "Command", "labkit_MinimalProbe_app", ... - "Id", "minimal_probe", ... - "Title", "Minimal Runtime Probe", ... - "Family", "Test", ... - "AppVersion", "1.0.0", ... - "Updated", "2026-07-16", ... - "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ... - "Layout", @minimalLayout); -end - -function layout = minimalLayout() - field = labkit.ui.layout.field( ... - "exampleValue", "Example value", "kind", "number", "value", 1); - tab = labkit.ui.layout.tab("main", "Main", { ... - labkit.ui.layout.section("settings", "Settings", {field})}); - status = labkit.ui.layout.statusPanel( ... - "status", "Status", "value", "Ready."); - workspace = labkit.ui.layout.workspace( ... - "workspace", "Workspace", {status}); - layout = labkit.ui.layout.workbench( ... - "minimalProbe", "Minimal Runtime Probe", ... - "controlTabs", {tab}, "workspace", workspace); -end - -function verifyRuntimeV2() - h = guiTestHelpers(); - h.assertUifigureAvailable(); - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', 'hidden'); - cleanupMode = onCleanup(@() setenv('LABKIT_GUI_TEST_MODE', oldMode)); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - cleanupValues = zeros(1, 0); - renderedModels = zeros(1, 0); - presentedCounts = zeros(1, 0); - - fig = labkit.ui.runtime.launch(@definition); - h.waitForUiIdle(fig); - ui = getappdata(fig, 'labkitUiRegistry'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.definition.contractVersion == 2, ... - 'V2 definitions should launch through the v2 runtime path.'); - assert(runtime.state.project.parameters.count == 1, ... - 'Start should run as the first queued semantic event.'); - assert(runtime.metrics.stateCommits == 1 && ... - runtime.metrics.presentationCommits == 2, ... - 'Initial presentation and Start should each commit exactly once.'); - assert(~isappdata(fig, 'labkitUiStartup') && ... - ~isappdata(fig, 'labkitUiBusy'), ... - 'V2 startup should release readiness only after initial commits finish.'); - assert(startsWith(string(fig.Name), "Runtime V2 Probe") && ... - endsWith(string(fig.Name), "v1.0.0 (2026-07-14)"), ... - 'runtime.launch should apply app version metadata.'); - assert(~isempty(findall(fig, 'Tag', 'labkitUiUtilityPlotMenu')) && ... - ~isempty(findall(fig, 'Tag', 'labkitUiUtilitySaveState')), ... - 'V2 utilities should infer plot support and expose durable persistence.'); - assert(string(ui.controls.files.status.Value) == "2 inputs" && ... - string(ui.controls.files.currentSelectedFiles().id) == "item2", ... - 'V2 control presentation should separate file inventory, selection, and status.'); - renderedBeforeSelection = numel(renderedModels); - ui.controls.files.listbox.Value = ui.controls.files.listbox.Items{1}; - ui.controls.files.listbox.ValueChangedFcn( ... - ui.controls.files.listbox, struct()); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.state.session.selection.fileIndex == 1, ... - 'V2 event services should decode a selected file item into its index.'); - assert(string(ui.controls.files.currentSelectedFiles().id) == "item1", ... - 'The presenter should preserve the canonical file selection.'); - assert(numel(renderedModels) == renderedBeforeSelection, ... - 'A state-only file selection must not rerun unchanged preview renderers.'); - - invoke(ui.controls.increment.button); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.state.project.parameters.count == 2, ... - 'Generated action callbacks should commit returned semantic state.'); - assert(runtime.state.session.workflow.logLines(end) == "increment", ... - 'Workflow log service should append canonical session log state.'); - assert(any(contains(string(ui.controls.appLog.textArea.Value), ... - "increment")), ... - 'Runtime should present workflow log lines in semantic log panels.'); - assert(runtime.metrics.stateCommits == 3 && ... - runtime.metrics.presentationCommits == 4, ... - 'Each action should produce one state and one presentation commit.'); - - gain = ui.controls.gain.valueHandle; - gain.Value = 4; - gain.ValueChangedFcn(gain, struct()); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.state.project.parameters.gain == 4, ... - 'A layout binding should update its canonical semantic path.'); - assert(runtime.state.session.workflow.history(end) == "gainChanged", ... - 'A binding Event should run after the bound value is staged.'); - - historyBeforeBinding = runtime.state.session.workflow.history; - preset = ui.controls.boundOnly.valueHandle; - preset.Value = "Second"; - preset.ValueChangedFcn(preset, struct()); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(runtime.state.session.selection.boundOnly == "Second", ... - 'A binding without Event should commit its canonical state path.'); - assert(isequal(runtime.state.session.workflow.history, ... - historyBeforeBinding), ... - 'A binding without Event should not require a no-op App action.'); - - beforeNestedPresentation = runtime.metrics.presentationCommits; - invoke(ui.controls.outer.button); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(isequal(runtime.state.session.workflow.history(end-2:end), ... - ["outer", "outerEnd", "inner"]), ... - 'Nested services.dispatch should enqueue until the outer event commits.'); - assert(runtime.metrics.presentationCommits == beforeNestedPresentation + 2, ... - 'Outer and queued inner events should each have one visible commit.'); - - invoke(ui.controls.resourceOne.button); - invoke(ui.controls.resourceTwo.button); - assert(isequal(cleanupValues, 1), ... - 'Replacing a scoped resource should dispose the prior resource once.'); - invoke(ui.controls.cellResource.button); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - cellResourceValue = runtime.resources( ... - [runtime.resources.scope] == "figure" & ... - [runtime.resources.id] == "cellProbe").value; - assert(iscell(cellResourceValue) && ... - isequal(cellResourceValue, {"alpha", "beta"}), ... - 'Resource registry must retain a cell value inside one scalar entry.'); - assertThrows(@() invoke(ui.controls.failAfterResource.button), ... - 'runtimeV2Probe:ExpectedFailure'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(any(cleanupValues == 99) && ... - ~containsResource(runtime.resources, "event", "temporary"), ... - 'A failed event should dispose event-scoped resources.'); - - beforeFailure = runtime.state; - beforeFailureMetrics = runtime.metrics; - assertThrows(@() invoke(ui.controls.failPresentation.button), ... - 'runtimeV2Probe:PresentationFailure'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(isequaln(runtime.state, beforeFailure), ... - 'A presenter error should roll semantic state back atomically.'); - assert(isequal(runtime.metrics, beforeFailureMetrics), ... - 'Failed events should not count as committed state or presentation.'); - assert(contains(string(ui.controls.status.textArea.Value), ... - "Count: " + beforeFailure.project.parameters.count), ... - 'Presenter rollback should restore the last committed visible state.'); - - assertThrows(@() invoke(ui.controls.invalidState.button), ... - 'labkit:ui:runtime:InvalidState'); - runtime = getappdata(fig, 'labkitUiAppRuntime'); - assert(isequaln(runtime.state, beforeFailure), ... - 'Runtime resources in semantic state should be rejected before commit.'); - assert(~isempty(renderedModels) && renderedModels(end) == ... - runtime.state.project.parameters.count, ... - 'Registered renderers should receive prepared models for the latest state.'); - assert(~isempty(ui.controls.main.axesById.first.Children) && ... - ~isempty(ui.controls.main.axesById.second.Children), ... - 'A presenter should reconcile every declared axis in one preview area.'); - assert(numel(presentedCounts) >= runtime.metrics.presentationCommits, ... - 'The presenter should run for every visible commit.'); - - delete(fig); - assert(any(cleanupValues == 2), ... - 'Figure deletion should dispose remaining figure-scoped resources.'); - clear cleanupFigures cleanupMode; - - function def = definition() - project = struct("Version", 1, "Create", @createProject, ... - "Validate", @validateProject); - actions = struct( ... - "increment", @increment, ... - "fileSelectionChanged", @fileSelectionChanged, ... - "gainChanged", @gainChanged, ... - "outer", @outer, ... - "inner", @inner, ... - "resourceOne", @resourceOne, ... - "resourceTwo", @resourceTwo, ... - "cellResource", @cellResource, ... - "failAfterResource", @failAfterResource, ... - "failPresentation", @failPresentation, ... - "invalidState", @invalidState); - def = labkit.ui.runtime.define( ... - "Command", "runtime_v2_probe", ... - "Id", "runtime_v2_probe", ... - "Title", "Runtime V2 Probe", ... - "Family", "Test", ... - "AppVersion", "1.0.0", ... - "Updated", "2026-07-14", ... - "Requirements", labkit.contract.requirements(), ... - "Project", project, ... - "CreateSession", @createSession, ... - "Layout", @buildLayout, ... - "Actions", actions, ... - "Present", @present, ... - "Renderers", struct("main", @renderMain), ... - "Start", @start); - end - - function project = createProject() - project = struct( ... - "inputs", struct(), ... - "parameters", struct("count", 0, "gain", 1, ... - "failPresentation", false), ... - "annotations", struct(), ... - "results", struct(), ... - "extensions", struct()); - end - - function accepted = validateProject(project) - accepted = isstruct(project.parameters) && ... - project.parameters.count >= 0; - end - - function session = createSession(~) - session = struct( ... - "selection", struct("fileIndex", 2, "boundOnly", "First"), ... - "workflow", struct("history", strings(1, 0), ... - "logLines", strings(0, 1)), ... - "view", struct(), ... - "cache", struct()); - end - - function layout = buildLayout(callbacks) - actions = { ... - labkit.ui.layout.action("increment", "Increment", callbacks.increment), ... - labkit.ui.layout.action("outer", "Outer", callbacks.outer), ... - labkit.ui.layout.action("resourceOne", "Resource one", callbacks.resourceOne), ... - labkit.ui.layout.action("resourceTwo", "Resource two", callbacks.resourceTwo), ... - labkit.ui.layout.action("cellResource", "Cell resource", callbacks.cellResource), ... - labkit.ui.layout.action("failAfterResource", "Fail resource", callbacks.failAfterResource), ... - labkit.ui.layout.action("failPresentation", "Fail presentation", callbacks.failPresentation), ... - labkit.ui.layout.action("invalidState", "Invalid state", callbacks.invalidState)}; - layout = labkit.ui.layout.workbench("runtimeV2Probe", ... - "Runtime V2 Probe", ... - "controlTabs", {labkit.ui.layout.tab("controls", "Main", { ... - labkit.ui.layout.section("parameters", "Parameters", { ... - labkit.ui.layout.field("gain", "Gain", ... - "kind", "spinner", "Bind", ... - "project.parameters.gain", "Event", ... - "gainChanged", "debounceMs", 0), ... - labkit.ui.layout.field("boundOnly", "Bound only", ... - "kind", "dropdown", "items", ... - {'First', 'Second'}, "Bind", ... - "session.selection.boundOnly", "debounceMs", 0), ... - labkit.ui.layout.statusPanel("status", "Status", ... - "value", "Count: 0"), ... - labkit.ui.layout.filePanel("files", "Files", ... - "selectionMode", "multiple", "status", "None", ... - "onSelectionChange", callbacks.fileSelectionChanged)}), ... - labkit.ui.layout.section("actions", "Actions", actions), ... - labkit.ui.layout.section("log", "Log", { ... - labkit.ui.layout.logPanel("appLog", "Log")})})}, ... - "workspace", labkit.ui.layout.workspace("workspace", ... - "Preview", {labkit.ui.layout.previewArea("main", "Main", ... - "layout", "stack", "count", 2, ... - "axisIds", {'first', 'second'})})); - end - - function state = start(state, ~, ~) - state.project.parameters.count = 1; - state.session.workflow.history(end + 1) = "start"; - end - - function state = increment(state, ~, services) - assert(isfield(services, 'diagnostics') && ... - isfield(services.diagnostics, 'report'), ... - 'Runtime should inject app-neutral diagnostics services.'); - fresh = services.project.newState(); - assert(all(isfield(fresh.project, ... - {'inputs', 'parameters', 'annotations', 'results', 'extensions'})) && ... - all(isfield(fresh.session, ... - {'selection', 'workflow', 'view', 'cache'})), ... - 'Project newState should return canonical Runtime state.'); - sources = struct("id", {}, "required", {}, "role", {}, ... - "reference", {}); - sources = services.project.upsertSource( ... - sources, "input", "reference", "/tmp/first.dat", true); - sources = services.project.upsertSource( ... - sources, "input", "replacement", "/tmp/second.dat", false); - assert(isscalar(sources) && sources.role == "replacement" && ... - ~sources.required, ... - 'Project source service should replace records by stable id.'); - state.project.parameters.count = state.project.parameters.count + 1; - state = services.workflow.log(state, "increment"); - end - - function state = gainChanged(state, ~, ~) - state.session.workflow.history(end + 1) = "gainChanged"; - end - - function state = fileSelectionChanged(state, event, services) - indices = services.events.indices(event, "selectedFiles", 2); - assert(isscalar(indices), ... - 'One selected file should decode to one scalar index.'); - state.session.selection.fileIndex = indices(1); - end - - function state = outer(state, ~, services) - state.session.workflow.history(end + 1) = "outer"; - services.dispatch(struct("id", "inner", "source", "service", ... - "target", "runtime", "value", [], "meta", struct())); - state.session.workflow.history(end + 1) = "outerEnd"; - end - - function state = inner(state, ~, ~) - state.session.workflow.history(end + 1) = "inner"; - end - - function state = resourceOne(state, ~, services) - services.resources.set("figure", "probe", 1, @cleanupResource); - services.resources.set("figure", "plainProbe", ... - struct("value", 3)); - assert(services.resources.get("figure", "plainProbe").value == 3, ... - 'Plain resources should use Runtime default cleanup.'); - end - - function state = resourceTwo(state, ~, services) - services.resources.set("figure", "probe", 2, @cleanupResource); - end - - function state = cellResource(state, ~, services) - services.resources.set("figure", "cellProbe", ... - {"alpha", "beta"}, @(~) []); - end - - function failAfterResource(~, ~, services) - services.resources.set("event", "temporary", 99, @cleanupResource); - error('runtimeV2Probe:ExpectedFailure', 'Expected resource failure.'); - end - - function state = failPresentation(state, ~, ~) - state.project.parameters.failPresentation = true; - end - - function state = invalidState(state, ~, services) - state.session.cache.figure = services.figure; - end - - function view = present(state) - presentedCounts(end + 1) = state.project.parameters.count; - if state.project.parameters.failPresentation - error('runtimeV2Probe:PresentationFailure', ... - 'Expected presentation failure.'); - end - view = struct(); - view.controls.status = struct( ... - "Text", "Count: " + state.project.parameters.count); - files = [struct("id", "item1", "path", "/tmp/one.dat", ... - "status", ""), struct("id", "item2", ... - "path", "/tmp/two.dat", "status", "")]; - view.controls.files = struct(); - view.controls.files.Files = files; - view.controls.files.Selection = "item" + ... - state.session.selection.fileIndex; - view.controls.files.Status = "2 inputs"; - axisSpec = struct("Renderer", "main", ... - "Model", state.project.parameters.count); - view.previews.main.Axes = struct( ... - "first", axisSpec, "second", axisSpec); - end - - function renderMain(ax, model) - renderedModels(end + 1) = model; - cla(ax); - plot(ax, [0 1], [model model]); - end - - function cleanupResource(value) - cleanupValues(end + 1) = value; - end -end - -function invoke(button) - button.ButtonPushedFcn(button, struct()); -end - -function def = unboundEventDefinition() - project = struct('Version', 1, 'Create', @unboundEventProject, ... - 'Validate', @(value) isstruct(value)); - def = labkit.ui.runtime.define( ... - 'Command', 'unbound_event_probe', ... - 'Id', 'unbound_event_probe', 'Title', 'Unbound Event Probe', ... - 'Family', 'Test', 'AppVersion', '1.0.0', ... - 'Updated', '2026-07-15', ... - 'Requirements', labkit.contract.requirements(), ... - 'Project', project, 'Layout', @unboundEventLayout, ... - 'Actions', struct('noop', @(state, varargin) state), ... - 'Present', @(state) struct()); -end - -function project = unboundEventProject() - project = struct('inputs', struct(), 'parameters', struct(), ... - 'annotations', struct(), 'results', struct(), ... - 'extensions', struct()); -end - -function layout = unboundEventLayout(varargin) - control = labkit.ui.layout.panner('lostControl', 'Lost control', ... - 'value', 1, 'limits', [1 3], 'Event', 'noop'); - layout = labkit.ui.layout.workbench('unboundEventProbe', ... - 'Unbound Event Probe', 'controlTabs', {labkit.ui.layout.tab( ... - 'settings', 'Settings', {labkit.ui.layout.section( ... - 'controls', 'Controls', {control})})}, ... - 'workspace', labkit.ui.layout.workspace( ... - 'workspace', 'Workspace', {})); -end - -function def = unknownLayoutActionDefinition() - project = projectSpec(@projectWithGain); - def = labkit.ui.runtime.define( ... - 'Command', 'unknown_layout_action_probe', ... - 'Id', 'unknown_layout_action_probe', 'Title', 'Unknown Layout Action', ... - 'Family', 'Test', 'AppVersion', '1.0.0', ... - 'Updated', '2026-07-15', ... - 'Requirements', labkit.contract.requirements(), ... - 'Project', project, 'Layout', @unknownLayoutActionLayout, ... - 'Actions', struct('noop', @(state, varargin) state), ... - 'Present', @(state) struct()); -end - -function layout = unknownLayoutActionLayout(varargin) - control = labkit.ui.layout.field('gain', 'Gain', ... - 'Bind', 'project.parameters.gain', 'Event', 'missingAction'); - layout = probeLayout(control, {}); -end - -function def = unknownControlDefinition() - def = basicProbeDefinition('unknown_control_probe', @emptyProbeLayout, ... - @(state) struct('controls', struct('missingControl', true))); -end - -function def = unknownInteractionActionDefinition() - def = basicProbeDefinition('unknown_interaction_probe', ... - @previewProbeLayout, @interactionPresentation); -end - -function def = duplicateSourceDefinition() - def = basicProbeDefinition('duplicate_source_probe', ... - @emptyProbeLayout, @(state) struct(), @projectWithDuplicateSources); -end - -function def = basicProbeDefinition(id, layoutFactory, presenter, projectFactory) - if nargin < 4 - projectFactory = @unboundEventProject; - end - def = labkit.ui.runtime.define( ... - 'Command', id, ... - 'Id', id, 'Title', 'Identity Probe', ... - 'Family', 'Test', 'AppVersion', '1.0.0', ... - 'Updated', '2026-07-15', ... - 'Requirements', labkit.contract.requirements(), ... - 'Project', projectSpec(projectFactory), ... - 'Layout', layoutFactory, ... - 'Actions', struct('noop', @(state, varargin) state), ... - 'Present', presenter, ... - 'Renderers', struct('probe', @(varargin) [])); -end - -function spec = projectSpec(factory) - spec = struct('Version', 1, 'Create', factory, ... - 'Validate', @(value) isstruct(value)); -end - -function project = projectWithGain() - project = unboundEventProject(); - project.parameters.gain = 1; -end - -function project = projectWithDuplicateSources() - project = unboundEventProject(); - reference = struct('schemaVersion', 1, 'relativePath', '', ... - 'originalPath', '/tmp/input.dat', 'fileName', 'input.dat'); - source = struct('id', 'input', 'required', true, ... - 'role', 'input', 'reference', reference); - project.inputs.sources = [source source]; -end - -function layout = emptyProbeLayout(varargin) - layout = probeLayout( ... - labkit.ui.layout.action('noop', 'No operation', []), {}); -end - -function layout = previewProbeLayout(varargin) - preview = labkit.ui.layout.previewArea('preview', 'Preview'); - layout = probeLayout( ... - labkit.ui.layout.action('noop', 'No operation', []), {preview}); -end - -function layout = probeLayout(control, workspaceChildren) - layout = labkit.ui.layout.workbench('identityProbe', 'Identity Probe', ... - 'controlTabs', {labkit.ui.layout.tab('main', 'Main', { ... - labkit.ui.layout.section('content', 'Content', {control})})}, ... - 'workspace', labkit.ui.layout.workspace( ... - 'workspace', 'Workspace', workspaceChildren)); -end - -function view = interactionPresentation(~) - view = struct(); - view.previews.preview = struct('Renderer', 'probe', 'Model', 1); - view.interactions.editor = struct( ... - 'Kind', 'anchors', 'Targets', 'preview', 'Value', zeros(0, 2), ... - 'Event', 'missingAction', 'ImageSize', [10 10]); -end - -function value = unboundEventRequirements() - value = labkit.contract.requirements(); -end - -function value = unboundEventVersion() - value = struct('name', 'unbound_event_probe', ... - 'displayName', 'Unbound Event Probe', 'family', 'Test', ... - 'version', '1.0.0', 'updated', '2026-07-15'); -end - -function tf = containsResource(resources, scope, id) - tf = ~isempty(resources) && any( ... - [resources.scope] == scope & [resources.id] == id); -end - -function assertThrows(callback, identifier) - try - callback(); - catch ME - assert(strcmp(ME.identifier, identifier), ... - 'Expected %s but caught %s.', identifier, ME.identifier); - return; - end - error('Expected an error with identifier %s.', identifier); -end diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2WorkspaceTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2WorkspaceTest.m deleted file mode 100644 index 3eae9ccd2..000000000 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRuntimeV2WorkspaceTest.m +++ /dev/null @@ -1,224 +0,0 @@ -classdef GuiLayoutUiRuntimeV2WorkspaceTest < matlab.unittest.TestCase - %GUILAYOUTUIRUNTIMEV2WORKSPACETEST Verify workspace and table presentation. - - methods (Test, TestTags = {'GUI', 'Structural'}) - function presentation_updates_result_table_headers(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', 'hidden'); - cleanupMode = onCleanup( ... - @() setenv('LABKIT_GUI_TEST_MODE', oldMode)); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - - fig = labkit.ui.runtime.launch(@tablePresentationDefinition); - h.waitForUiIdle(fig); - ui = getappdata(fig, 'labkitUiRegistry'); - tableHandle = ui.controls.dynamicTable.table; - - testCase.verifyEqual( ... - ui.controls.dynamicPreview.panel.Layout.Column, [1 2], ... - 'Workspace previews should span the full right-side grid.'); - testCase.verifyEqual( ... - string(ui.controls.dynamicPreview.panel.Visible), "off"); - testCase.verifyEqual(ui.rightGrid.RowHeight{2}, 0, ... - 'A hidden workspace panel should collapse its row.'); - testCase.verifyEqual(ui.rightGrid.RowHeight{1}, '1x', ... - 'The visible workspace table should retain flexible height.'); - testCase.verifyEqual(string(tableHandle.ColumnName), ... - ["Alpha"; "Beta"]); - testCase.verifyEqual(string(tableHandle.RowName), ... - ["r1"; "r2"]); - testCase.verifyEqual(tableHandle.Data, {1, ''; 3, 4}); - - clear cleanupFigures cleanupMode; - end - - function workspace_tabs_build_one_native_page_group(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - - fig = labkit.ui.runtime.launch(@workspaceTabsDefinition); - h.waitForUiIdle(fig); - ui = getappdata(fig, 'labkitUiRegistry'); - - testCase.verifyEqual(numel(ui.rightGrid.RowHeight), 1); - testCase.verifyEqual(numel(ui.rightGrid.ColumnWidth), 1); - testCase.verifyEqual(ui.rightGrid.RowHeight, {'1x'}); - testCase.verifyEqual( ... - string(ui.workspace.pages.dataPage.tab.Title), "Data"); - testCase.verifyEqual( ... - string(ui.workspace.pages.plotPage.tab.Title), "Plot"); - testCase.verifyEqual( ... - ui.workspace.pages.dataPage.grid.RowHeight, {'1x', '1x'}); - testCase.verifyEqual( ... - ui.controls.workspaceTable.panel.Parent, ... - ui.workspace.pages.dataPage.grid); - testCase.verifyEqual( ... - ui.controls.workspacePlot.panel.Parent, ... - ui.workspace.pages.plotPage.grid); - - ui.workspace.tabGroup.SelectedTab = ... - ui.workspace.pages.plotPage.tab; - drawnow; - testCase.verifyEqual( ... - ui.workspace.tabGroup.SelectedTab, ... - ui.workspace.pages.plotPage.tab); - - clear cleanupFigures; - end - - function workspace_tabs_reject_mixed_single_and_empty_pages(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - - testCase.verifyError(@() labkit.ui.runtime.launch( ... - @mixedWorkspaceDefinition), ... - 'labkit:ui:runtime:InvalidChildKind'); - testCase.verifyError(@() labkit.ui.runtime.launch( ... - @singleWorkspaceTabDefinition), ... - 'labkit:ui:runtime:InvalidChildKind'); - testCase.verifyError(@() labkit.ui.runtime.launch( ... - @emptyWorkspaceTabDefinition), ... - 'labkit:ui:runtime:EmptyWorkspaceTab'); - - clear cleanupFigures; - end - end -end - -function def = workspaceTabsDefinition() - def = staticDefinition( ... - "workspace_tabs_probe", "Workspace Tabs Probe", @workspaceTabsLayout); -end - -function layout = workspaceTabsLayout() - dataPage = labkit.ui.layout.tab("dataPage", "Data", { ... - labkit.ui.layout.resultTable( ... - "workspaceTable", "Data table", "data", {1}), ... - labkit.ui.layout.statusPanel( ... - "workspaceStatus", "Data status", "value", "Ready")}); - plotPage = labkit.ui.layout.tab("plotPage", "Plot", { ... - labkit.ui.layout.previewArea("workspacePlot", "Plot")}); - layout = workspaceShape({dataPage, plotPage}); -end - -function def = mixedWorkspaceDefinition() - def = invalidWorkspaceDefinition(@mixedWorkspaceLayout); -end - -function layout = mixedWorkspaceLayout() - page = labkit.ui.layout.tab("page", "Page", { ... - labkit.ui.layout.statusPanel("pageStatus", "Status")}); - directPanel = labkit.ui.layout.statusPanel("directStatus", "Status"); - layout = workspaceShape({page, directPanel}); -end - -function def = singleWorkspaceTabDefinition() - def = invalidWorkspaceDefinition(@singleWorkspaceTabLayout); -end - -function layout = singleWorkspaceTabLayout() - page = labkit.ui.layout.tab("onlyPage", "Only page", { ... - labkit.ui.layout.statusPanel("onlyStatus", "Status")}); - layout = workspaceShape({page}); -end - -function def = emptyWorkspaceTabDefinition() - def = invalidWorkspaceDefinition(@emptyWorkspaceTabLayout); -end - -function layout = emptyWorkspaceTabLayout() - emptyPage = labkit.ui.layout.tab("emptyPage", "Empty", {}); - validPage = labkit.ui.layout.tab("validPage", "Valid", { ... - labkit.ui.layout.statusPanel("validStatus", "Status")}); - layout = workspaceShape({emptyPage, validPage}); -end - -function def = invalidWorkspaceDefinition(layoutFactory) - def = staticDefinition( ... - "invalid_workspace_probe", "Invalid Workspace Probe", layoutFactory); -end - -function def = staticDefinition(id, titleText, layoutFactory) - def = labkit.ui.runtime.define( ... - "Command", id, ... - "Id", id, ... - "Title", titleText, ... - "Family", "Test", ... - "AppVersion", "1.0.0", ... - "Updated", "2026-07-19", ... - "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ... - "Layout", layoutFactory); -end - -function layout = workspaceShape(workspaceChildren) - controlTab = labkit.ui.layout.tab("controls", "Controls", { ... - labkit.ui.layout.section("controlSection", "Controls", { ... - labkit.ui.layout.field("controlValue", "Value", ... - "kind", "number", "value", 1)})}); - layout = labkit.ui.layout.workbench( ... - "workspaceProbe", "Workspace Probe", ... - "controlTabs", {controlTab}, ... - "workspace", labkit.ui.layout.workspace( ... - "workspace", "Workspace", workspaceChildren)); -end - -function def = tablePresentationDefinition() - def = labkit.ui.runtime.define( ... - 'Command', 'table_presentation_probe', ... - 'Id', 'table_presentation_probe', ... - 'Title', 'Table Presentation Probe', ... - 'Family', 'Test', ... - 'AppVersion', '1.0.0', ... - 'Updated', '2026-07-19', ... - 'Requirements', labkit.contract.requirements(), ... - 'Project', projectSpec(), ... - 'Layout', @tablePresentationLayout, ... - 'Actions', struct('noop', @(state, varargin) state), ... - 'Present', @tablePresentation, ... - 'Renderers', struct('probe', @(varargin) [])); -end - -function spec = projectSpec() - spec = struct('Version', 1, 'Create', @createProject, ... - 'Validate', @(value) isstruct(value)); -end - -function project = createProject() - project = struct('inputs', struct('sources', ... - labkit.ui.runtime.emptySourceRecords()), ... - 'parameters', struct(), 'annotations', struct(), ... - 'results', struct(), 'extensions', struct()); -end - -function layout = tablePresentationLayout(varargin) - result = labkit.ui.layout.resultTable( ... - 'dynamicTable', 'Dynamic table', ... - 'columns', {'Initial'}, 'data', {0}); - preview = labkit.ui.layout.previewArea( ... - 'dynamicPreview', 'Dynamic preview'); - control = labkit.ui.layout.action('noop', 'No operation', []); - layout = labkit.ui.layout.workbench( ... - 'tablePresentationProbe', 'Table Presentation Probe', ... - 'controlTabs', {labkit.ui.layout.tab('main', 'Main', { ... - labkit.ui.layout.section('content', 'Content', {control})})}, ... - 'workspace', labkit.ui.layout.workspace( ... - 'workspace', 'Workspace', {result, preview})); -end - -function view = tablePresentation(~) - view = struct(); - missingValue = string(missing); - view.controls.dynamicTable = struct( ... - 'ColumnName', {{'Alpha', 'Beta'}}, ... - 'RowName', {{'r1', 'r2'}}, ... - 'Data', {{1, missingValue; 3, 4}}); - view.controls.dynamicPreview = struct('Visible', false); - view.previews.dynamicPreview = struct( ... - 'Renderer', 'probe', 'Model', 1); -end diff --git a/tests/cases/gui/labkit_framework/ui/GuiStartupLifecycleTest.m b/tests/cases/gui/labkit_framework/ui/GuiStartupLifecycleTest.m deleted file mode 100644 index 9b787cf47..000000000 --- a/tests/cases/gui/labkit_framework/ui/GuiStartupLifecycleTest.m +++ /dev/null @@ -1,125 +0,0 @@ -classdef GuiStartupLifecycleTest < matlab.unittest.TestCase - %GUISTARTUPLIFECYCLETEST Verify deferred GUI startup failures are rejected. - - methods (Test, TestTags = {'GUI', 'Structural'}) - function completed_startup_releases_busy_state_and_hides_status(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - layout = labkit.ui.layout.workbench('startupProbe', ... - 'Startup Probe', ... - 'controlTabs', {labkit.ui.layout.tab('setup', 'Setup', { ... - labkit.ui.layout.section('actions', 'Actions', { ... - labkit.ui.layout.action('run', 'Run', @noop)})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', ... - 'Preview', {labkit.ui.layout.previewArea('preview', ... - 'Preview')})); - - ui = labkit.ui.runtime.create(layout); - - testCase.verifyFalse(isappdata(ui.figure, 'labkitUiStartup'), ... - 'Completed startup should remove transient lifecycle state.'); - testCase.verifyFalse(isappdata(ui.figure, 'labkitUiBusy'), ... - 'Completed startup should release callback busy gating.'); - testCase.verifyEqual(string(ui.startupStatusPanel.Visible), "off", ... - 'Completed startup should hide the readiness surface.'); - testCase.verifyEqual(ui.main.RowHeight{2}, 0, ... - 'Completed startup should collapse the readiness row.'); - clear cleanupFigures - h.closeAllFigures(); - end - - function startup_assertion_reports_deferred_failure(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - fig = uifigure('Visible', 'off'); - startupState = struct( ... - 'failed', true, ... - 'message', "Startup failed: synthetic startup failure"); - setappdata(fig, 'labkitUiStartup', startupState); - - testCase.verifyError(@() h.assertStartupSucceeded(fig), ... - 'LabKit:Tests:GuiStartupFailed'); - clear cleanupFigures - h.closeAllFigures(); - end - - function runtime_v2_reports_ordered_startup_phases(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - oldMode = getenv('LABKIT_GUI_TEST_MODE'); - setenv('LABKIT_GUI_TEST_MODE', 'hidden'); - cleanupMode = onCleanup(@() setenv( ... - 'LABKIT_GUI_TEST_MODE', oldMode)); - cleanupFigures = onCleanup(@() h.closeAllFigures()); - messages = strings(0, 1); - - fig = labkit.ui.runtime.launch(@definition, ... - "RequestAdapter", @startupRequest); - - testCase.verifyEqual(messages, [ ... - "Creating app state..."; ... - "Preparing app layout..."; ... - "Building app shell..."; ... - "Building controls..."; ... - "Preparing workspace..."; ... - "Preparing runtime..."; ... - "Preparing first view..."; ... - "Running startup actions..."; ... - "Ready."], ... - 'Runtime startup should expose one ordered phase stream.'); - delete(fig); - clear cleanupFigures cleanupMode - - function [request, dispatchArgs] = startupRequest(~) - request = struct('startupProgress', @captureProgress); - dispatchArgs = {}; - end - - function captureProgress(message) - messages(end + 1, 1) = string(message); - end - end - end -end - -function noop(varargin) -end - -function def = definition() - project = struct('Version', 1, 'Create', @createProject, ... - 'Validate', @(value) isstruct(value)); - def = labkit.ui.runtime.define( ... - "Command", "startup_phase_probe", ... - "Id", "startup_phase_probe", ... - "Title", "Startup Phase Probe", ... - "Family", "Test", ... - "AppVersion", "1.0.0", ... - "Updated", "2026-07-15", ... - "Requirements", labkit.contract.requirements(), ... - "Project", project, ... - "Layout", @startupLayout, ... - "Actions", struct('noop', @actionNoop), ... - "Present", @(state) struct()); -end - -function state = actionNoop(state, varargin) -end - -function project = createProject() - project = struct('inputs', struct(), 'parameters', struct(), ... - 'annotations', struct(), 'results', struct(), ... - 'extensions', struct()); -end - -function layout = startupLayout(varargin) - layout = labkit.ui.layout.workbench('startupPhaseProbe', ... - 'Startup Phase Probe', ... - 'controlTabs', {labkit.ui.layout.tab('setup', 'Setup', {})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', ... - 'Preview', {labkit.ui.layout.previewArea('preview', 'Preview')})); -end diff --git a/tests/cases/gui/labkit_framework/ui/UiMatlabPlatformAdapterTest.m b/tests/cases/gui/labkit_framework/ui/UiMatlabPlatformAdapterTest.m new file mode 100644 index 000000000..07c311f1b --- /dev/null +++ b/tests/cases/gui/labkit_framework/ui/UiMatlabPlatformAdapterTest.m @@ -0,0 +1,627 @@ +classdef UiMatlabPlatformAdapterTest < matlab.unittest.TestCase + methods (Test, TestTags = {'GUI', 'Structural'}) + function reconcilesChronoLikeSemanticTree(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + app = chronoLikeApplication(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab(app); + cleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); + + testCase.verifyEqual(string(component(figure, "run").Enable), "off"); + testCase.verifyEqual(string(component(figure, "timeUnit").Value), "Time (s)"); + testCase.verifyEqual(string( ... + component(figure, "timeUnit.label").Text), "timeUnit"); + testCase.verifyEqual(component(figure, "lineWidth").Limits, [0.5 5]); + testCase.verifyEqual(string(component(figure, "files").Items), ... + ["01 first.DTA [ready]", ... + "02 second.DTA [needs review]"]); + testCase.verifyEqual( ... + component(figure, "files").UserData.Paths, ... + ["first.DTA", "second.DTA"]); + testCase.verifyEqual(string(component(figure, "log").Value), "Loaded two files"); + testCase.verifyEqual(component(figure, "analysis").UserData.Status, "Ready"); + testCase.verifyEqual(numel(component(figure, "preview.main").Children), 1); + clear cleanup + end + + function preservesManualViewportAroundRenderer(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + app = chronoLikeApplication(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab(app); + cleanup = onCleanup(@() runtime.close()); + ax = component(runtime.figureHandle(), "preview.main"); + ax.XLim = [-1 4]; + ax.YLim = [-2 3]; + runtime.applyBinding("lineWidth", 3); + + testCase.verifyEqual(ax.XLim, [-1 4]); + testCase.verifyEqual(ax.YLim, [-2 3]); + testCase.verifyEqual(string(ax.XLimMode), "manual"); + testCase.verifyEqual(string(ax.YLimMode), "manual"); + clear cleanup + end + + function replacesChoicesWhenCurrentValueDisappears(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + chronoLikeApplication()); + cleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); + + runtime.applyBinding("dynamicChoice", "ECG"); + + choice = component(figure, "dynamicChoice"); + testCase.verifyEqual(string(choice.Items), "ECG"); + testCase.verifyEqual(string(choice.Value), "ECG"); + clear cleanup + end + + function rendererFailureRestoresPreviousNativeView(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + app = chronoLikeApplication(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab(app); + cleanup = onCleanup(@() runtime.close()); + ax = component(runtime.figureHandle(), "preview.main"); + + testCase.verifyError( ... + @() runtime.applyBinding("lineWidth", 4), ... + "labkit:app:runtime:ActionFailed"); + + testCase.verifyEqual(runtime.State.project.lineWidth, 1); + testCase.verifyEqual(ax.Children(1).YData, [1 2 3]); + clear cleanup + end + + function nativeCallbacksUseTypedRuntimeEntrypoints(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + chronoLikeApplication()); + cleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); + + width = component(figure, "lineWidth"); + width.Value = 2; + width.ValueChangedFcn(width, struct()); + run = component(figure, "run"); + run.ButtonPushedFcn(run, struct()); + testCase.verifyEqual(component(figure, "data").Selection, [3 1]); + runtime.applyFileSelection("files", ["one.DTA", "two.DTA"], [1 2]); + files = component(figure, "files"); + files.Value = files.Items(2); + files.ValueChangedFcn(files, struct()); + + testCase.verifyEqual(runtime.State.project.lineWidth, 2); + testCase.verifyEqual(runtime.State.project.changeCount, 1); + testCase.verifyEqual(runtime.State.project.actionCount, 1); + testCase.verifyEqual(runtime.State.session.sourceCount, 2); + testCase.verifyEqual(runtime.State.session.selection.Indices, 2); + testCase.verifyEqual( ... + runtime.State.session.selectedSourceIds, "files-2"); + data = component(figure, "data"); + data.Data = {'A', 2; 'B', 3}; + data.CellEditCallback(data, struct( ... + "Indices", [1 2], "PreviousData", 1, "NewData", 2)); + invokeTableSelection(data, [1 1; 2 2]); + + testCase.verifyEqual(runtime.State.project.tableEditCount, 1); + testCase.verifyEqual(runtime.State.session.tableData, ... + {'A', 2; 'B', 3}); + testCase.verifyEqual(runtime.State.session.tableCells, ... + [1 1; 2 2]); + clear cleanup + end + + function reconcilesManagedRectangleAndDispatchesDirectCallback(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + interactionApplication()); + cleanup = onCleanup(@() runtime.close()); + ax = component(runtime.figureHandle(), "preview.main"); + + rectangles = findall(ax, "Type", "rectangle"); + testCase.verifyNotEmpty(rectangles); + runtime.applyInteraction( ... + "cropRegion", "interactionChanged", [2 3 4 5]); + testCase.verifyEqual(runtime.State.project.crop, [2 3 4 5]); + clear cleanup + end + + function reconcilesStructuredPointSlotsValue(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + pointSlotsApplication()); + cleanup = onCleanup(@() runtime.close()); + + runtime.applyInteraction( ... + "markers", "interactionChanged", [2 3; 4 5]); + + testCase.verifyEqual(runtime.State.project.points, [2 3; 4 5]); + clear cleanup + end + + function presentsStartupFailureInTheAppWindow(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + startupFailureApplication()); + cleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); + + failure = getappdata(figure, "labkitAppStartupFailure"); + testCase.verifyTrue(runtime.StartupFailed); + testCase.verifyTrue(failure.failed); + testCase.verifyEqual(failure.identifier, ... + "labkit:app:runtime:ActionFailed"); + testCase.verifyEqual(failure.message, ... + "Startup failed: Synthetic startup probe failed."); + testCase.verifyEqual(string(component( ... + figure, "labkitAppStartupStatus").Visible), "on"); + testCase.verifyEqual(string(component( ... + figure, "labkitAppStartupStatusLabel").Text), ... + failure.message); + testCase.verifyFalse(isappdata(figure, "labkitAppBusy")); + clear cleanup + end + + function enforcesSharedWorkbenchVisualPolicy(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + visualPolicyApplication()); + cleanup = onCleanup(@() runtime.close()); + figure = runtime.figureHandle(); + testCase.verifyEmpty(findall(figure, ... + "Tag", "labkitAppStartupStatus")); + testCase.verifyFalse(isappdata(figure, "labkitAppBusy")); + runtime.showFigure(); + figure.Visible = "off"; + testCase.verifyEqual(string(figure.Name), ... + "Visual policy probe v1.0.0 (2026-07-19)"); + + workbench = component(figure, "labkitAppWorkbenchGrid"); + testCase.verifyEqual(workbench.ColumnWidth{1}, 420); + testCase.verifyEqual(workbench.ColumnWidth{2}, 6); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "labkitAppColumnResize")), 1); + testCase.verifyGreaterThanOrEqual(numel(findall(figure, ... + "Tag", "labkitAppRowResize")), 3); + + tabLayout = component(figure, "mainTab.layout"); + testCase.verifyEqual(string(tabLayout.Scrollable), "on"); + fieldLayout = component(figure, "visualChoice.layout"); + choice = component(figure, "visualChoice"); + testCase.verifyEqual(choice.Layout.Row, 1); + testCase.verifyEqual(fieldLayout.Layout.Row, 2); + testCase.verifyEqual(string(choice.Enable), "off"); + testCase.verifyEqual(string(component( ... + figure, "visualChoice.label").Enable), "off"); + gain = component(figure, "visualGain"); + testCase.verifyTrue(contains(string(class(gain)), "Spinner")); + testCase.verifyEqual(gain.Step, 0.1); + testCase.verifyEqual(string(gain.ValueDisplayFormat), "%.2f"); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "visualGain.slider")), 1); + rangeStart = component(figure, "visualRange"); + rangeEnd = component(figure, "visualRange.end"); + testCase.verifyTrue(contains(string(class(rangeStart)), ... + "NumericEditField")); + testCase.verifyTrue(contains(string(class(rangeEnd)), ... + "NumericEditField")); + autoFirst = component(figure, "visualAutoFirst"); + autoSecond = component(figure, "visualAutoSecond"); + autoThird = component(figure, "visualAutoThird"); + visualRun = component(figure, "visualRun"); + visualReset = component(figure, "visualReset"); + titledGroup = component(figure, "visualButtons"); + drawnow; + testCase.verifyEqual(string(titledGroup.Title), "Commands"); + testCase.verifyEqual(string(titledGroup.BorderType), "line"); + testCase.verifyGreaterThanOrEqual(visualRun.Position(4), 22); + testCase.verifyGreaterThanOrEqual(visualReset.Position(4), 22); + testCase.verifyEqual(string(visualRun.Text), "Run"); + testCase.verifyEqual(string(visualReset.Text), "Reset"); + testCase.verifyEqual(autoFirst.Layout.Column, 1); + testCase.verifyEqual(autoSecond.Layout.Column, 2); + testCase.verifyEqual(autoThird.Layout.Column, [1 2]); + testCase.verifyEqual(string(autoThird.Tooltip), ... + "Explain the third scientific action."); + testCase.verifyFalse(contains(string(autoThird.Tooltip), newline)); + + visualFileChoose = component(figure, "visualFile.choose"); + testCase.verifyEqual(string(visualFileChoose.Tooltip), ... + "Choose one calibrated probe image."); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "visualFile.status")), 1); + testCase.verifyEqual(string(component( ... + figure, "visualFile.status").Value), "Image loaded"); + testCase.verifyEmpty(findall(figure, ... + "Tag", "visualFile.remove")); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "visualFiles.folder")), 1); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "visualFiles.recursiveFolder")), 1); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "visualFiles.status")), 1); + testCase.verifyEqual(string(component( ... + figure, "visualFiles.status").Value), ... + "Two files queued"); + + logPanel = component(figure, "visualLog.panel"); + testCase.verifyEqual(string(logPanel.Title), "Log"); + testCase.verifyEqual(string(logPanel.BorderType), "line"); + logTabLayout = component(figure, "logTab.layout"); + logSectionLayout = component(figure, ... + "visualLogSection.layout"); + testCase.verifyEqual(string(logTabLayout.RowHeight{1}), "1x"); + testCase.verifyEqual(string(logTabLayout.Scrollable), "off"); + testCase.verifyEqual(string( ... + logSectionLayout.RowHeight{1}), "1x"); + follow = component(figure, "visualLog.follow"); + testCase.verifyEqual(string(follow.Text), "Pause auto-scroll"); + testCase.verifyEqual(string(component( ... + figure, "visualLog").Value), "Ready."); + logSection = component(figure, "visualLog.panel").Parent; + testCase.verifyGreaterThan(logSection.Position(4), ... + 0.75 * logTabLayout.Position(4)); + testCase.verifyEqual(string(component( ... + figure, "applicationUsage").Value), ... + ["Choose inputs"; "Run analysis"]); + + mode = component(figure, "visualPlot.viewMode"); + testCase.verifyEqual(string(mode.Value), "Second"); + leftAxes = component(figure, "visualPlot.left"); + rightAxes = component(figure, "visualPlot.right"); + testCase.verifyNotEmpty(figure.WindowScrollWheelFcn, ... + "Declared plot navigation must work without an ROI editor."); + testCase.verifyEqual(leftAxes.Layout.Row, 1); + testCase.verifyEqual(leftAxes.Layout.Column, 1); + testCase.verifyEqual(rightAxes.Layout.Row, 1); + testCase.verifyEqual(rightAxes.Layout.Column, 2); + testCase.verifyEqual(string(leftAxes.Title.String), "Left"); + testCase.verifyEqual(string(rightAxes.XLabel.String), "Time"); + testCase.verifyEqual(string(getappdata(rightAxes, ... + "labkitPreviewScrollZoomAxes")), "x"); + + menuTags = ["labkitAppUtilityPlotMenu", ... + "labkitAppUtilityScreenshot"]; + for tag = menuTags + testCase.verifyEqual(numel(findall( ... + figure, "Tag", tag)), 1); + end + figure.CloseRequestFcn(figure, []); + testCase.verifyTrue(isvalid(figure)); + testCase.verifyEqual(numel(findall(figure, ... + "Tag", "labkitAppClosePrompt")), 1); + clear cleanup + end + + function updatesVersionedDocumentTitleAcrossSaveAndEdit(testCase) + setupLabKitTestPath(); + helpers = guiTestHelpers(); + helpers.assertUifigureAvailable(); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + chronoLikeApplication()); + cleanup = onCleanup(@() runtime.close()); + filepath = string(tempname) + ".mat"; + cleanupFile = onCleanup(@() deleteIfPresent(filepath)); + figure = runtime.figureHandle(); + runtime.showFigure(); + figure.Visible = "off"; + + testCase.verifyEqual(string(figure.Name), ... + "Chrono probe v1.0.0 (2026-07-19) *"); + runtime.saveProject(runtime.State, filepath); + testCase.verifyEqual(string(figure.Name), ... + "Chrono probe v1.0.0 (2026-07-19)"); + runtime.applyBinding("lineWidth", 2); + testCase.verifyEqual(string(figure.Name), ... + "Chrono probe v1.0.0 (2026-07-19) *"); + clear cleanupFile cleanup + end + end +end + +function app = startupFailureApplication() +app = labkit.app.Definition( ... + Entrypoint="labkit_StartupFailureProbe_app", ... + AppId="probe.startup-failure", Title="Startup failure probe", ... + Family="Tests", AppVersion="1.0.0", Updated="2026-07-20", ... + Requirements=[], Workbench=labkit.app.layout.workbench({}), ... + OnStart=@failStartup); +end + +function state = failStartup(~, ~) +error("probe:StartupFailure", "Synthetic startup probe failed."); +state = struct(); +end + +function app = visualPolicyApplication() +main = labkit.app.layout.tab("mainTab", "Main", { ... + labkit.app.layout.section("visualInputs", "Inputs", { ... + labkit.app.layout.fileList("visualFile", Label="Image", ... + SelectionMode="single", MaxFiles=1, ... + ChooseTooltip="Choose one calibrated probe image."), ... + labkit.app.layout.field("visualChoice", Label="Mode:", ... + Kind="choice", Choices=["One", "Two"]), ... + labkit.app.layout.slider("visualGain", Label="Gain:", ... + Limits=[-1 1], Step=0.1, ValueDisplayFormat="%.2f"), ... + labkit.app.layout.rangeField("visualRange", Label="Range:", ... + Value=[0.2 0.8], Limits=[0 1]), ... + labkit.app.layout.field("visualResult", Label="Result:", ... + Kind="readonly", Value="-")}), ... + labkit.app.layout.section("visualActions", "Actions", { ... + labkit.app.layout.group("visualButtons", { ... + labkit.app.layout.button("visualRun", "Run", @noOp, ... + Tooltip="Run the visual probe."), ... + labkit.app.layout.button("visualReset", "Reset", @noOp, ... + Tooltip="Reset the visual probe.")}, ... + Layout="horizontal", Title="Commands"), ... + labkit.app.layout.group("visualAutoButtons", { ... + labkit.app.layout.button("visualAutoFirst", "First", @noOp, ... + Tooltip="Run the first visual action."), ... + labkit.app.layout.button("visualAutoSecond", "Second", @noOp, ... + Tooltip="Run the second visual action."), ... + labkit.app.layout.button("visualAutoThird", ... + "Third action", @noOp, ... + Tooltip="Explain the third scientific action.")})}), ... + labkit.app.layout.section("visualFileBatch", "Batch", { ... + labkit.app.layout.fileList("visualFiles", ... + Label="Files", SelectionMode="multiple")})}); +log = labkit.app.layout.tab("logTab", "Log", { ... + labkit.app.layout.section("visualLogSection", "Log", { ... + labkit.app.layout.logPanel("visualLog", Title="Log")})}); +workspace = labkit.app.layout.workspace( ... + labkit.app.layout.plotArea("visualPlot", @drawVisualPolicy, ... + Title="Plots", Layout="pair", AxisIds=["left", "right"], ... + AxisTitles=["Left", "Right"], XLabels=["", "Time"], ... + YLabels=["Value", ""], ColumnWidths={120, '1x'}, ... + ScrollZoomAxes=["xy", "x"], ViewModes=["First", "Second"], ... + OnValueChanged=@changeVisualMode)); +app = labkit.app.Definition( ... + Entrypoint="labkit_VisualPolicyProbe_app", ... + AppId="probe.visual-policy", Title="Visual policy probe", ... + Family="Tests", AppVersion="1.0.0", Updated="2026-07-19", ... + Requirements=[], Workbench=labkit.app.layout.workbench( ... + {main, log}, Workspace=workspace, ... + Usage=["Choose inputs", "Run analysis"]), ... + PresentWorkbench=@presentVisualPolicy); +end + +function state = noOp(state, ~) +end + +function state = changeVisualMode(state, value, ~) +state.session.visualMode = value; +end + +function drawVisualPolicy(axesById, ~) +plot(axesById.left, 1:2, 1:2); +plot(axesById.right, 1:2, 2:-1:1); +end + +function view = presentVisualPolicy(~) +view = labkit.app.view.Snapshot() ... + .text("visualFile", "Image loaded") ... + .text("visualFiles", "Two files queued") ... + .enabled("visualChoice", false) ... + .value("visualPlot", "Second") ... + .renderPlot("visualPlot", struct()); +end + +function app = interactionApplication() +interaction = labkit.app.interaction.rectangle( ... + "cropRegion", @changeCrop); +plot = labkit.app.layout.plotArea( ... + "preview", @drawInteractionImage, ... + Interactions={interaction}); +app = labkit.app.Definition( ... + Entrypoint="labkit_InteractionProbe_app", ... + AppId="probe.interaction", Title="Interaction probe", ... + Family="Tests", AppVersion="1.0.0", Updated="2026-07-19", ... + Requirements=[], Workbench=labkit.app.layout.workbench({}, ... + Workspace=labkit.app.layout.workspace(plot)), ... + PresentWorkbench=@presentInteraction); +end + +function state = changeCrop(state, position, ~) +state.project.crop = position; +end + +function drawInteractionImage(axesById, ~) +imagesc(axesById.main, zeros(10)); +end + +function view = presentInteraction(state) +position = [1 1 3 3]; +if isfield(state.project, "crop") + position = state.project.crop; +end +view = labkit.app.view.Snapshot() ... + .renderPlot("preview", struct()) ... + .rectangle("cropRegion", position, ImageSize=[10 10]); +end + +function app = pointSlotsApplication() +interaction = labkit.app.interaction.pointSlots( ... + "markers", @changePoints); +plot = labkit.app.layout.plotArea( ... + "preview", @drawInteractionImage, Interactions={interaction}); +app = labkit.app.Definition( ... + Entrypoint="labkit_PointSlotsProbe_app", ... + AppId="probe.point-slots", Title="Point slots probe", ... + Family="Tests", AppVersion="1.0.0", Updated="2026-07-19", ... + Requirements=[], Workbench=labkit.app.layout.workbench({}, ... + Workspace=labkit.app.layout.workspace(plot)), ... + PresentWorkbench=@presentPointSlots); +end + +function state = changePoints(state, points, ~) +state.project.points = points; +end + +function view = presentPointSlots(state) +points = NaN(2, 2); +if isfield(state.project, "points") + points = state.project.points; +end +value = struct("points", points, "selectedIndex", 2, "locked", false); +view = labkit.app.view.Snapshot() ... + .renderPlot("preview", struct()) ... + .pointSlots("markers", value, ImageSize=[10 10]); +end + +function app = chronoLikeApplication() +content = labkit.app.layout.group("controls", { ... + labkit.app.layout.button("run", "Run", @runAnalysis, ... + Tooltip="Run the probe analysis."), ... + labkit.app.layout.field("timeUnit", Kind="choice", ... + Value="Time (s)", Choices=["Time (s)", "Time (ms)"]), ... + labkit.app.layout.field("dynamicChoice", Kind="choice", ... + Choices="(none)", Bind="project.dynamicChoice"), ... + labkit.app.layout.slider("lineWidth", Value=1, Limits=[0.5 5], ... + Bind="project.lineWidth", OnValueChanged=@lineWidthChanged), ... + labkit.app.layout.fileList("files", Bind="project.sources", ... + SelectionBind="session.selection", ... + OnSelectionChanged=@fileSelectionChanged)}); +dataTable = labkit.app.layout.dataTable("data", ... + Columns=["Group", "Value"], ColumnEditable=[true true], ... + OnCellEdited=@tableEdited, ... + OnCellSelectionChanged=@tableSelected); +workspace = labkit.app.layout.workspace(); +workspace = workspace.page("analysis", "Analysis", ... + labkit.app.layout.plotArea("preview", @draw)); +app = labkit.app.Definition(Entrypoint="labkit_ChronoProbe_app", ... + AppId="probe.chrono", Title="Chrono probe", Family="Tests", ... + AppVersion="1.0.0", Updated="2026-07-19", Requirements=[], ... + ProjectSchema=labkit.app.project.Schema(Version=1, ... + Create=@createProject, Validate=@validateProject), ... + CreateSession=@createSession, ... + PresentWorkbench=@presentProbe, ... + Workbench=labkit.app.layout.workbench({content, dataTable, ... + labkit.app.layout.logPanel("log"), ... + labkit.app.layout.statusPanel("status")}, Workspace=workspace)); +end + +function view = presentProbe(state) +view = labkit.app.view.Snapshot() ... + .enabled("run", false) ... + .value("timeUnit", "Time (s)") ... + .choices("timeUnit", ["Time (s)", "Time (ms)"]) ... + .choices("dynamicChoice", dynamicChoices(state.project.dynamicChoice)) ... + .value("dynamicChoice", state.project.dynamicChoice) ... + .value("lineWidth", state.project.lineWidth) ... + .limits("lineWidth", [0.5 5]) ... + .filePaths("files", ["first.DTA", "second.DTA"]) ... + .fileItemStatuses("files", ["ready", "needs review"]) ... + .tableData("data", state.session.tableData, ... + Columns=["Group", "Value"], ColumnEditable=[true true]) ... + .tableCellSelection("data", ... + labkit.app.event.TableCellSelection(state.session.tableCells)) ... + .text("log", "Loaded two files") ... + .text("status", "Ready") ... + .workspacePage("analysis", Enabled=true, Status="Ready") ... + .renderPlot("preview", state.project.lineWidth); +end + +function project = createProject() +project = struct("lineWidth", 1, "changeCount", 0, "actionCount", 0, ... + "tableEditCount", 0, ... + "dynamicChoice", "(none)", ... + "sources", struct([])); +end + +function accepted = validateProject(project) +accepted = isstruct(project) && isscalar(project) && ... + isfield(project, "lineWidth") && isnumeric(project.lineWidth) && ... + isscalar(project.lineWidth) && isfinite(project.lineWidth) && ... + isfield(project, "changeCount") && isfield(project, "actionCount") && ... + isfield(project, "tableEditCount") && ... + isfield(project, "dynamicChoice") && ... + isfield(project, "sources"); +end + +function choices = dynamicChoices(value) +choices = "(none)"; +if string(value) == "ECG" + choices = "ECG"; +end +end + +function draw(axesById, scale) +ax = axesById.main; +cla(ax); +plot(ax, 1:3, scale * (1:3)); +xlim(ax, [0 10]); +ylim(ax, [0 20]); +if scale == 4 + error("probe:RendererFailure", "Injected renderer failure."); +end +end + +function state = runAnalysis(state, ~) +state.project.actionCount = state.project.actionCount + 1; +state.session.tableData = {'A', 1; 'B', 2; 'C', 3}; +state.session.tableCells = [3 1]; +end + +function state = lineWidthChanged(state, ~, ~) +state.project.changeCount = state.project.changeCount + 1; +end + +function session = createSession(project, ~) +session = struct("selection", labkit.app.event.ListSelection(), ... + "sourceCount", numel(project.sources), ... + "selectedSourceIds", strings(1, 0), ... + "tableData", {{"A", 1; "B", 2}}, ... + "tableCells", zeros(0, 2)); +end + +function state = fileSelectionChanged(state, selection, ~) +state.session.selectedSourceIds = selection.Ids; +end + +function state = tableEdited(state, edit, ~) +state.session.tableData = edit.Data; +state.session.tableCells = zeros(0, 2); +state.project.tableEditCount = state.project.tableEditCount + 1; +end + +function state = tableSelected(state, selection, ~) +state.session.tableCells = selection.CellIndices; +end + +function invokeTableSelection(tableHandle, cells) +if isprop(tableHandle, "SelectionChangedFcn") && ... + ~isempty(tableHandle.SelectionChangedFcn) + tableHandle.SelectionChangedFcn(tableHandle, struct("Selection", cells)); +else + tableHandle.CellSelectionCallback(tableHandle, struct("Indices", cells)); +end +end + +function value = component(figure, tag) +value = findall(figure, "Tag", char(tag)); +assert(isscalar(value), "Expected one component with Tag %s.", tag); +end + +function deleteIfPresent(filepath) +if isfile(filepath) + delete(filepath); +end +end diff --git a/tests/cases/gui/project/launcher/LauncherCatalogGuiTest.m b/tests/cases/gui/project/launcher/LauncherCatalogGuiTest.m index 88f6e29b0..cf120b188 100644 --- a/tests/cases/gui/project/launcher/LauncherCatalogGuiTest.m +++ b/tests/cases/gui/project/launcher/LauncherCatalogGuiTest.m @@ -45,15 +45,14 @@ function launcher_reads_version_from_single_definition(testCase) 'end\n'])); writeText(fullfile(packageFolder, "definition.m"), sprintf([ ... 'function def = definition()\n' ... - 'def = labkit.ui.runtime.define( ...\n' ... - ' "Command", "labkit_DefinitionMetadataProbe_app", ...\n' ... - ' "Id", "definition_metadata_probe", ...\n' ... - ' "Title", "Definition Metadata Probe", ...\n' ... - ' "Family", "Probe", ...\n' ... - ' "AppVersion", "3.2.1", ...\n' ... - ' "Updated", "2026-07-16", ...\n' ... - ' "Requirements", labkit.contract.requirements("ui", ">=7 <8"), ...\n' ... - ' "Layout", @() struct());\n' ... + 'def = labkit.app.Definition( ...\n' ... + ' Entrypoint="labkit_DefinitionMetadataProbe_app", ...\n' ... + ' AppId="definition_metadata_probe", ...\n' ... + ' Title="Definition Metadata Probe", ...\n' ... + ' Family="Probe", AppVersion="3.2.1", ...\n' ... + ' Updated="2026-07-16", ...\n' ... + ' Requirements=labkit.contract.requirements("app", ">=1 <2"), ...\n' ... + ' Workbench=labkit.app.layout.workbench({}));\n' ... 'end\n'])); apps = labkit_launcher("list"); diff --git a/tests/cases/gui/project/launcher/LauncherGuiTest.m b/tests/cases/gui/project/launcher/LauncherGuiTest.m index 18da22a15..9c41a8be8 100644 --- a/tests/cases/gui/project/launcher/LauncherGuiTest.m +++ b/tests/cases/gui/project/launcher/LauncherGuiTest.m @@ -62,7 +62,7 @@ function launcher_updates_documentation_site(testCase) h.invokeButton(fig, "Update Documentation"); drawnow; - ui = getappdata(fig, 'labkitUiRegistry'); + ui = getappdata(fig, 'labkitLauncherView'); status = string(ui.controls.statusLine.textArea.Value); testCase.verifyTrue(any(contains(status, ... "Documentation updated:")), ... @@ -126,6 +126,73 @@ function launcher_defers_app_folder_path_setup_until_launch(testCase) cd(originalFolder); end + function launcher_opens_app_sdk_entrypoint_without_retired_arguments(testCase) + setupLabKitTestPath(); + h = guiTestHelpers(); + h.assertUifigureAvailable(); + h.closeAllFigures(); + cleanupFigures = onCleanup(@() h.closeAllFigures()); + + fig = labkit_launcher(); + drawnow; + tables = findall(fig, 'Type', 'uitable'); + testCase.verifyNumElements(tables, 1); + commands = string(tables(1).Data(:, 7)); + row = find(commands == "labkit_DICPreprocess_app", 1); + testCase.assertNotEmpty(row, ... + "Launcher catalog must include DIC Preprocess."); + + invokeTableSelection(tables(1), row); + h.invokeButton(fig, 'Open Selected App'); + drawnow; + + textAreas = findall(fig, 'Type', 'uitextarea'); + messages = strings(0, 1); + for k = 1:numel(textAreas) + messages = [messages; string(textAreas(k).Value(:))]; + end + testCase.verifyTrue(any(contains(messages, ... + "Launched labkit_DICPreprocess_app.")), ... + "Launcher must call the App SDK entrypoint without retired arguments."); + clear cleanupFigures + h.closeAllFigures(); + end + + function launcher_debug_uses_verbose_sdk_diagnostics_and_sample(testCase) + root = setupLabKitTestPath(); + h = guiTestHelpers(); + h.assertUifigureAvailable(); + h.closeAllFigures(); + cleanupFigures = onCleanup(@() h.closeAllFigures()); + debugRoot = fullfile(root, "artifacts", "diagnostics", ... + "launcher", "labkit_DICPreprocess_app"); + existing = folderNames(debugRoot); + + fig = labkit_launcher(); + drawnow; + tables = findall(fig, 'Type', 'uitable'); + commands = string(tables(1).Data(:, 7)); + row = find(commands == "labkit_DICPreprocess_app", 1); + testCase.assertNotEmpty(row); + invokeTableSelection(tables(1), row); + h.invokeButton(fig, 'Open Debug'); + drawnow; + + created = setdiff(folderNames(debugRoot), existing, "stable"); + testCase.verifyNumElements(created, 1, ... + "Debug launch should create one isolated diagnostic session."); + sessionFolder = fullfile(debugRoot, created(1)); + testCase.addTeardown(@() removeFolderIfPresent(sessionFolder)); + testCase.verifyTrue(isfile(fullfile(sessionFolder, "events.jsonl")), ... + "Verbose diagnostics should write structured runtime events."); + testCase.verifyTrue(isfile(fullfile(sessionFolder, "sample-pack.json")), ... + "Debug launch should build the App-owned synthetic sample."); + assertInfoContains(fig, ... + "Launched labkit_DICPreprocess_app in debug mode."); + clear cleanupFigures + h.closeAllFigures(); + end + function clean_artifacts_has_static_safety_guards(testCase) root = setupLabKitTestPath(); source = fileread(fullfile(root, "labkit_launcher.m")); @@ -282,7 +349,7 @@ function launcher_refresh_handles_added_and_removed_apps(testCase) fig = labkit_launcher(); drawnow; - ui = getappdata(fig, 'labkitUiRegistry'); + ui = getappdata(fig, 'labkitLauncherView'); tableHandle = ui.controls.appTable.table; betaRow = find(string(tableHandle.Data(:, 7)) == "labkit_Beta_app", 1); invokeTableSelection(tableHandle, betaRow); @@ -380,7 +447,7 @@ function verify_launcher_layout() end function assertRefreshPreservesSelectedApp(fig, h) - ui = getappdata(fig, 'labkitUiRegistry'); + ui = getappdata(fig, 'labkitLauncherView'); tableHandle = ui.controls.appTable.table; if size(tableHandle.Data, 1) < 2 return; @@ -395,7 +462,7 @@ function assertRefreshPreservesSelectedApp(fig, h) 'Refreshing the launcher app list should preserve the selected app when it still exists.'); end function assertDetailsCommand(fig, expectedCommand, message) - ui = getappdata(fig, 'labkitUiRegistry'); + ui = getappdata(fig, 'labkitLauncherView'); details = string(ui.controls.selectedDetails.textArea.Value); assert(any(contains(details, "Command: " + string(expectedCommand))), message); end @@ -460,7 +527,7 @@ function assertLauncherTextAreasHaveRoom(fig) drawnow; pause(0.5); drawnow; - ui = getappdata(fig, 'labkitUiRegistry'); + ui = getappdata(fig, 'labkitLauncherView'); assert(isvalid(ui.controls.selectedDetails.textArea) && ... ~isempty(ui.controls.selectedDetails.textArea.Value), ... 'Selected App details should preserve a readable status panel.'); @@ -527,11 +594,15 @@ function assertLauncherActionGroups(fig) "Documentation and History"]); assert(all(arrayfun(@(button) isequal(button.Parent, runButtons(1).Parent), ... runButtons)), 'Run actions should share one titled action group.'); - runRows = arrayfun(@(button) button.Layout.Row, runButtons); - runColumns = arrayfun(@(button) button.Layout.Column, runButtons); - assert(isequal(runRows, [1 1 2 2]) && ... - isequal(runColumns, [1 2 1 2]), ... - 'Run actions should use a two-by-two task-oriented layout.'); + assert(isequal(runButtons(1).Layout.Row, 1) && ... + isequal(runButtons(1).Layout.Column, 1) && ... + isequal(runButtons(2).Layout.Row, 1) && ... + isequal(runButtons(2).Layout.Column, 2) && ... + isequal(runButtons(3).Layout.Row, 2) && ... + isequal(runButtons(3).Layout.Column, 1) && ... + isequal(runButtons(4).Layout.Row, 2) && ... + isequal(runButtons(4).Layout.Column, 2), ... + 'Run actions should separate normal and diagnostic App SDK launches.'); maintenanceButtons = arrayfun(@(text) findLauncherButton(fig, text), ... ["Update Documentation", "Run Code Analyzer", ... @@ -573,7 +644,7 @@ function assertPanelTitles(fig, expectedTitles) end function assertInfoContains(fig, expectedText) - ui = getappdata(fig, 'labkitUiRegistry'); + ui = getappdata(fig, 'labkitLauncherView'); value = string(ui.controls.statusLine.textArea.Value); assert(any(contains(value, expectedText)), ... 'Launcher info text should include: %s', expectedText); @@ -625,6 +696,17 @@ function removeFolderIfPresent(folder) end end +function names = folderNames(folder) + names = strings(0, 1); + if exist(folder, "dir") ~= 7 + return; + end + entries = dir(folder); + entries = entries([entries.isdir]); + names = string({entries.name}); + names = names(~ismember(names, [".", ".."])); +end + function removePathIfPresent(folder) if pathContainsExact(folder) rmpath(char(folder)); diff --git a/tests/cases/gui/project/launcher/LauncherProfilerTest.m b/tests/cases/gui/project/launcher/LauncherProfilerTest.m index 7190a5340..6f39b5e7d 100644 --- a/tests/cases/gui/project/launcher/LauncherProfilerTest.m +++ b/tests/cases/gui/project/launcher/LauncherProfilerTest.m @@ -102,7 +102,7 @@ function createProfileProbeApp(root) end function assertInfoContains(fig, expectedText) - ui = getappdata(fig, 'labkitUiRegistry'); + ui = getappdata(fig, 'labkitLauncherView'); value = string(ui.controls.statusLine.textArea.Value); assert(any(contains(value, expectedText)), ... 'Launcher info text should include: %s', expectedText); diff --git a/tests/cases/gui/project/launcher/LauncherProgressFeedbackTest.m b/tests/cases/gui/project/launcher/LauncherProgressFeedbackTest.m index ecf0594fd..e5eaefd4b 100644 --- a/tests/cases/gui/project/launcher/LauncherProgressFeedbackTest.m +++ b/tests/cases/gui/project/launcher/LauncherProgressFeedbackTest.m @@ -52,10 +52,18 @@ function launcher_actions_use_busy_feedback(testCase) 'App launch should leave the shared busy state after the app entry point returns.'); testCase.verifyNotEmpty(strfind(runBody, ... 'setStatus(launchHandOffStatus(app, debugMode));'), ... - 'App launch should explain where startup phases are displayed.'); + 'App launch should report the App SDK runtime handoff.'); testCase.verifyNotEmpty(strfind(runBody, ... - 'feval(app.command, "RequestAdapter", requestAdapter);'), ... - 'App launch should keep the progress dialog connected to runtime phases.'); + 'feval(app.command);'), ... + 'App launch should call the App SDK entrypoint without retired launch arguments.'); + testCase.verifyEmpty(strfind(runBody, 'RequestAdapter'), ... + 'App launch must not pass the retired runtime RequestAdapter.'); + testCase.verifyNotEmpty(strfind(runBody, ... + 'feval(app.command, "Diagnostics", diagnostics);'), ... + 'Debug launch should pass only the typed App SDK diagnostic contract.'); + testCase.verifyNotEmpty(strfind(source, ... + 'Level="verbose", ArtifactFolder=folder, Sample="synthetic"'), ... + 'Debug launch should persist verbose events and request the App sample.'); testCase.verifyNotEmpty(strfind(runBody, ... 'uiprogressdlg(fig, ''Title'', ''Profile LabKit App'''), ... 'Profiling the next app should show visible progress while waiting for the app to close.'); diff --git a/tests/cases/unit/apps/dic/DicDebugSamplePackTest.m b/tests/cases/unit/apps/dic/DicDebugSamplePackTest.m index 820f3b3ce..8db0605b7 100644 --- a/tests/cases/unit/apps/dic/DicDebugSamplePackTest.m +++ b/tests/cases/unit/apps/dic/DicDebugSamplePackTest.m @@ -6,33 +6,76 @@ function dic_debug_sample_packs_read_through_app_io(testCase) setupLabKitTestPath(); root = string(tempname); cleanup = onCleanup(@() cleanupFolder(root)); - mkdir(char(root)); - debug = labkit.ui.debug.context("dic_debug_sample_test", struct( ... - "logFile", fullfile(char(root), "trace.log"))); + context = labkit.app.diagnostic.SampleContext(root); - pre = dic_preprocess.debug.writeSamplePack(debug); - ref = imread(char(pre.representativeFiles.reference)); - moving = imread(char(pre.representativeFiles.moving)); + pre = dic_preprocess.debug.writeSamplePack(context); + testCase.verifyClass(pre, "labkit.app.diagnostic.SamplePack"); + testCase.verifyTrue( ... + dic_preprocess.projectSpec().Validate(pre.InitialProject)); + ref = imread(artifactPath(context, pre, "referenceImage")); + moving = imread(artifactPath(context, pre, "movingImage")); testCase.verifySize(ref, size(moving), ... "DIC preprocess representative pair should have matching dimensions."); - verifyThrows(testCase, @() imread(char(pre.boundaryFiles.malformedImage)), ... + verifyThrows(testCase, ... + @() imread(artifactPath(context, pre, "malformedImage")), ... "Malformed DIC image should fail through imread."); - post = dic_postprocess.debug.writeSamplePack(debug); - strain = dic_postprocess.sourceFiles.loadNcorrStrain(char(post.representativeFiles.mat)); + post = dic_postprocess.debug.writeSamplePack(context); + testCase.verifyClass(post, "labkit.app.diagnostic.SamplePack"); + testCase.verifyTrue( ... + dic_postprocess.projectSpec().Validate(post.InitialProject)); + strain = dic_postprocess.sourceFiles.loadNcorrStrain( ... + artifactPath(context, post, "strain")); testCase.verifyTrue(isfield(strain, "exx") && isfield(strain, "eyy")); - testCase.verifySize(imread(char(post.representativeFiles.reference)), ... - size(imread(char(post.representativeFiles.mask)))); - edge = dic_postprocess.sourceFiles.loadNcorrStrain(char(post.boundaryFiles.validEdgeSparseRoiMat)); + testCase.verifySize( ... + imread(artifactPath(context, post, "referenceImage")), ... + size(imread(artifactPath(context, post, "maskImage")))); + edge = dic_postprocess.sourceFiles.loadNcorrStrain( ... + artifactPath(context, post, "sparseRoiStrain")); testCase.verifyTrue(any(edge.roiMask(:)), ... "DIC postprocess edge MAT should include a readable sparse ROI."); verifyThrows(testCase, ... - @() dic_postprocess.sourceFiles.loadNcorrStrain(char(post.boundaryFiles.malformedMissingStrainsMat)), ... + @() dic_postprocess.sourceFiles.loadNcorrStrain( ... + artifactPath(context, post, "malformedStrain")), ... "Malformed DIC MAT should fail through app IO."); end + + function dic_definitions_start_from_typed_synthetic_projects(testCase) + setupLabKitTestPath(); + root = string(tempname); + cleanup = onCleanup(@() cleanupFolder(root)); + + preprocess = startSynthetic( ... + dic_preprocess.definition(), fullfile(root, "preprocess")); + cleanupPreprocess = onCleanup(@() preprocess.close()); + testCase.verifyEqual( ... + string({preprocess.State.project.inputs.sources.role}), ... + ["referenceImage", "movingImage"]); + + postprocess = startSynthetic( ... + dic_postprocess.definition(), fullfile(root, "postprocess")); + cleanupPostprocess = onCleanup(@() postprocess.close()); + testCase.verifyEqual( ... + string({postprocess.State.project.inputs.sources.role}), ... + ["strain", "reference", "mask"]); + clear cleanupPostprocess cleanupPreprocess cleanup + end end end +function runtime = startSynthetic(definition, folder) +options = labkit.app.diagnostic.Options( ... + Level="verbose", ArtifactFolder=folder, Sample="synthetic"); +runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), options); +end + +function filepath = artifactPath(context, pack, id) +matches = cellfun(@(artifact) artifact.Id == id, pack.Artifacts); +artifact = pack.Artifacts{matches}; +filepath = char(fullfile(context.ArtifactFolder, artifact.RelativePath)); +end + function verifyThrows(testCase, fcn, message) didThrow = false; try diff --git a/tests/cases/unit/apps/dic/DicPostprocessIoExportTest.m b/tests/cases/unit/apps/dic/DicPostprocessIoExportTest.m index 866a605b8..eae5ba668 100644 --- a/tests/cases/unit/apps/dic/DicPostprocessIoExportTest.m +++ b/tests/cases/unit/apps/dic/DicPostprocessIoExportTest.m @@ -66,20 +66,20 @@ function documentedStandaloneExampleRuns(testCase) ["Mean"; "Std"; "Median"; "Min"; "Max"]); end - function runtimeV2ProjectAndPresenterContracts(testCase) + function appSdkProjectAndPresenterContracts(testCase) setupLabKitTestPath(); folder = tempname; mkdir(folder); cleanup = onCleanup(@() cleanupFolder(folder)); definition = dic_postprocess.definition(); - testCase.verifyEqual(definition.contractVersion, 2); - project = definition.project.Create(); - testCase.verifyTrue(definition.project.Validate(project)); - testCase.verifyEmpty(definition.project.Migrate, ... + testCase.verifyClass(definition, "labkit.app.Definition"); + project = definition.ProjectSchema.Create(); + testCase.verifyTrue(definition.ProjectSchema.Validate(project)); + testCase.verifyEmpty(definition.ProjectSchema.Migrate, ... 'Payload version 1 should not invent a migration callback.'); invalid = project; invalid.parameters.gamma = Inf; - testCase.verifyFalse(definition.project.Validate(invalid)); + testCase.verifyFalse(definition.ProjectSchema.Validate(invalid)); matPath = fullfile(folder, 'project-strain.mat'); referencePath = fullfile(folder, 'project-reference.png'); @@ -95,21 +95,20 @@ function runtimeV2ProjectAndPresenterContracts(testCase) sourceRecord("dicMat", "strain", matPath); ... sourceRecord("referenceImage", "reference", referencePath); ... sourceRecord("maskImage", "mask", maskPath)]; - cache = dic_postprocess.sourceFiles.loadProjectInputs( ... - project.inputs.sources, true); + cache = dic_postprocess.sourceFiles.loadProjectInputs(struct( ... + "dicMat", string(matPath), ... + "referenceImage", string(referencePath), ... + "maskImage", string(maskPath)), true); [summary, ~, ~] = dic_postprocess.analysisRun.prepareOutputs( ... cache, project.parameters); project.results.summaryTable = summary; - session = dic_postprocess.createSession(project); - state = struct('project', project, 'session', session); - presentation = dic_postprocess.userInterface.presentWorkbench(state); - testCase.verifyTrue(isscalar(presentation)); - testCase.verifyGreaterThan(size( ... - presentation.controls.resultTable.Data, 1), 0); - testCase.verifyNotEmpty( ... - presentation.previews.overlayAxes.Axes.exx.Model.imageData); - testCase.verifyNotEmpty( ... - presentation.previews.overlayAxes.Axes.eyy.Model.imageData); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, project); + runtimeCleanup = onCleanup(@() runtime.close()); + testCase.verifyClass(runtime.Presentation, ... + "labkit.app.view.Snapshot"); + testCase.verifyNotEmpty(runtime.State.session.cache.overlayExx); + testCase.verifyNotEmpty(runtime.State.session.cache.overlayEyy); testCase.verifyEqual(fieldnames(project.inputs), {'sources'}, ... 'Durable project inputs should not duplicate decoded cache data.'); end diff --git a/tests/cases/unit/apps/dic/DicPostprocessViewTest.m b/tests/cases/unit/apps/dic/DicPostprocessViewTest.m index 71ee6f184..8bec0b143 100644 --- a/tests/cases/unit/apps/dic/DicPostprocessViewTest.m +++ b/tests/cases/unit/apps/dic/DicPostprocessViewTest.m @@ -2,21 +2,16 @@ %DICPOSTPROCESSVIEWTEST Verify GUI-free DIC postprocess view helpers. methods (Test, TestTags = {'Unit'}) - function displayPathReportsLoadedAndMissingPaths(testCase) - setupLabKitTestPath(); - - testCase.verifyEqual(dic_postprocess.userInterface.displayPath(""), 'none'); - testCase.verifyEqual(dic_postprocess.userInterface.displayPath("sample.mat"), 'sample.mat'); - end - function tagFromPathPreservesLastMillimeterToken(testCase) setupLabKitTestPath(); - tag = dic_postprocess.userInterface.tagFromPath("run_0.5mm_repeat_1.25mm.mat"); - fallback = dic_postprocess.userInterface.tagFromPath("run_without_dimension.mat"); + tag = dic_postprocess.resultFiles.tagFromPath( ... + "run_0.5mm_repeat_1.25mm.mat"); + fallback = dic_postprocess.resultFiles.tagFromPath( ... + "run_without_dimension.mat"); - testCase.verifyEqual(tag, '1.25mm'); - testCase.verifyEqual(fallback, 'unknown_mm'); + testCase.verifyEqual(tag, "1.25mm"); + testCase.verifyEqual(fallback, "unknown_mm"); end function summaryTableDataBuildsUiCellData(testCase) @@ -28,18 +23,21 @@ function summaryTableDataBuildsUiCellData(testCase) summary = table(metric, exx, eyy, ... 'VariableNames', {'Metric', 'EXX', 'EYY'}); - data = dic_postprocess.userInterface.summaryTableData(summary); - emptyData = dic_postprocess.userInterface.summaryTableData(table()); + data = dic_postprocess.overlayPreview.summaryTableData(summary); + emptyData = dic_postprocess.overlayPreview.summaryTableData(table()); testCase.verifyEqual(data, {'Mean', 1.25, -2; 'Std', 0.5, 0.25}); testCase.verifyEqual(emptyData, {}); end - function ternarySelectsDisplayText(testCase) + function definitionUsesCanonicalAppSdkStructure(testCase) setupLabKitTestPath(); - testCase.verifyEqual(dic_postprocess.userInterface.ternary(true, 'yes', 'no'), 'yes'); - testCase.verifyEqual(dic_postprocess.userInterface.ternary(false, 'yes', 'no'), 'no'); + definition = dic_postprocess.definition(); + + testCase.verifyClass(definition, "labkit.app.Definition"); + testCase.verifyClass(definition.ProjectSchema, ... + "labkit.app.project.Schema"); end end end diff --git a/tests/cases/unit/apps/dic/DicPreprocessIoExportTest.m b/tests/cases/unit/apps/dic/DicPreprocessIoExportTest.m index 6ae19f32b..d89eb6f2f 100644 --- a/tests/cases/unit/apps/dic/DicPreprocessIoExportTest.m +++ b/tests/cases/unit/apps/dic/DicPreprocessIoExportTest.m @@ -45,7 +45,7 @@ function writeCurrentImagesAndMaskCreatePngOutputs(testCase) testCase.verifyEqual(imread(maskPath), mask); end - function runtimeV2ProjectSessionAndPresenterContracts(testCase) + function appSdkProjectSessionAndPresenterContracts(testCase) setupLabKitTestPath(); folder = string(tempname); @@ -59,34 +59,32 @@ function runtimeV2ProjectSessionAndPresenterContracts(testCase) imwrite(moving, movingPath); definition = dic_preprocess.definition(); - testCase.verifyEqual(definition.contractVersion, 2); - project = definition.project.Create(); + project = definition.ProjectSchema.Create(); project.inputs.sources = [sourceRecord( ... - "referenceImage", "reference", referencePath); ... - sourceRecord("movingImage", "moving", movingPath)]; + "referenceImage", "referenceImage", referencePath); ... + sourceRecord("movingImage", "movingImage", movingPath)]; project.annotations.editSteps = struct( ... "kind", "crop", "transform", [], ... "rect", [1 1 2 2], "description", "crop"); - testCase.verifyTrue(definition.project.Validate(project)); - testCase.verifyEmpty(definition.project.Migrate, ... + testCase.verifyTrue(definition.ProjectSchema.Validate(project)); + testCase.verifyEmpty(definition.ProjectSchema.Migrate, ... 'Payload version 1 should not invent a migration callback.'); testCase.verifyFalse(hasDurableImagePixels(project), ... 'Decoded and derived image pixels belong to session cache.'); - session = dic_preprocess.createSession(project); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, project); + runtimeCleanup = onCleanup(@() runtime.close()); + session = runtime.State.session; testCase.verifyEqual(session.cache.referenceImage, reference); testCase.verifyEqual(session.cache.movingImage, moving); testCase.verifySize(session.cache.currentReferenceImage, [3 3 3]); - state = struct('project', project, 'session', session); - presentation = dic_preprocess.userInterface.presentWorkbench(state); - testCase.verifyTrue(isscalar(presentation)); - testCase.verifyNotEmpty( ... - presentation.previews.previewAxes.Axes.reference.Model.imageData); - testCase.verifyNotEmpty( ... - presentation.previews.previewAxes.Axes.current.Model.imageData); - testCase.verifyFalse(isfield(presentation, 'interactions'), ... - 'Idle presentation should not construct an interaction runtime.'); + presentation = runtime.Presentation; + testCase.verifyClass(presentation, "labkit.app.view.Snapshot"); + testCase.verifyTrue( ... + definition.validateViewSnapshot(presentation)); + clear runtimeCleanup end end end diff --git a/tests/cases/unit/apps/dic/DicPreprocessStateTest.m b/tests/cases/unit/apps/dic/DicPreprocessStateTest.m index 6acc202ef..60c3fd3dd 100644 --- a/tests/cases/unit/apps/dic/DicPreprocessStateTest.m +++ b/tests/cases/unit/apps/dic/DicPreprocessStateTest.m @@ -1,5 +1,5 @@ classdef DicPreprocessStateTest < matlab.unittest.TestCase - %DICPREPROCESSSTATETEST Verify native V2 DIC project/cache contracts. + %DICPREPROCESSSTATETEST Verify current DIC project/cache contracts. methods (Test, TestTags = {'Unit'}) function editHistoryStoresDurableStepsAndTrimsSnapshots(testCase) @@ -125,7 +125,7 @@ function applyBoundaryToMaskAddsAndSubtractsCanvas(testCase) function project = baseProject() definition = dic_preprocess.definition(); - project = definition.project.Create(); + project = definition.ProjectSchema.Create(); end function project = populatedProject() diff --git a/tests/cases/unit/apps/dic/DicPreprocessViewTest.m b/tests/cases/unit/apps/dic/DicPreprocessViewTest.m index 2de9ea066..f65d237a3 100644 --- a/tests/cases/unit/apps/dic/DicPreprocessViewTest.m +++ b/tests/cases/unit/apps/dic/DicPreprocessViewTest.m @@ -2,60 +2,11 @@ %DICPREPROCESSVIEWTEST Verify GUI-free DIC preprocess view helpers. methods (Test, TestTags = {'Unit'}) - function buildSummaryReportsUnloadedState(testCase) - setupLabKitTestPath(); - - state = baseState(); - - lines = dic_preprocess.userInterface.buildSummary(state); - - expected = { ... - 'Reference: none', ... - 'Moving: none', ... - 'Current pair: not loaded', ... - 'Undo steps: 0', ... - 'Last aligned image: not generated', ... - 'ROI mask: not drawn'}.'; - testCase.verifyEqual(lines, expected); - end - - function buildSummaryReportsCurrentPairAndDerivedState(testCase) - setupLabKitTestPath(); - - state = baseState(); - state.project.inputs.sources = [ ... - sourceRecord("referenceImage", "reference.png"); ... - sourceRecord("movingImage", "moving.png")]; - state.session.cache.currentReferenceImage = ... - zeros(12, 20, 3, 'uint8'); - state.session.cache.currentMovingImage = zeros(10, 18, 'uint8'); - state.project.annotations.editSteps = struct( ... - 'kind', "alignment", 'transform', eye(3), ... - 'rect', [], 'description', "alignment"); - state.project.annotations.maskImage = uint8([0 1; 1 0]); - state.project.annotations.history = struct( ... - 'editSteps', {struct([]) struct([])}, ... - 'maskImage', {[] []}, ... - 'maskPoints', {[] []}, ... - 'description', {'alignment', 'crop'}); - - lines = dic_preprocess.userInterface.buildSummary(state); - - expected = { ... - 'Reference: reference.png', ... - 'Moving: moving.png', ... - 'Current pair: reference 12 x 20, moving 10 x 18', ... - 'Undo steps: 2', ... - 'Last aligned image: available', ... - 'ROI mask: available'}.'; - testCase.verifyEqual(lines, expected); - end - function cropSummariesPreserveDetailText(testCase) setupLabKitTestPath(); - selection = dic_preprocess.userInterface.cropSelectionSummary([10.2 20.7 31.5 31.5]); - applied = dic_preprocess.userInterface.cropSummary([1.5 2.5 30 30]); + selection = dic_preprocess.analysisRun.cropSelectionSummary([10.2 20.7 31.5 31.5]); + applied = dic_preprocess.analysisRun.cropSummary([1.5 2.5 30 30]); testCase.verifyEqual(selection, { ... 'Active crop source: current reference and current moving images', ... @@ -71,7 +22,7 @@ function transformSummaryFormatsMatrixRows(testCase) tform = [1 0 0; 0 1 0; 2.5 -3.25 1]; - lines = dic_preprocess.userInterface.transformSummary(tform, [12 20 3], [10 18]); + lines = dic_preprocess.analysisRun.transformSummary(tform, [12 20 3], [10 18]); testCase.verifyEqual(lines, { ... 'Reference size: 12 x 20', ... @@ -92,11 +43,11 @@ function previewRequestBuildsSelectedPreviewImages(testCase) state.session.cache.movingImage = uint8(2 .* ones(2, 2)); state.project.annotations.maskImage = uint8([0 255; 255 0]); - overlay = dic_preprocess.userInterface.previewRequest( ... + overlay = dic_preprocess.analysisRun.previewRequest( ... state, 'False-color overlay'); - original = dic_preprocess.userInterface.previewRequest( ... + original = dic_preprocess.analysisRun.previewRequest( ... state, 'Original pair'); - mask = dic_preprocess.userInterface.previewRequest(state, 'ROI mask'); + mask = dic_preprocess.analysisRun.previewRequest(state, 'ROI mask'); testCase.verifyEqual(overlay.topTitle, "Current reference"); testCase.verifyEqual(overlay.bottomTitle, "False-color overlay"); @@ -112,15 +63,15 @@ function previewRequestBuildsSelectedPreviewImages(testCase) function maskEditControlStateAndDraftDetailsMatchAnchorState(testCase) setupLabKitTestPath(); - emptyState = dic_preprocess.userInterface.maskEditControlState(true, ... + emptyState = dic_preprocess.maskEditing.maskEditControlState(true, ... zeros(0, 2), [], struct('maskImage', {}, ... 'maskPoints', {}, 'description', {})); - boundaryState = dic_preprocess.userInterface.maskEditControlState(true, ... + boundaryState = dic_preprocess.maskEditing.maskEditControlState(true, ... [1 1; 2 2; 3 1], uint8([0 255]), ... struct('maskImage', {uint8(1)}, ... 'maskPoints', {[1 2]}, 'description', {'mask'})); - emptyDetails = dic_preprocess.userInterface.maskDraftDetails(zeros(0, 2)); - boundaryDetails = dic_preprocess.userInterface.maskDraftDetails([1 1; 2 2; 3 1]); + emptyDetails = dic_preprocess.maskEditing.maskDraftDetails(zeros(0, 2)); + boundaryDetails = dic_preprocess.maskEditing.maskDraftDetails([1 1; 2 2; 3 1]); testCase.verifyEqual(emptyState.addBoundary, 'off'); testCase.verifyEqual(emptyState.undoPoint, 'off'); @@ -131,18 +82,20 @@ function maskEditControlStateAndDraftDetailsMatchAnchorState(testCase) 'Mask ROI anchors: 0. Double-click the reference preview to add anchors; at least 3 anchors are required.'}); testCase.verifyEqual(boundaryDetails, { ... 'Mask ROI anchors: 3. Preview, Add to mask, or Subtract from mask.'}); - testCase.verifyEqual(dic_preprocess.userInterface.onOff(true), 'on'); - testCase.verifyEqual(dic_preprocess.userInterface.onOff(false), 'off'); + testCase.verifyEqual(dic_preprocess.maskEditing.onOff(true), 'on'); + testCase.verifyEqual(dic_preprocess.maskEditing.onOff(false), 'off'); end end end function state = baseState() definition = dic_preprocess.definition(); - project = definition.project.Create(); + project = definition.ProjectSchema.Create(); state = struct( ... "project", project, ... - "session", dic_preprocess.createSession(project)); + "session", dic_preprocess.createSession( ... + project, ... + labkit.app.internal.CallbackContextFactory.disconnected())); end function source = sourceRecord(id, filepath) diff --git a/tests/cases/unit/apps/electrochem/ChronoOverlayExportTest.m b/tests/cases/unit/apps/electrochem/ChronoOverlayExportTest.m index bac1fb8a2..b64e93a03 100644 --- a/tests/cases/unit/apps/electrochem/ChronoOverlayExportTest.m +++ b/tests/cases/unit/apps/electrochem/ChronoOverlayExportTest.m @@ -15,23 +15,30 @@ function verify_chronoOverlayExport() checkGapCenterAlignment(); checkFallbackAlignment(); checkMergedExportInterpolation(); - checkRuntimeV2Contracts(); + checkExplicitUiContract(); end -function checkRuntimeV2Contracts() - definition = chrono_overlay.definition(); - assert(definition.contractVersion == 2, ... - 'Chrono overlay should use the runtime V2 definition contract.'); - project = definition.project.Create(); - assert(definition.project.Validate(project), ... +function checkExplicitUiContract() + app = chrono_overlay.definition(); + assert(isa(app, "labkit.app.Definition"), ... + 'Chrono overlay should use the App Definition contract.'); + project = app.ProjectSchema.Create(); + assert(app.ProjectSchema.Validate(project), ... 'The default Chrono overlay project should validate.'); - assert(definition.project.Version == 2 && ... - isa(definition.project.Migrate, 'function_handle'), ... + assert(app.ProjectSchema.Version == 2 && ... + isa(app.ProjectSchema.Migrate, 'function_handle'), ... 'Payload version 2 should expose one version-aware migration entry.'); + assert(isequal( ... + labkit.app.internal.DefinitionInspector.signalIds(app), ... + "exportCurves__pressed"), ... + 'Chrono export should compile directly from its button callback.'); + version = labkit_ChronoOverlay_app("version"); + assert(string(version.version) == "1.5.0", ... + 'The entrypoint version request should use Definition metadata.'); invalid = project; invalid.parameters.lineWidth = Inf; - assert(~definition.project.Validate(invalid), ... + assert(~app.ProjectSchema.Validate(invalid), ... 'The project validator should reject non-finite plot parameters.'); item = makeOverlayItem('synthetic.DTA', [-1; 0; 1], ... @@ -39,22 +46,17 @@ function checkRuntimeV2Contracts() item.filepath = '/synthetic/synthetic.DTA'; legacyProject = project; legacyProject.inputs.items = item; - migrated = definition.project.Migrate(legacyProject, 1); + migrated = app.ProjectSchema.Migrate(legacyProject, 1); assert(~isfield(migrated.inputs, 'items'), ... 'Chrono Overlay v1 migration should remove decoded DTA items.'); - session = chrono_overlay.createSession(project); - session.cache.items = item; - session.selection.paths = string(item.filepath); + session = struct( ... + "cache", struct("items", item), ... + "selection", struct("files", ... + labkit.app.event.ListSelection(Indices=1))); state = struct('project', project, 'session', session); - presentation = chrono_overlay.userInterface.presentWorkbench(state); - assert(isscalar(presentation) && ... - isscalar(presentation.controls.files), ... - 'The presenter should return scalar declarative control specs.'); - assert(string(presentation.controls.files.Selection) == "item1", ... - 'A restored project should select its available source by default.'); - assert(isfield(presentation.previews.overlayPlots.Axes, 'voltage') && ... - isfield(presentation.previews.overlayPlots.Axes, 'current'), ... - 'The presenter should prepare both registered overlay axes.'); + presentation = chrono_overlay.workbench.present(state); + assert(isa(presentation, "labkit.app.view.Snapshot"), ... + 'The view builder should return a closed Snapshot value.'); end function checkGapCenterAlignment() diff --git a/tests/cases/unit/apps/electrochem/CicExportTest.m b/tests/cases/unit/apps/electrochem/CicExportTest.m index 46ea099f1..80e4743e4 100644 --- a/tests/cases/unit/apps/electrochem/CicExportTest.m +++ b/tests/cases/unit/apps/electrochem/CicExportTest.m @@ -13,7 +13,7 @@ function verify_cicExport() %TEST_CICEXPORT Verify app-side CIC result/export table helpers. item = makeChronoFixtureItem('', 'chrono "cic".DTA'); - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); opts = struct(); opts.delay_s = 10e-6; @@ -26,31 +26,24 @@ function verify_cicExport() assert(item.analysis.ok, item.analysis.message); definition = cic.definition(); - assert(definition.contractVersion == 2 && ... - definition.project.Version == 1 && ... - isempty(definition.project.Migrate), ... - 'CIC should use a first-version Runtime V2 project contract.'); - project = definition.project.Create(); - assert(definition.project.Validate(project) && ... + assert(isa(definition, "labkit.app.Definition") && ... + definition.ProjectSchema.Version == 1 && ... + isempty(definition.ProjectSchema.Migrate), ... + 'CIC should use a first-version App SDK project contract.'); + project = definition.ProjectSchema.Create(); + assert(definition.ProjectSchema.Validate(project) && ... ~isfield(project.inputs, 'items'), ... 'CIC projects should validate without persisting decoded DTA items.'); - session = definition.createSession(project); - [~, sourceName, sourceExtension] = fileparts(item.filepath); - reference = struct( ... - "schemaVersion", 1, "relativePath", "", ... - "originalPath", string(item.filepath), ... - "fileName", string(sourceName) + string(sourceExtension)); - project.inputs.sources = struct( ... - "id", "item1", "required", true, "role", "chrono", ... - "reference", reference); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, project); + session = runtime.State.session; session.cache.items = item; - session.selection.currentIndex = 1; + session.selection.files = ... + labkit.app.event.ListSelection(Indices=1); state = struct("project", project, "session", session); - presentation = definition.present(state); - assert(isfield(presentation.previews.plotAxes.Axes, 'top') && ... - isfield(presentation.previews.plotAxes.Axes, 'bottom') && ... - string(presentation.controls.files.Selection) == "item1", ... - 'CIC presenter should prepare both axes and current source selection.'); + presentation = definition.PresentWorkbench(state); + assert(isa(presentation, "labkit.app.view.Snapshot"), ... + 'CIC presenter should return an App SDK snapshot.'); assert(~contains(evalc('disp(state)'), 'matlab.ui'), ... 'CIC canonical state should contain no UI handles.'); @@ -120,7 +113,7 @@ function deleteIfExists(filepath) end function [C, cols] = buildCICBatchTableData(items, unitLabel) - [C, cols] = cic.userInterface.buildBatchTableData(items, unitLabel); + [C, cols] = cic.analysisRun.buildBatchTableData(items, unitLabel); end function writeCICResultsCSV(items, filepath, unitLabel) diff --git a/tests/cases/unit/apps/electrochem/CicViewTest.m b/tests/cases/unit/apps/electrochem/CicViewTest.m index 7385ba0c5..1bca84e1f 100644 --- a/tests/cases/unit/apps/electrochem/CicViewTest.m +++ b/tests/cases/unit/apps/electrochem/CicViewTest.m @@ -5,12 +5,12 @@ function displayUnitNormalizesKnownAndFallbackLabels(testCase) setupLabKitTestPath(); - [scale, label, suffix] = cic.userInterface.displayUnit('uC/cm^2'); + [scale, label, suffix] = cic.analysisRun.displayUnit('uC/cm^2'); testCase.verifyEqual(scale, 1e3); testCase.verifyEqual(label, 'uC/cm^2'); testCase.verifyEqual(suffix, 'uCcm2'); - [scale, label, suffix] = cic.userInterface.displayUnit('unexpected'); + [scale, label, suffix] = cic.analysisRun.displayUnit('unexpected'); testCase.verifyEqual(scale, 1); testCase.verifyEqual(label, 'mC/cm^2'); testCase.verifyEqual(suffix, 'mCcm2'); @@ -20,8 +20,8 @@ function currentSummaryBuildsSuccessfulRowsAndBestSafeValue(testCase) setupLabKitTestPath(); items = makeItems(); - choices = cic.userInterface.analysisChoices(); - summary = cic.userInterface.buildCurrentSummary(items, 1, ... + choices = cic.analysisRun.analysisChoices(); + summary = cic.analysisRun.buildCurrentSummary(items, 1, ... choices.cicModes(1), 'uC/cm^2'); testCase.verifyEqual(summary.controlMode, 'Current-controlled chrono'); @@ -44,9 +44,9 @@ function currentSummaryHandlesFailedAnalysis(testCase) items(1).controlMode = 'unknown'; items(1).analysis = struct('ok', false, 'message', 'bad pulse window'); items(2).analysis.safe = false; - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); - summary = cic.userInterface.buildCurrentSummary(items, 1, ... + summary = cic.analysisRun.buildCurrentSummary(items, 1, ... choices.cicModes(3), 'mC/cm^2'); testCase.verifyEqual(summary.controlMode, 'Unknown chrono control mode'); @@ -61,14 +61,14 @@ function currentSummaryPreservesBestSafeWithoutCurrentItem(testCase) setupLabKitTestPath(); items = makeItems(); - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); - summary = cic.userInterface.buildCurrentSummary(items, [], ... + summary = cic.analysisRun.buildCurrentSummary(items, [], ... choices.cicModes(3), 'mC/cm^2'); testCase.verifyEqual(summary.controlMode, '-'); testCase.verifyEqual(summary.bestSafe, 'safe-second | CICtotal = 0.012 mC/cm^2'); - emptySummary = cic.userInterface.buildCurrentSummary(struct([]), [], ... + emptySummary = cic.analysisRun.buildCurrentSummary(struct([]), [], ... choices.cicModes(3), 'mC/cm^2'); testCase.verifyEqual(emptySummary.bestSafe, '-'); end @@ -77,9 +77,9 @@ function plotRequestBuildsTimeVoltagePayload(testCase) setupLabKitTestPath(); A = makeAnalysis(false, 0.0025, 0.0035, 0.0060); - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); - request = cic.userInterface.plotRequest(A, 'sample-file', ... + request = cic.analysisPlot.plotRequest(A, 'sample-file', ... choices.xAxes(1), choices.yAxes(1)); testCase.verifyEqual(request.kind, 'VT'); @@ -101,9 +101,9 @@ function plotRequestBuildsSampleCurrentPayload(testCase) setupLabKitTestPath(); A = makeAnalysis(true, 0.0090, 0.0040, 0.0120); - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); - request = cic.userInterface.plotRequest(A, 'safe-file', ... + request = cic.analysisPlot.plotRequest(A, 'safe-file', ... choices.xAxes(2), choices.yAxes(2)); testCase.verifyEqual(request.kind, 'IT'); diff --git a/tests/cases/unit/apps/electrochem/ComputeCICTest.m b/tests/cases/unit/apps/electrochem/ComputeCICTest.m index 72bf57d36..ba5677d5d 100644 --- a/tests/cases/unit/apps/electrochem/ComputeCICTest.m +++ b/tests/cases/unit/apps/electrochem/ComputeCICTest.m @@ -13,7 +13,7 @@ function verify_computeCIC() %TEST_COMPUTECIC Verify app-side CIC / voltage-transient analysis. item = makeChronoFixtureItem(); - choices = cic.userInterface.analysisChoices(); + choices = cic.analysisRun.analysisChoices(); opts = struct(); opts.delay_s = 10e-6; diff --git a/tests/cases/unit/apps/electrochem/ComputeCSCTest.m b/tests/cases/unit/apps/electrochem/ComputeCSCTest.m index 1928cab31..28bc858a6 100644 --- a/tests/cases/unit/apps/electrochem/ComputeCSCTest.m +++ b/tests/cases/unit/apps/electrochem/ComputeCSCTest.m @@ -20,7 +20,7 @@ function verify_computeCSC() curves = item.curves; assert(~isempty(curves), 'CV/CT fixture should contain at least one curve.'); curve = curves(1); - choices = csc.userInterface.analysisChoices(); + choices = csc.analysisRun.analysisChoices(); opts = struct('scanRate', scanRate, ... 'mode', char(choices.modes(1)), 'area_cm2', '2'); diff --git a/tests/cases/unit/apps/electrochem/ComputeVTResistanceTest.m b/tests/cases/unit/apps/electrochem/ComputeVTResistanceTest.m index e58906152..f585fbb20 100644 --- a/tests/cases/unit/apps/electrochem/ComputeVTResistanceTest.m +++ b/tests/cases/unit/apps/electrochem/ComputeVTResistanceTest.m @@ -15,7 +15,7 @@ function verify_computeVTResistance() item = makeChronoFixtureItem(); opts = struct(); - choices = vt_resistance.userInterface.analysisChoices(); + choices = vt_resistance.analysisRun.analysisChoices(); opts.windowMode = choices.steadyWindows(1); opts.voltageMode = choices.voltageModes(1); opts.pulseMode = choices.pulseModes(1); diff --git a/tests/cases/unit/apps/electrochem/CscExportTest.m b/tests/cases/unit/apps/electrochem/CscExportTest.m index 6053ed08b..fc9e1f91a 100644 --- a/tests/cases/unit/apps/electrochem/CscExportTest.m +++ b/tests/cases/unit/apps/electrochem/CscExportTest.m @@ -19,35 +19,36 @@ function verify_cscExport() 'CSC export fixture should include multiple curves.'); definition = csc.definition(); - assert(definition.contractVersion == 2 && ... - definition.project.Version == 1 && ... - isempty(definition.project.Migrate), ... - 'CSC should use a first-version Runtime V2 project contract.'); - project = definition.project.Create(); - assert(definition.project.Validate(project) && ... + assert(isa(definition, "labkit.app.Definition") && ... + definition.ProjectSchema.Version == 1 && ... + isempty(definition.ProjectSchema.Migrate), ... + 'CSC should use a first-version App SDK project contract.'); + project = definition.ProjectSchema.Create(); + assert(definition.ProjectSchema.Validate(project) && ... ~isfield(project.inputs, 'items'), ... 'CSC projects should validate without decoded DTA items.'); - defaults = csc.userInterface.defaultPlotSelections( ... + defaults = csc.analysisRun.defaultPlotSelections( ... item.curves(1).headers(item.curves(1).numericMask)); project.parameters.topX = string(defaults.topX); project.parameters.topY = string(defaults.topY); project.parameters.bottomX = string(defaults.bottomX); project.parameters.bottomY = string(defaults.bottomY); - session = definition.createSession(project); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, project); + session = runtime.State.session; session.cache.items = item; - session.selection.currentIndex = 1; + session.selection.files = ... + labkit.app.event.ListSelection(Indices=1); session.selection.currentCurve = ... - csc.userInterface.analysisChoices().allCycles; + csc.analysisRun.analysisChoices().allCycles; state = struct("project", project, "session", session); - presentation = definition.present(state); - assert(isfield(presentation.previews.plotAxes.Axes, 'top') && ... - isfield(presentation.previews.plotAxes.Axes, 'bottom') && ... - string(presentation.controls.files.Selection) == "item1", ... - 'CSC presenter should prepare both axes and current source selection.'); + presentation = definition.PresentWorkbench(state); + assert(isa(presentation, "labkit.app.view.Snapshot"), ... + 'CSC presenter should return an App SDK snapshot.'); assert(~contains(evalc('disp(state)'), 'matlab.ui'), ... 'CSC canonical state should contain no UI handles.'); - choices = csc.userInterface.analysisChoices(); + choices = csc.analysisRun.analysisChoices(); opts = struct('mode', char(choices.modes(2)), 'area_cm2', '2'); T = csc.resultFiles.buildResultsTable(item, opts); expectedNames = {'File', 'CurveIndex', 'CurveName', 'Rows', ... @@ -81,12 +82,12 @@ function verify_cscExport() 'CSC export CV normalized charge'); assertClose(T.CSCctCath_mCcm2(1), A.Qct_mC_cm2, 1e-13, ... 'CSC export CT normalized charge'); - data = csc.userInterface.cycleResultsTableData(T, choices.modes(2)); + data = csc.analysisRun.cycleResultsTableData(T, choices.modes(2)); assert(isequal(size(data), [height(T) 6]), ... 'CSC compact cycle table should preserve six visible columns.'); assertClose(data{1, 3}, A.Qcv_mC_cm2, 1e-13, ... 'CSC compact table should follow selected display mode.'); - names = csc.userInterface.cycleResultsColumnNames(choices.modes(2)); + names = csc.analysisRun.cycleResultsColumnNames(choices.modes(2)); assert(contains(names{3}, 'cathodic'), ... 'CSC compact table headers should name the selected display mode.'); diff --git a/tests/cases/unit/apps/electrochem/CscViewTest.m b/tests/cases/unit/apps/electrochem/CscViewTest.m index ed01a46dc..83c2e4518 100644 --- a/tests/cases/unit/apps/electrochem/CscViewTest.m +++ b/tests/cases/unit/apps/electrochem/CscViewTest.m @@ -6,23 +6,23 @@ function chargeFormattingPreservesLegacyText(testCase) setupLabKitTestPath(); testCase.verifyEqual( ... - csc.userInterface.formatChargeAndCSC(1.25e-4, NaN), ... + csc.analysisRun.formatChargeAndCSC(1.25e-4, NaN), ... sprintf('%.12e C', 1.25e-4)); testCase.verifyEqual( ... - csc.userInterface.formatChargeAndCSC(1.25e-4), ... + csc.analysisRun.formatChargeAndCSC(1.25e-4), ... sprintf('%.12e C', 1.25e-4)); testCase.verifyEqual( ... - csc.userInterface.formatChargeAndCSC(1.25e-4, 0), ... + csc.analysisRun.formatChargeAndCSC(1.25e-4, 0), ... sprintf('%.12e C', 1.25e-4)); testCase.verifyEqual( ... - csc.userInterface.formatChargeAndCSC(1.25e-4, 2), ... + csc.analysisRun.formatChargeAndCSC(1.25e-4, 2), ... sprintf('%.12e C | %.12e mC/cm^2', 1.25e-4, 1e3 * 1.25e-4 / 2)); end function defaultPlotSelectionsPreferLegacyCvctColumns(testCase) setupLabKitTestPath(); - selections = csc.userInterface.defaultPlotSelections({'T', 'Vf', 'Im', 'Vu'}); + selections = csc.analysisRun.defaultPlotSelections({'T', 'Vf', 'Im', 'Vu'}); testCase.verifyEqual(selections.topX, 'Vf'); testCase.verifyEqual(selections.topY, 'Im'); @@ -33,7 +33,7 @@ function defaultPlotSelectionsPreferLegacyCvctColumns(testCase) function defaultPlotSelectionsFallBackToFirstColumn(testCase) setupLabKitTestPath(); - selections = csc.userInterface.defaultPlotSelections({'Potential', 'Current'}); + selections = csc.analysisRun.defaultPlotSelections({'Potential', 'Current'}); testCase.verifyEqual(selections.topX, 'Potential'); testCase.verifyEqual(selections.topY, 'Potential'); @@ -48,7 +48,7 @@ function trimOverlayDataPreparesLegacyTrimVectors(testCase) 'IcathDisp', [NaN -2 -3], ... 'IanodDisp', [1 NaN 3]); - overlay = csc.userInterface.trimOverlayData(true, 'Im', [0 1 2], result); + overlay = csc.analysisPlot.trimOverlayData(true, 'Im', [0 1 2], result); testCase.verifyTrue(overlay.ok); testCase.verifyEqual(overlay.x, [0 1 2]); @@ -63,9 +63,9 @@ function trimOverlayDataRejectsNonCurrentAndMismatchedVectors(testCase) 'IcathDisp', [NaN -2 -3], ... 'IanodDisp', [1 NaN 3]); - disabled = csc.userInterface.trimOverlayData(false, 'Im', [0 1 2], result); - voltageAxis = csc.userInterface.trimOverlayData(true, 'Vf', [0 1 2], result); - mismatchedX = csc.userInterface.trimOverlayData(true, 'Im', [0 1], result); + disabled = csc.analysisPlot.trimOverlayData(false, 'Im', [0 1 2], result); + voltageAxis = csc.analysisPlot.trimOverlayData(true, 'Vf', [0 1 2], result); + mismatchedX = csc.analysisPlot.trimOverlayData(true, 'Im', [0 1], result); testCase.verifyFalse(disabled.ok); testCase.verifyFalse(voltageAxis.ok); @@ -84,8 +84,8 @@ function comparisonReadoutFormatsSuccessfulComparison(testCase) 'dtErr', 3.25e-6, ... 'area_cm2', 2); - choices = csc.userInterface.analysisChoices(); - readout = csc.userInterface.comparisonReadout( ... + choices = csc.analysisRun.analysisChoices(); + readout = csc.analysisRun.comparisonReadout( ... result, choices.modes(2)); testCase.verifyTrue(readout.ok); @@ -117,8 +117,8 @@ function comparisonReadoutPreservesChargeOnlyStatus(testCase) 'dtErr', 3.25e-6, ... 'area_cm2', NaN); - choices = csc.userInterface.analysisChoices(); - readout = csc.userInterface.comparisonReadout( ... + choices = csc.analysisRun.analysisChoices(); + readout = csc.analysisRun.comparisonReadout( ... result, choices.modes(1)); testCase.verifyEqual(readout.qctText, sprintf('%.12e C', result.Qct)); @@ -134,8 +134,8 @@ function comparisonReadoutPreservesFailureDisplay(testCase) 'message', 'No matching CV/CT curve data.', ... 'logMessage', 'Compare skipped: No matching CV/CT curve data.'); - choices = csc.userInterface.analysisChoices(); - readout = csc.userInterface.comparisonReadout( ... + choices = csc.analysisRun.analysisChoices(); + readout = csc.analysisRun.comparisonReadout( ... result, choices.modes(1)); testCase.verifyFalse(readout.ok); @@ -156,7 +156,7 @@ function plotRequestBuildsDataLabelsAndLogText(testCase) 'headers', {{'T', 'Vf', 'Im'}}, ... 'data', [0 0.1 -1; 1 0.2 NaN; 2 0.3 2]); - request = csc.userInterface.plotRequest(curve, 'Vf', 'Im', 'Top'); + request = csc.analysisPlot.plotRequest(curve, 'Vf', 'Im', 'Top'); testCase.verifyEqual(request.x, [0.1; 0.3]); testCase.verifyEqual(request.y, [-1; 2]); @@ -175,7 +175,7 @@ function plotRequestPreservesInvalidSelectionPayload(testCase) 'headers', {{'T', 'Vf', 'Im'}}, ... 'data', [0 0.1 -1; 1 0.2 1]); - request = csc.userInterface.plotRequest(curve, 'Missing', 'Im', 'Bottom'); + request = csc.analysisPlot.plotRequest(curve, 'Missing', 'Im', 'Bottom'); testCase.verifyEmpty(request.x); testCase.verifyEmpty(request.y); diff --git a/tests/cases/unit/apps/electrochem/EisOverlayExportTest.m b/tests/cases/unit/apps/electrochem/EisOverlayExportTest.m index 8cfa26b84..08883f944 100644 --- a/tests/cases/unit/apps/electrochem/EisOverlayExportTest.m +++ b/tests/cases/unit/apps/electrochem/EisOverlayExportTest.m @@ -27,25 +27,26 @@ function verify_eisOverlayExport() assert(~item.freqDesc, 'Fixture frequency order should preserve low-to-high/mixed summary behavior.'); assert(isstruct(item.analysis) && isempty(fieldnames(item.analysis)), ... 'EIS item should initialize an empty analysis struct.'); - axisItems = eis.userInterface.axisItems(); + axisItems = eis.overlayPlot.axisItems(); definition = eis.definition(); - assert(definition.contractVersion == 2 && ... - definition.project.Version == 1 && ... - isempty(definition.project.Migrate), ... - 'EIS should use a first-version Runtime V2 project contract.'); - project = definition.project.Create(); - assert(definition.project.Validate(project) && ... + schema = definition.ProjectSchema; + assert(isa(definition, "labkit.app.Definition") && ... + schema.Version == 1 && isempty(schema.Migrate), ... + 'EIS should use a first-version App SDK project contract.'); + project = schema.Create(); + assert(schema.Validate(project) && ... ~isfield(project.inputs, 'items'), ... 'EIS projects should validate without decoded DTA items.'); - session = definition.createSession(project); - session.cache.items = item; - session.selection.paths = string(item.filepath); - state = struct("project", project, "session", session); - presentation = definition.present(state); - assert(isfield(presentation.previews.plot.Axes, 'overlay') && ... - string(presentation.controls.files.Selection) == "item1", ... - 'EIS presenter should prepare its overlay and source selection.'); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(definition); + cleanup = onCleanup(@() runtime.close()); + runtime.applyFileSelection("files", string(fixture), 1); + state = runtime.State; + assert(numel(state.session.cache.items) == 1 && ... + state.session.selection.files.Indices == 1 && ... + all(ismember(["files", "plot"], ... + labkit.app.internal.DefinitionInspector.targetIds(definition))), ... + 'EIS runtime should prepare its overlay and source selection.'); assert(~contains(evalc('disp(state)'), 'matlab.ui'), ... 'EIS canonical state should contain no UI handles.'); @@ -68,25 +69,26 @@ function verify_eisOverlayExport() assert(any(strcmp(T.Properties.VariableNames, expectedY)), ... 'EIS export table should preserve axis/file-based Y column names.'); - summary = eis.userInterface.buildSummary(canonicalItem); + summary = eis.sourceFiles.buildSummary(canonicalItem); assert(numel(summary) == 2 && contains(summary{2}, item.name) && ... contains(summary{2}, sprintf('N=%d', item.n)) && ... contains(summary{2}, 'Freq') && contains(summary{2}, 'Hz') && ... contains(summary{2}, 'low->high/mixed'), ... 'EIS summary should report canonical item details.'); - assert(eis.userInterface.axisModeForSelection( ... + assert(eis.overlayPlot.axisModeForSelection( ... axisItems(5), axisItems(7)) == "equal", ... 'Nyquist EIS selection should use equal axes.'); - assert(eis.userInterface.axisModeForSelection( ... + assert(eis.overlayPlot.axisModeForSelection( ... axisItems(5), axisItems(7), true, false) == "normal", ... 'Log-scaled Nyquist EIS selection should release equal axes for stable zooming.'); - assert(eis.userInterface.axisModeForSelection( ... + assert(eis.overlayPlot.axisModeForSelection( ... axisItems(5), axisItems(7), false, true) == "normal", ... 'Log-scaled Nyquist EIS selection should release equal axes for stable zooming.'); - assert(eis.userInterface.axisModeForSelection( ... + assert(eis.overlayPlot.axisModeForSelection( ... axisItems(1), axisItems(8)) == "normal", ... 'Non-Nyquist EIS selection should use normal axes.'); + clear cleanup; end function item = removeLegacyEisFields(item) diff --git a/tests/cases/unit/apps/electrochem/ElectrochemDebugSamplePackTest.m b/tests/cases/unit/apps/electrochem/ElectrochemDebugSamplePackTest.m index 25ab0f722..6e161d1d2 100644 --- a/tests/cases/unit/apps/electrochem/ElectrochemDebugSamplePackTest.m +++ b/tests/cases/unit/apps/electrochem/ElectrochemDebugSamplePackTest.m @@ -6,73 +6,80 @@ function electrochem_debug_sample_packs_load_through_dta_facade(testCase) setupLabKitTestPath(); fixtureRoot = string(tempname); cleanup = onCleanup(@() cleanupFolder(fixtureRoot)); - mkdir(char(fixtureRoot)); + context = labkit.app.diagnostic.SampleContext(fixtureRoot); - debug = labkit.ui.debug.context("electrochem_debug_sample_test", struct( ... - "logFile", fullfile(char(fixtureRoot), "trace.log"))); - - verifyChronoOverlayPack(testCase, chrono_overlay.debug.writeSamplePack(debug)); - verifyChronoPack(testCase, cic.debug.writeSamplePack(debug), "labkit_CIC_app"); - verifyChronoPack(testCase, vt_resistance.debug.writeSamplePack(debug), "labkit_VTResistance_app"); - verifyCvctPack(testCase, csc.debug.writeSamplePack(debug)); - verifyEisPack(testCase, eis.debug.writeSamplePack(debug)); - - testCase.verifyTrue(isfile(debug.manifestFile), ... - "Debug sample writers should record a session manifest."); - manifestText = string(fileread(debug.manifestFile)); - testCase.verifyTrue(contains(manifestText, "labkit_EIS_app"), ... - "The last recorded manifest should identify the app-owned sample pack."); - testCase.verifyTrue(contains(manifestText, "outputFolder"), ... - "Manifest payload should retain the debug output folder."); + verifyChronoOverlayPack(testCase, context, ... + chrono_overlay.debug.writeSamplePack(context)); + verifyChronoPack(testCase, context, ... + cic.debug.writeSamplePack(context), "weakResponse"); + verifyChronoPack(testCase, context, ... + vt_resistance.debug.writeSamplePack(context), "lowResistance"); + verifyCvctPack(testCase, context, ... + csc.debug.writeSamplePack(context)); + verifyEisPack(testCase, context, ... + eis.debug.writeSamplePack(context)); end end end -function verifyChronoOverlayPack(testCase, pack) - testCase.verifyEqual(numel(pack.representativeFiles), 2, ... +function verifyChronoOverlayPack(testCase, context, pack) + verifyTypedPack(testCase, pack); + representativeFiles = [ ... + artifactPath(context, pack, "currentPulse"), ... + artifactPath(context, pack, "voltagePulse")]; + testCase.verifyEqual(numel(representativeFiles), 2, ... "Chrono overlay debug pack should provide two representative chrono files."); - for k = 1:numel(pack.representativeFiles) - item = loadDta(testCase, pack.representativeFiles(k), "chrono"); + for k = 1:numel(representativeFiles) + item = loadDta(testCase, representativeFiles(k), "chrono"); testCase.verifyGreaterThan(numel(item.t), 20, ... "Chrono overlay samples should contain tabular time-series rows."); testCase.verifyTrue(any(isfinite(item.Vf)) && any(isfinite(item.Im)), ... "Chrono overlay samples should include finite voltage and current data."); end - verifyBoundaryFiles(testCase, pack.boundaryFiles, "chrono"); + verifyBoundaryFiles(testCase, context, pack, "noPulse", "chrono"); end -function verifyChronoPack(testCase, pack, appName) - testCase.verifyEqual(string(pack.manifest.app), appName); - item = loadDta(testCase, pack.representativeFiles, "chrono"); +function verifyChronoPack(testCase, context, pack, edgeId) + verifyTypedPack(testCase, pack); + item = loadDta(testCase, ... + artifactPath(context, pack, "representative"), "chrono"); testCase.verifyGreaterThan(numel(item.t), 20, ... "Chrono debug samples should contain representative rows."); testCase.verifyTrue(any(abs(item.Im) > 0), ... "Chrono debug samples should include current response data."); - verifyBoundaryFiles(testCase, pack.boundaryFiles, "chrono"); + verifyBoundaryFiles(testCase, context, pack, edgeId, "chrono"); end -function verifyCvctPack(testCase, pack) - item = loadDta(testCase, pack.representativeFiles, "cvct"); +function verifyCvctPack(testCase, context, pack) + verifyTypedPack(testCase, pack); + item = loadDta(testCase, ... + artifactPath(context, pack, "representative"), "cvct"); testCase.verifyGreaterThanOrEqual(numel(item.curves), 2, ... "CSC debug CV/CT sample should include at least two curve tables."); testCase.verifyTrue(isfinite(item.scanRate), ... "CSC debug CV/CT sample should include scan-rate metadata."); - edge = loadDta(testCase, pack.boundaryFiles.validEdge, "cvct"); + edge = loadDta(testCase, ... + artifactPath(context, pack, "zeroScanRate"), "cvct"); testCase.verifyEqual(edge.scanRate, 0, ... "CSC edge sample should be valid CV/CT data with zero scan-rate metadata."); - verifyMalformedFile(testCase, pack.boundaryFiles.malformed, "cvct"); + verifyMalformedFile(testCase, ... + artifactPath(context, pack, "malformed"), "cvct"); end -function verifyEisPack(testCase, pack) - item = loadDta(testCase, pack.representativeFiles, "eis"); +function verifyEisPack(testCase, context, pack) + verifyTypedPack(testCase, pack); + item = loadDta(testCase, ... + artifactPath(context, pack, "representative"), "eis"); testCase.verifyGreaterThan(item.n, 10, ... "EIS debug sample should include a representative ZCURVE table."); testCase.verifyTrue(all(isfinite(item.Freq)), ... "EIS debug sample should include finite frequencies."); - edge = loadDta(testCase, pack.boundaryFiles.validEdge, "eis"); + edge = loadDta(testCase, ... + artifactPath(context, pack, "sparse"), "eis"); testCase.verifyEqual(edge.n, 8, ... "EIS edge sample should be valid but sparse."); - verifyMalformedFile(testCase, pack.boundaryFiles.malformed, "eis"); + verifyMalformedFile(testCase, ... + artifactPath(context, pack, "malformed"), "eis"); end function item = loadDta(testCase, filepath, kind) @@ -81,11 +88,23 @@ function verifyEisPack(testCase, pack) testCase.verifyTrue(status.ok, status.message); end -function verifyBoundaryFiles(testCase, files, kind) - edge = loadDta(testCase, files.validEdge, kind); +function verifyBoundaryFiles(testCase, context, pack, edgeId, kind) + edge = loadDta(testCase, artifactPath(context, pack, edgeId), kind); testCase.verifyGreaterThan(edge.n, 2, ... "Format-valid edge sample should load through the DTA facade."); - verifyMalformedFile(testCase, files.malformed, kind); + verifyMalformedFile(testCase, ... + artifactPath(context, pack, "malformed"), kind); +end + +function verifyTypedPack(testCase, pack) +testCase.verifyClass(pack, "labkit.app.diagnostic.SamplePack"); +testCase.verifyNotEmpty(pack.InitialProject.inputs.sources); +end + +function filepath = artifactPath(context, pack, id) +matches = cellfun(@(artifact) artifact.Id == id, pack.Artifacts); +filepath = fullfile(context.ArtifactFolder, ... + pack.Artifacts{matches}.RelativePath); end function verifyMalformedFile(testCase, filepath, kind) diff --git a/tests/cases/unit/apps/electrochem/VtResistanceExportTest.m b/tests/cases/unit/apps/electrochem/VtResistanceExportTest.m index 9e6c2668e..9069734fe 100644 --- a/tests/cases/unit/apps/electrochem/VtResistanceExportTest.m +++ b/tests/cases/unit/apps/electrochem/VtResistanceExportTest.m @@ -17,16 +17,16 @@ function verify_vtResistanceExport() assert(item.analysis.ok, item.analysis.message); definition = vt_resistance.definition(); - assert(definition.contractVersion == 2 && ... - definition.project.Version == 1 && ... - isempty(definition.project.Migrate), ... - 'VT Resistance should use a first-version Runtime V2 project.'); - project = definition.project.Create(); - assert(definition.project.Validate(project) && ... + assert(definition.ProjectSchema.Version == 1 && ... + isempty(definition.ProjectSchema.Migrate), ... + 'VT Resistance should use a first-version App SDK project.'); + project = definition.ProjectSchema.Create(); + assert(definition.ProjectSchema.Validate(project) && ... ~isfield(project.inputs, 'items'), ... 'VT Resistance should not persist decoded DTA items.'); - session = definition.createSession(project); - assert(session.selection.currentIndex == 0 && ... + context = labkit.app.internal.CallbackContextFactory.disconnected(); + session = definition.CreateSession(project, context); + assert(isempty(session.selection.files.Indices) && ... isempty(session.cache.items), ... 'An empty project should rebuild an empty transient session.'); @@ -90,7 +90,7 @@ function deleteIfExists(filepath) end function C = buildVTBatchTableData(items) - C = vt_resistance.userInterface.buildBatchTableData(items); + C = vt_resistance.analysisRun.buildBatchTableData(items); end function writeVTResultsCSV(items, filepath) diff --git a/tests/cases/unit/apps/gait/GaitAnalysisTest.m b/tests/cases/unit/apps/gait/GaitAnalysisTest.m index e8cfd6502..e2a1564df 100644 --- a/tests/cases/unit/apps/gait/GaitAnalysisTest.m +++ b/tests/cases/unit/apps/gait/GaitAnalysisTest.m @@ -171,12 +171,14 @@ function project_migration_adopts_canonical_source_collection(testCase) testCase.verifyEqual(migrated.inputs.sources, expected); testCase.verifyFalse(isfield(migrated.inputs, "source")); - testCase.verifyEqual(definition.project.Version, 3); - testCase.verifyTrue(isa(definition.project.Migrate, ... + testCase.verifyEqual(definition.ProjectSchema.Version, 3); + testCase.verifyTrue(isa(definition.ProjectSchema.Migrate, ... 'function_handle')); end function debug_sample_runs_on_an_isolated_gait_path(testCase) + folder = makeFolder(); + cleanup = onCleanup(@() cleanupFolder(folder)); [root, appRoot] = gaitRoots(); previousPath = path; pathCleanup = onCleanup(@() path(previousPath)); @@ -185,15 +187,21 @@ function debug_sample_runs_on_an_isolated_gait_path(testCase) addpath(appRoot); rehash path - pack = gait_analysis.debug.writeSamplePack(); + context = labkit.app.diagnostic.SampleContext(folder); + pack = gait_analysis.debug.writeSamplePack(context); + posePath = pack.InitialProject.inputs.sources(1) ... + .reference.originalPath; pose = gait_analysis.sourceFiles.readPoseFile( ... - pack.representativeFiles); + posePath); testCase.verifyTrue(pose.ok); testCase.verifyEqual(pose.frameRate, 30); + testCase.verifyEqual(pack.Scenario, ... + "representative-pose-coordinates"); + testCase.verifyEqual(numel(pack.Artifacts), 1); testCase.verifyEmpty(which("video_marker.projectSpec")); - clear pathCleanup + clear pathCleanup cleanup end end @@ -231,7 +239,7 @@ function current_video_marker_producer_matches_gait_reader(testCase) "anchorRevision", zeros(12, 1, "uint64")); project = struct(); project.inputs = struct( ... - "sources", labkit.ui.runtime.emptySourceRecords(), ... + "sources", struct([]), ... "videoMetadata", markerVideoMetadata()); project.parameters = struct(); project.annotations = struct( ... @@ -242,7 +250,7 @@ function current_video_marker_producer_matches_gait_reader(testCase) "edges", [1 2; 2 3; 3 4; 4 5]), ... "frames", frames, ... "calibration", ... - labkit.ui.interaction.scaleBarCalibration(20, 2, "mm")); + labkit.app.interaction.scaleCalibration(20, 2, "mm")); project.results = struct(); project.extensions = struct(); labkitProject = markerEnvelope(project); @@ -260,7 +268,7 @@ function current_video_marker_producer_matches_gait_reader(testCase) project.annotations.frames.coords = coords; project.inputs.videoMetadata = markerVideoMetadata(); project.annotations.calibration = ... - labkit.ui.interaction.scaleBarCalibration(20, 2, "mm"); + labkit.app.interaction.scaleCalibration(20, 2, "mm"); assert(spec.Validate(project)); labkitProject = markerEnvelope(project); end diff --git a/tests/cases/unit/apps/image_measurement/BatchImageCropExportTest.m b/tests/cases/unit/apps/image_measurement/BatchImageCropExportTest.m index 1bdb4383d..e64478b58 100644 --- a/tests/cases/unit/apps/image_measurement/BatchImageCropExportTest.m +++ b/tests/cases/unit/apps/image_measurement/BatchImageCropExportTest.m @@ -112,7 +112,7 @@ function checkPhysicalScaleCropUsesUnifiedOutputPixels() function checkPhysicalScaleUnitsAreConvertedWithoutMutatingCalibration() item = physicalItem("source_mm.png", uint8(80 * ones(120, 120)), 4); - item.scaleCalibration = labkit.ui.interaction.scaleBarCalibration(40, 10, "mm", ... + item.scaleCalibration = labkit.app.interaction.scaleCalibration(40, 10, "mm", ... struct('defaultUnit', 'mm', 'referenceLine', [1 1; 41 1])); plan = batch_crop.cropGeometry.scalePlan(item, struct( ... @@ -182,8 +182,8 @@ function checkMissingWorkflowPromptNamesAffectedFiles() items(3).scaleCalibration = ... batch_crop.scaleCalibration.emptyCalibration("um"); - centerText = batch_crop.userInterface.missingWorkflowItemsText(items, "center"); - scaleText = batch_crop.userInterface.missingWorkflowItemsText(items, "scale"); + centerText = batch_crop.resultFiles.missingWorkflowItemsText(items, "center"); + scaleText = batch_crop.resultFiles.missingWorkflowItemsText(items, "scale"); assert(contains(centerText, "needs_center.png") && ~contains(centerText, "ready.png"), ... 'Missing-center prompt should name only items without confirmed centers.'); @@ -200,8 +200,8 @@ function checkFilePanelEntriesExposeWorkflowStatus() items(3).scaleCalibration = ... batch_crop.scaleCalibration.emptyCalibration("um"); - pixelEntries = batch_crop.userInterface.filePanelEntries(items, "Pixels"); - physicalEntries = batch_crop.userInterface.filePanelEntries(items, "Physical"); + pixelEntries = batch_crop.sourceFiles.taskEntries(items, "Pixels"); + physicalEntries = batch_crop.sourceFiles.taskEntries(items, "Physical"); assert(isequal(string({pixelEntries.status}).', ... ["ready"; "needs center"; "ready"]), ... @@ -324,7 +324,7 @@ function checkExportPlanFingerprintTracksItemsAndOptions() item.angleDeg = 0; item.centerXY = [(size(imageData, 2) + 1) / 2, (size(imageData, 1) + 1) / 2]; item.centerSet = true; - item.scaleCalibration = labkit.ui.interaction.scaleBarCalibration(pixelsPerUnit, 1, "um", ... + item.scaleCalibration = labkit.app.interaction.scaleCalibration(pixelsPerUnit, 1, "um", ... struct('defaultUnit', 'um', 'referenceLine', [1 1; 1 + pixelsPerUnit, 1])); end diff --git a/tests/cases/unit/apps/image_measurement/BatchImageCropTest.m b/tests/cases/unit/apps/image_measurement/BatchImageCropTest.m index da3a44feb..a32d713be 100644 --- a/tests/cases/unit/apps/image_measurement/BatchImageCropTest.m +++ b/tests/cases/unit/apps/image_measurement/BatchImageCropTest.m @@ -33,60 +33,67 @@ function verify_batchImageCrop() checkTasksForSourceIdsExcludeImagePixels(); checkReadItemsAcceptsFilePanelCellPaths(); checkDuplicateItemCreatesIndependentCropTask(); - checkV2ProjectPresentationAndScaleBarGeometry(); + checkAppSdkProjectAndScaleBarGeometry(); end -function checkV2ProjectPresentationAndScaleBarGeometry() +function checkAppSdkProjectAndScaleBarGeometry() definition = batch_crop.definition(); - assert(definition.contractVersion == 2, ... - 'Batch crop definition should use the V2 runtime contract.'); - project = definition.project.Create(); - assert(definition.project.Version == 2 && ... - isa(definition.project.Migrate, 'function_handle'), ... - 'Batch crop should migrate v1 pixel-owning projects to v2 tasks.'); - assert(definition.project.Validate(project), ... + assert(definition.AppId == "batch_crop", ... + 'Batch crop definition should use the explicit App SDK contract.'); + project = definition.ProjectSchema.Create(); + assert(definition.ProjectSchema.Version == 3 && ... + isa(definition.ProjectSchema.Migrate, 'function_handle'), ... + ['Batch crop should migrate older projects to distinct ' ... + 'task source records.']); + assert(definition.ProjectSchema.Validate(project), ... 'Fresh batch crop projects should satisfy the app validator.'); item = batch_crop.sourceFiles.emptyItem(); item.path = "synthetic.png"; item.image = uint8(reshape(1:120, 10, 12)); item.centerXY = [6.5 5.5]; item.centerSet = true; - item.scaleCalibration = labkit.ui.interaction.scaleBarCalibration( ... + item.scaleCalibration = labkit.app.interaction.scaleCalibration( ... 40, 10, "um", struct('defaultUnit', 'um', ... 'referenceLine', [2 2; 42 2])); legacyProject = project; - legacyProject.inputs.items = item; - migrated = definition.project.Migrate(legacyProject, 1); + legacyProject.inputs.items = [item; item]; + migratedV2 = definition.ProjectSchema.Migrate(legacyProject, 1); + migrated = definition.ProjectSchema.Migrate(migratedV2, 2); assert(~isfield(migrated.inputs.items, 'image') && ... ~isfield(migrated.inputs.items, 'path') && ... - migrated.inputs.items.sourceId == "image1" && ... - labkit.ui.runtime.sourcePaths( ... - migrated.inputs.sources(1)) == "synthetic.png", ... + numel(migrated.inputs.sources) == 2 && ... + numel(unique(string({migrated.inputs.sources.id}))) == 2 && ... + isequal(string({migrated.inputs.items.sourceId}), ... + string({migrated.inputs.sources.id})) && ... + all(string({migrated.inputs.sources.role}) == "cropSource"), ... 'The v1 migration should replace embedded pixels and paths with a source id.'); task = rmfield(item, {'path', 'image'}); task.sourceId = "image1"; - project.inputs.sources = labkit.ui.runtime.sourceRecord( ... - "image1", "cropSource", "synthetic.png", true); + sourcePath = string(tempname) + ".png"; + imwrite(item.image, sourcePath); + cleanup = onCleanup(@() deleteIfPresent(sourcePath)); + project.inputs.sources = labkit.app.project.sourceRecord( ... + "image1", "cropSource", sourcePath, true); project.inputs.items = orderfields( ... task, batch_crop.cropTasks.emptyTask()); - session = definition.createSession(definition.project.Create()); - session.selection.currentIndex = 1; - session.cache.images{1} = item.image; - state = struct("project", project, "session", session); - view = definition.present(state); - assert(isfield(view, 'interactions') && ... - isfield(view.interactions, 'cropCenter') && ... - view.interactions.cropCenter.Kind == "pointSlots" && ... - view.interactions.cropCenter.Event == "cropCenterEdited" && ... - view.interactions.cropCenter.Options.placeSelectedOnBackground, ... - 'Batch crop should use a draggable center with one-click placement.'); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, project); + state = runtime.State; + runtime.close(); assert(~contains(evalc('disp(state)'), 'matlab.ui'), ... 'Batch crop canonical state should contain no live UI handles.'); - geometry = labkit.ui.interaction.scaleBarGeometry( ... + geometry = labkit.app.interaction.scaleBarGeometry( ... size(item.image), item.scaleCalibration, 0.1, "Bottom center", "White"); assert(size(geometry.line, 1) == 2 && ... isequal(geometry.color, [1 1 1]) && geometry.barLength == 0.1, ... 'Scale-bar geometry should be deterministic and serializable.'); + clear cleanup +end + +function deleteIfPresent(path) +if isfile(path) + delete(path); +end end function checkCropCenterAvoidsInvalidRotatedMaskRegions() @@ -242,17 +249,17 @@ function checkPreviewViewPreservesZoomAcrossPaddingRedraw() geometry = batch_crop.cropGeometry.prepareCropCanvas(img, struct( ... 'angleDeg', 0, ... 'paddingPercent', 40)); - placement = batch_crop.userInterface.previewPlacement(geometry); + placement = batch_crop.cropPreview.placement(geometry); fig = figure('Visible', 'off'); cleanup = onCleanup(@() close(fig)); ax = axes(fig); ax.XLim = [5, 12]; ax.YLim = [4, 10]; - state = batch_crop.userInterface.capturePreviewView(ax, geometry, placement); + state = batch_crop.cropPreview.captureView(ax, geometry, placement); ax.XLim = placement.xData + [-0.5, 0.5]; ax.YLim = placement.yData + [-0.5, 0.5]; - batch_crop.userInterface.restorePreviewView(ax, state, geometry, placement); + batch_crop.cropPreview.restoreView(ax, state, geometry, placement); assert(abs(diff(ax.XLim) - state.xSpan) < 1e-9 && ... abs(diff(ax.YLim) - state.ySpan) < 1e-9, ... @@ -268,12 +275,12 @@ function checkPreviewViewPreservesOriginalRoiAcrossPreviewScaleChanges() geometryA = batch_crop.cropGeometry.prepareCropCanvas(img, struct( ... 'angleDeg', 0, ... 'paddingPercent', 20)); - placementA = batch_crop.userInterface.previewPlacement(geometryA); + placementA = batch_crop.cropPreview.placement(geometryA); geometryB = batch_crop.cropGeometry.prepareCropCanvas(img, struct( ... 'angleDeg', 0, ... 'paddingPercent', 200, ... 'maxCanvasPixels', 5000)); - placementB = batch_crop.userInterface.previewPlacement(geometryB); + placementB = batch_crop.cropPreview.placement(geometryB); assert(geometryB.coordinateScale < geometryA.coordinateScale, ... 'Test setup should force a changed preview coordinate scale.'); @@ -283,8 +290,8 @@ function checkPreviewViewPreservesOriginalRoiAcrossPreviewScaleChanges() ax.XLim = [30, 70]; ax.YLim = [25, 65]; - state = batch_crop.userInterface.capturePreviewView(ax, geometryA, placementA); - batch_crop.userInterface.restorePreviewView(ax, state, geometryB, placementB); + state = batch_crop.cropPreview.captureView(ax, geometryA, placementA); + batch_crop.cropPreview.restoreView(ax, state, geometryB, placementB); restored = restoredOriginalLimits(ax, geometryB, placementB); assert(max(abs(restored.x - state.originalXLim)) < 1e-9 && ... @@ -297,8 +304,8 @@ function checkPreviewRenderDataDownsamplesWithoutChangingCoordinates() geometry = batch_crop.cropGeometry.prepareCropCanvas(img, struct( ... 'angleDeg', 0, ... 'paddingPercent', 0)); - placement = batch_crop.userInterface.previewPlacement(geometry); - render = batch_crop.userInterface.previewRenderData(geometry, placement, ... + placement = batch_crop.cropPreview.placement(geometry); + render = batch_crop.cropPreview.renderData(geometry, placement, ... struct('MaxPreviewPixels', 200)); assert(render.scaleFactor > 1, ... @@ -424,7 +431,7 @@ function checkDuplicateItemCreatesIndependentCropTask() item.centerXY = [3, 4]; item.centerSet = true; item.paddingPercent = 25; - item.scaleCalibration = labkit.ui.interaction.scaleBarCalibration(80, 20, "um", ... + item.scaleCalibration = labkit.app.interaction.scaleCalibration(80, 20, "um", ... struct('defaultUnit', 'um')); duplicated = batch_crop.cropTasks.duplicateItem(item); @@ -451,7 +458,7 @@ function checkDuplicateItemCreatesIndependentCropTask() item.angleDeg = 0; item.centerXY = [(size(imageData, 2) + 1) / 2, (size(imageData, 1) + 1) / 2]; item.centerSet = true; - item.scaleCalibration = labkit.ui.interaction.scaleBarCalibration(pixelsPerUnit, 1, "um", ... + item.scaleCalibration = labkit.app.interaction.scaleCalibration(pixelsPerUnit, 1, "um", ... struct('defaultUnit', 'um', 'referenceLine', [1 1; 1 + pixelsPerUnit, 1])); end diff --git a/tests/cases/unit/apps/image_measurement/FlirThermalTest.m b/tests/cases/unit/apps/image_measurement/FlirThermalTest.m index f0b4e3af9..55ab82d1f 100644 --- a/tests/cases/unit/apps/image_measurement/FlirThermalTest.m +++ b/tests/cases/unit/apps/image_measurement/FlirThermalTest.m @@ -15,9 +15,9 @@ function flirThermalAppReadsSummarizesAndExports(testCase) [items, importReport] = flir_thermal.sourceFiles.readImages( ... [string(ordinaryPath); string(sourcePath)]); range = items(1).displayRange; - rows = flir_thermal.userInterface.summaryTableData(items(1), range, "turbo"); - details = flir_thermal.userInterface.detailLines(items, 1, folder); - entries = flir_thermal.userInterface.filePanelEntries(items); + rows = flir_thermal.thermalPreview.presentationData.summaryTableData(items(1), range, "turbo"); + details = flir_thermal.thermalPreview.presentationData.detailLines(items, 1, folder); + entries = flir_thermal.thermalPreview.presentationData.filePanelEntries(items); payload = flir_thermal.resultFiles.writeOutputs(items, struct( ... "outputFolder", folder, ... "format", "PNG", ... @@ -25,7 +25,7 @@ function flirThermalAppReadsSummarizesAndExports(testCase) "colorMapping", "Gamma", ... "gammaValue", 1.6, ... "range", [])); - labels = flir_thermal.userInterface.rangeControlLabels(); + labels = flir_thermal.thermalPreview.presentationData.rangeControlLabels(); testCase.verifyEqual(numel(items), 1); testCase.verifyEqual(importReport.requested, 2); @@ -79,13 +79,13 @@ function flirThermalAppReadsSummarizesAndExports(testCase) gradient = [10 20; 40 90]; unchanged = gradient; - linearRgb = flir_thermal.userInterface.renderThermalImage( ... + linearRgb = flir_thermal.thermalPreview.presentationData.renderThermalImage( ... gradient, [10 90], "turbo", "Linear"); - logRgb = flir_thermal.userInterface.renderThermalImage( ... + logRgb = flir_thermal.thermalPreview.presentationData.renderThermalImage( ... gradient, [10 90], "turbo", "Log"); - gammaRgb = flir_thermal.userInterface.renderThermalImage( ... + gammaRgb = flir_thermal.thermalPreview.presentationData.renderThermalImage( ... gradient, [10 90], "turbo", "Gamma", 1.6); - strongerGammaRgb = flir_thermal.userInterface.renderThermalImage( ... + strongerGammaRgb = flir_thermal.thermalPreview.presentationData.renderThermalImage( ... gradient, [10 90], "turbo", "Gamma", 4.0); testCase.verifyEqual(gradient, unchanged, ... 'Nonlinear color mapping should not mutate source temperature data.'); @@ -108,10 +108,10 @@ function flirThermalRawItemsFallbackAndRangeStatus(testCase) item.units = "raw"; item.displayRange = [1 4]; - [values, units, label] = flir_thermal.userInterface.valueMatrix(item); - rows = flir_thermal.userInterface.summaryTableData(item, ... + [values, units, label] = flir_thermal.thermalPreview.presentationData.valueMatrix(item); + rows = flir_thermal.thermalPreview.presentationData.summaryTableData(item, ... item.displayRange, "gray"); - entries = flir_thermal.userInterface.filePanelEntries(item); + entries = flir_thermal.thermalPreview.presentationData.filePanelEntries(item); testCase.verifyEqual(values, item.raw); testCase.verifyEqual(units, "raw"); @@ -121,7 +121,7 @@ function flirThermalRawItemsFallbackAndRangeStatus(testCase) "needs range; temperature unavailable"); item.rangeAdjusted = true; - entries = flir_thermal.userInterface.filePanelEntries(item); + entries = flir_thermal.thermalPreview.presentationData.filePanelEntries(item); testCase.verifyEqual(entries.status, ... "range set; temperature unavailable"); end @@ -135,8 +135,8 @@ function flirThermalWarnsWhenCorrectionUsesDefaults(testCase) writeSyntheticFlirRjpegFixture(sourcePath, struct("emissivity", 0)); items = flir_thermal.sourceFiles.readImages(sourcePath); - details = flir_thermal.userInterface.detailLines(items, 1, folder); - entries = flir_thermal.userInterface.filePanelEntries(items); + details = flir_thermal.thermalPreview.presentationData.detailLines(items, 1, folder); + entries = flir_thermal.thermalPreview.presentationData.filePanelEntries(items); conversion = items.metadata.temperatureConversion; testCase.verifyTrue(conversion.usedDefaults); @@ -154,16 +154,16 @@ function flirThermalRangeControlBoundsPresets(testCase) item.name = "bounds_fixture.rjpg"; item.temperatureC = [20 25; 35 40]; item.units = "C"; - labels = flir_thermal.userInterface.rangeControlLabels(); + labels = flir_thermal.thermalPreview.presentationData.rangeControlLabels(); - items = flir_thermal.userInterface.rangePresetItems(); - defaultBounds = flir_thermal.userInterface.rangeControlBounds(item, ... + items = flir_thermal.thermalPreview.presentationData.rangePresetItems(); + defaultBounds = flir_thermal.thermalPreview.presentationData.rangeControlBounds(item, ... labels.standardPreset, [0 1]); - estimatedBounds = flir_thermal.userInterface.rangeControlBounds(item, ... + estimatedBounds = flir_thermal.thermalPreview.presentationData.rangeControlBounds(item, ... labels.estimatedPreset, [-20 120]); - highBounds = flir_thermal.userInterface.rangeControlBounds(item, ... + highBounds = flir_thermal.thermalPreview.presentationData.rangeControlBounds(item, ... labels.highPreset, [-20 120]); - wideBounds = flir_thermal.userInterface.rangeControlBounds(item, ... + wideBounds = flir_thermal.thermalPreview.presentationData.rangeControlBounds(item, ... labels.widePreset, [-20 120]); testCase.verifyTrue(any(strcmp(items, char(labels.estimatedPreset)))); @@ -201,14 +201,14 @@ function flirThermalReadingsAndManifestUseIndependentRois(testCase) flir_thermal.analysisRun.roiTemperatureMeanReading( ... item.temperatureC, [1 3], [3 3]); - details = flir_thermal.userInterface.detailLines(item, 1, folder); + details = flir_thermal.thermalPreview.presentationData.detailLines(item, 1, folder); payload = flir_thermal.resultFiles.writeOutputs(item, struct( ... "outputFolder", folder, ... "format", "PNG", ... "palette", "turbo", ... "range", [])); manifest = payload.manifest; - labels = flir_thermal.userInterface.rangeControlLabels(); + labels = flir_thermal.thermalPreview.presentationData.rangeControlLabels(); testCase.verifyTrue(any(contains(string(details), labels.roiHotSpot))); testCase.verifyTrue(any(contains(string(details), "Temperature differences"))); diff --git a/tests/cases/unit/apps/image_measurement/FocusStackFusionTest.m b/tests/cases/unit/apps/image_measurement/FocusStackFusionTest.m index 52ac5abc7..29267b206 100644 --- a/tests/cases/unit/apps/image_measurement/FocusStackFusionTest.m +++ b/tests/cases/unit/apps/image_measurement/FocusStackFusionTest.m @@ -24,13 +24,14 @@ function verify_focusStackFusion() function checkProjectContract() definition = focus_stack.definition(); - project = definition.project.Create(); - assert(definition.project.Validate(project), ... + project = definition.ProjectSchema.Create(); + assert(definition.ProjectSchema.Validate(project), ... 'Focus Stack projectSpec should accept its current project.'); assert(isempty(project.inputs.sources) && ... strlength(project.parameters.outputFolder) == 0, ... 'A new project should be source-free and avoid startup path side effects.'); - session = definition.createSession(project); + session = definition.CreateSession( ... + project, labkit.app.internal.CallbackContextFactory.disconnected()); assert(isempty(session.cache.images) && ... ~session.cache.result.ok, ... 'Focus Stack session reconstruction changed for an empty project.'); diff --git a/tests/cases/unit/apps/image_measurement/ImageCurvatureMeasurementTest.m b/tests/cases/unit/apps/image_measurement/ImageCurvatureMeasurementTest.m index b13a6cf30..21c830e4a 100644 --- a/tests/cases/unit/apps/image_measurement/ImageCurvatureMeasurementTest.m +++ b/tests/cases/unit/apps/image_measurement/ImageCurvatureMeasurementTest.m @@ -23,10 +23,11 @@ function verify_imageCurvatureMeasurement() function checkProjectSourceMigration() definition = curvature.definition(); - project = definition.project.Create(); - assert(definition.project.Validate(project), ... + project = definition.ProjectSchema.Create(); + assert(definition.ProjectSchema.Validate(project), ... 'Curvature projectSpec should accept its current project.'); - session = definition.createSession(project); + session = definition.CreateSession( ... + project, labkit.app.internal.CallbackContextFactory.disconnected()); assert(isempty(session.cache.image) && ... session.workflow.editMode == "none", ... 'Curvature empty-session reconstruction changed.'); @@ -34,13 +35,13 @@ function checkProjectSourceMigration() project.inputs.source = expected; project.inputs = rmfield(project.inputs, "sources"); - migrated = definition.project.Migrate(project, 1); + migrated = definition.ProjectSchema.Migrate(project, 1); assert(isequal(migrated.inputs.sources, expected), ... 'Curvature source migration changed the source record.'); assert(~isfield(migrated.inputs, 'source'), ... 'Curvature source migration retained the retired field.'); - assert(definition.project.Version == 2, ... + assert(definition.ProjectSchema.Version == 2, ... 'Curvature project version did not advance.'); end diff --git a/tests/cases/unit/apps/image_measurement/ImageEnhanceTest.m b/tests/cases/unit/apps/image_measurement/ImageEnhanceTest.m index 59cdfd381..0342ddbcf 100644 --- a/tests/cases/unit/apps/image_measurement/ImageEnhanceTest.m +++ b/tests/cases/unit/apps/image_measurement/ImageEnhanceTest.m @@ -28,24 +28,24 @@ function verify_imageEnhance() checkPerImageExportSteps(); checkExportTaskFingerprintTracksInputsOptionsAndSteps(); checkExportTaskPreservesPerImageSteps(); - checkRuntimeV2ProjectAndPresenterContracts(); + checkAppSdkProjectAndPresenterContracts(); end -function checkRuntimeV2ProjectAndPresenterContracts() +function checkAppSdkProjectAndPresenterContracts() definition = image_enhance.definition(); - assert(definition.contractVersion == 2, ... - 'Image Enhance must use the Runtime V2 definition contract.'); - project = definition.project.Create(); - assert(definition.project.Validate(project), ... + assert(isa(definition, 'labkit.app.Definition'), ... + 'Image Enhance must use the explicit App SDK definition contract.'); + project = definition.ProjectSchema.Create(); + assert(definition.ProjectSchema.Validate(project), ... 'A new Image Enhance project should satisfy its durable contract.'); assert(~isfield(project, 'items') && ~isfield(project, 'previewImages'), ... 'The durable project must not contain decoded pixels or preview caches.'); - session = image_enhance.createSession(project); + session = image_enhance.createSession( ... + project, labkit.app.internal.CallbackContextFactory.disconnected()); state = struct('project', project, 'session', session); - presentation = image_enhance.userInterface.presentWorkbench(state); - assert(isscalar(presentation) && ... - isfield(presentation.previews.preview, 'Renderer'), ... - 'The V2 presenter should return one deterministic registered preview.'); + presentation = image_enhance.workbench.present(state); + assert(isa(presentation, 'labkit.app.view.Snapshot'), ... + 'The presenter should return one explicit semantic snapshot.'); assert(isempty(session.cache.item) && ... ~isfield(session, 'whiteRoiHandle'), ... 'The rebuildable session must start without pixels or ROI handles.'); @@ -177,7 +177,8 @@ function checkEmptyNumericToolValuesStayScalar() function checkWhiteRoiToolAvailabilityFollowsBatchMode() spec = image_enhance.projectSpec(); project = spec.Create(); - session = image_enhance.createSession(project); + session = image_enhance.createSession( ... + project, labkit.app.internal.CallbackContextFactory.disconnected()); project.inputs.sources = struct('id', "image-1", 'required', true, ... 'role', "source-image", 'reference', struct()); annotation = image_enhance.enhancementAnnotations.empty(); @@ -187,17 +188,17 @@ function checkWhiteRoiToolAvailabilityFollowsBatchMode() session.cache.item = image_enhance.sourceFiles.emptyItem(); S = struct('project', project, 'session', session); - availability = image_enhance.userInterface.toolAvailability(S, 'White ROI calibration'); + availability = image_enhance.imagePreview.presentationData.toolAvailability(S, 'White ROI calibration'); assert(~availability.canSetWhiteRoi && ~availability.canApply, ... 'White ROI controls should stay disabled in shared batch mode.'); S.project.parameters.batchMode = false; - availability = image_enhance.userInterface.toolAvailability(S, 'White ROI calibration'); + availability = image_enhance.imagePreview.presentationData.toolAvailability(S, 'White ROI calibration'); assert(availability.canSetWhiteRoi && ~availability.canApply, ... 'Turning off shared batch mode should immediately enable ROI selection.'); S.project.annotations.items.whiteRoi = [1 1 4 4]; - availability = image_enhance.userInterface.toolAvailability(S, 'White ROI calibration'); + availability = image_enhance.imagePreview.presentationData.toolAvailability(S, 'White ROI calibration'); assert(availability.canSetWhiteRoi && availability.canApply, ... 'A per-image white ROI should enable applying the tool for the selected image.'); assert(~availability.canPreviewPending, ... @@ -205,13 +206,13 @@ function checkWhiteRoiToolAvailabilityFollowsBatchMode() end function checkWhiteRoiDefaultUsesImageCorner() - position = image_enhance.userInterface.defaultWhiteRoi([100 200 3]); + position = image_enhance.imagePreview.presentationData.defaultWhiteRoi([100 200 3]); assert(position(1) <= 10 && position(2) <= 10, ... 'Default white ROI should start near the image corner instead of the center.'); assert(position(3) == 40 && position(4) == 20, ... 'Default white ROI should keep the existing 20 percent image-size footprint.'); - smallPosition = image_enhance.userInterface.defaultWhiteRoi([6 5 3]); + smallPosition = image_enhance.imagePreview.presentationData.defaultWhiteRoi([6 5 3]); assert(isequal(smallPosition, [1 1 5 6]), ... 'Default white ROI should clamp to small image bounds.'); end @@ -222,7 +223,7 @@ function checkResultTableReportsExportSizeNotPreviewSize() item.image = zeros(2400, 3200, 3); previewImage = zeros(1500, 2000, 3); - data = image_enhance.userInterface.resultTableData(item, previewImage, 0); + data = image_enhance.imagePreview.presentationData.resultTableData(item, previewImage, 0); metricNames = string(data(:, 1)); outputValue = string(data(metricNames == "Output size", 2)); @@ -389,7 +390,7 @@ function checkExportTaskPreservesPerImageSteps() function checkPreviewImageDownsamplesLargeInputs() img = repmat(linspace(0, 1, 160), 120, 1); - [preview, scale] = image_enhance.userInterface.previewImage(img, 50); + [preview, scale] = image_enhance.imagePreview.presentationData.previewImage(img, 50); assert(size(preview, 3) == 3, ... 'Enhancement preview should render grayscale inputs as RGB.'); assert(size(preview, 1) <= 50, ... @@ -402,12 +403,12 @@ function checkPreviewImageDownsamplesLargeInputs() function checkPixelRadiusScalesWithPreview() img = syntheticGradientImage(); - preview = image_enhance.userInterface.previewImage(img, 24); + preview = image_enhance.imagePreview.presentationData.previewImage(img, 24); fullStep = image_enhance.analysisRun.makeStep('Local contrast', 50, 12, 0); previewStep = image_enhance.analysisRun.makeStep('Local contrast', 50, 6, 0); fullProcessed = image_enhance.analysisRun.applyStep(img, fullStep, []); - fullPreview = image_enhance.userInterface.previewImage(fullProcessed, 24); + fullPreview = image_enhance.imagePreview.presentationData.previewImage(fullProcessed, 24); previewProcessed = image_enhance.analysisRun.applyStep(preview, previewStep, []); assert(mean(abs(fullPreview(:) - previewProcessed(:))) < 0.04, ... diff --git a/tests/cases/unit/apps/image_measurement/ImageMatchTest.m b/tests/cases/unit/apps/image_measurement/ImageMatchTest.m index 643bec72c..c57c031c7 100644 --- a/tests/cases/unit/apps/image_measurement/ImageMatchTest.m +++ b/tests/cases/unit/apps/image_measurement/ImageMatchTest.m @@ -23,26 +23,26 @@ function verify_imageMatch() checkPreviewImageDownsamplesLargeInputs(); checkManifestAndExportContract(); checkExportTaskFingerprintTracksReferenceOptionsAndSteps(); - checkRuntimeV2ProjectAndPresenterContracts(); + checkAppSdkProjectAndPresenterContracts(); end -function checkRuntimeV2ProjectAndPresenterContracts() +function checkAppSdkProjectAndPresenterContracts() definition = image_match.definition(); - assert(definition.contractVersion == 2, ... - 'Image Match must use the Runtime V2 definition contract.'); - project = definition.project.Create(); - assert(definition.project.Validate(project), ... + assert(isa(definition, 'labkit.app.Definition'), ... + 'Image Match must use the explicit App SDK definition contract.'); + project = definition.ProjectSchema.Create(); + assert(definition.ProjectSchema.Validate(project), ... 'A new Image Match project should satisfy its durable contract.'); assert(~isfield(project, 'items') && ~isfield(project, 'referenceItem'), ... 'The durable project must exclude decoded source/reference pixels.'); assert(strlength(project.parameters.outputFolder) == 0, ... 'A new project should avoid startup path side effects.'); - session = definition.createSession(project); + session = definition.CreateSession( ... + project, labkit.app.internal.CallbackContextFactory.disconnected()); state = struct('project', project, 'session', session); - presentation = image_match.userInterface.presentWorkbench(state); - assert(isscalar(presentation) && ... - isfield(presentation.previews.preview, 'Renderer'), ... - 'The V2 presenter should return one deterministic registered preview.'); + presentation = image_match.workbench.present(state); + assert(isa(presentation, 'labkit.app.view.Snapshot'), ... + 'The presenter should return one explicit semantic snapshot.'); end function checkWhiteBalanceMatchMovesChannelRatiosTowardReference() @@ -173,7 +173,7 @@ function checkResultTableReportsExportSizeNotPreviewSize() item.image = zeros(2600, 3900, 3); previewImage = zeros(1500, 2250, 3); - data = image_match.userInterface.resultTableData(item, previewImage, 0); + data = image_match.imagePreview.presentationData.resultTableData(item, previewImage, 0); metricNames = string(data(:, 1)); outputValue = string(data(metricNames == "Output size", 2)); @@ -255,7 +255,7 @@ function checkExportTaskFingerprintTracksReferenceOptionsAndSteps() function checkPreviewImageDownsamplesLargeInputs() img = repmat(linspace(0, 1, 160), 120, 1); - preview = image_match.userInterface.previewImage(img, 50); + preview = image_match.imagePreview.presentationData.previewImage(img, 50); assert(size(preview, 3) == 3, ... 'Image-match preview should render grayscale inputs as RGB.'); assert(size(preview, 1) <= 50, ... diff --git a/tests/cases/unit/apps/image_measurement/ImageMeasurementDebugSamplePackTest.m b/tests/cases/unit/apps/image_measurement/ImageMeasurementDebugSamplePackTest.m index 8e194213e..31c514bec 100644 --- a/tests/cases/unit/apps/image_measurement/ImageMeasurementDebugSamplePackTest.m +++ b/tests/cases/unit/apps/image_measurement/ImageMeasurementDebugSamplePackTest.m @@ -6,55 +6,86 @@ function image_measurement_debug_sample_packs_read_through_app_io(testCase) setupLabKitTestPath(); root = string(tempname); cleanup = onCleanup(@() cleanupFolder(root)); - mkdir(char(root)); - debug = labkit.ui.debug.context("image_debug_sample_test", struct( ... - "logFile", fullfile(char(root), "trace.log"))); + debug = labkit.app.diagnostic.SampleContext(root); batch = batch_crop.debug.writeSamplePack(debug); - items = batch_crop.sourceFiles.readItems(batch.representativeFiles); + items = batch_crop.sourceFiles.readItems([ ... + artifactPath(debug, batch, "sourceA"); ... + artifactPath(debug, batch, "sourceB")]); testCase.verifyEqual(numel(items), 2); - verifyThrows(testCase, @() imread(char(batch.boundaryFiles.malformed)), ... + verifyThrows(testCase, @() imread(char( ... + artifactPath(debug, batch, "malformed"))), ... "Malformed batch-crop image should fail through imread."); focus = focus_stack.debug.writeSamplePack(debug); - focusImages = focus_stack.sourceFiles.readImages(focus.representativeFiles); + focusImages = focus_stack.sourceFiles.readImages([ ... + artifactPath(debug, focus, "image1"); ... + artifactPath(debug, focus, "image2"); ... + artifactPath(debug, focus, "image3"); ... + artifactPath(debug, focus, "image4")]); testCase.verifyEqual(numel(focusImages), 4); - verifyThrows(testCase, @() imread(char(focus.boundaryFiles.malformed)), ... + verifyThrows(testCase, @() imread(char( ... + artifactPath(debug, focus, "malformed"))), ... "Malformed focus-stack image should fail through imread."); enhance = image_enhance.debug.writeSamplePack(debug); - enhanceItems = image_enhance.sourceFiles.readImages(enhance.representativeFiles); + enhanceItems = image_enhance.sourceFiles.readImages([ ... + artifactPath(debug, enhance, "uneven"); ... + artifactPath(debug, enhance, "colorCast")]); testCase.verifyEqual(numel(enhanceItems), 2); - testCase.verifyFalse(isempty(imread(char(enhance.boundaryFiles.validEdge)))); + testCase.verifyFalse(isempty(imread(char( ... + artifactPath(debug, enhance, "lowContrast"))))); match = image_match.debug.writeSamplePack(debug); - reference = image_match.sourceFiles.readImages(match.referenceFile); - sources = image_match.sourceFiles.readImages(match.representativeFiles); + reference = image_match.sourceFiles.readImages( ... + artifactPath(debug, match, "reference")); + sources = image_match.sourceFiles.readImages([ ... + artifactPath(debug, match, "warm"); ... + artifactPath(debug, match, "dim")]); testCase.verifyEqual(numel(reference), 1); testCase.verifyEqual(numel(sources), 2); curve = curvature.debug.writeSamplePack(debug); - testCase.verifyFalse(isempty(imread(char(curve.representativeFiles)))); - testCase.verifyFalse(isempty(imread(char(curve.boundaryFiles.validEdge)))); + testCase.verifyFalse(isempty(imread(char( ... + artifactPath(debug, curve, "arc"))))); + testCase.verifyFalse(isempty(imread(char( ... + artifactPath(debug, curve, "lowContrast"))))); flir = flir_thermal.debug.writeSamplePack(debug); - [thermalItems, report] = flir_thermal.sourceFiles.readImages(flir.representativeFiles); + [thermalItems, report] = flir_thermal.sourceFiles.readImages([ ... + artifactPath(debug, flir, "warm"); ... + artifactPath(debug, flir, "cool")]); testCase.verifyEqual(report.loaded, 2); testCase.verifyEqual(numel(thermalItems), 2); - [~, edgeReport] = flir_thermal.sourceFiles.readImages(flir.boundaryFiles.validEdgeLowContrast); + [~, edgeReport] = flir_thermal.sourceFiles.readImages( ... + artifactPath(debug, flir, "lowContrast")); testCase.verifyEqual(edgeReport.loaded, 1); - [~, badReport] = flir_thermal.sourceFiles.readImages(flir.boundaryFiles.malformedPlainJpeg); + [~, badReport] = flir_thermal.sourceFiles.readImages( ... + artifactPath(debug, flir, "plainJpeg")); testCase.verifyEqual(badReport.loaded, 0); video = video_marker.debug.writeSamplePack(debug); - [reader, info] = video_marker.videoSource.openVideo(video.representativeFiles); + testCase.verifyClass(video, "labkit.app.diagnostic.SamplePack"); + [reader, info] = video_marker.videoSource.openVideo( ... + artifactPath(debug, video, "video")); frame = video_marker.videoSource.readFrame(reader, 1); testCase.verifyEqual(info.frameCount, 6); testCase.verifySize(frame, [72 96 3]); + clear reader + clear cleanup end end end +function filepath = artifactPath(context, pack, id) +ids = string(cellfun(@(value) value.Id, pack.Artifacts, ... + "UniformOutput", false)); +index = find(ids == string(id), 1); +assert(~isempty(index), "Expected diagnostic artifact %s.", id); +parts = cellstr(split(pack.Artifacts{index}.RelativePath, "/")); +filepath = string(fullfile(char(context.ArtifactFolder), parts{:})); +end + function verifyThrows(testCase, fcn, message) didThrow = false; try diff --git a/tests/cases/unit/apps/image_measurement/VideoMarkerTest.m b/tests/cases/unit/apps/image_measurement/VideoMarkerTest.m index 0b55e9a8a..ed9f12d08 100644 --- a/tests/cases/unit/apps/image_measurement/VideoMarkerTest.m +++ b/tests/cases/unit/apps/image_measurement/VideoMarkerTest.m @@ -4,7 +4,7 @@ methods (Test, TestTags = {'Unit'}) function first_skeleton_preset_is_legacy_five_point_leg(testCase) setupLabKitTestPath(); - presets = video_marker.userInterface.skeletonPresets(); + presets = video_marker.skeletonSetup.presets(); testCase.verifyEqual(presets(1).label, "Legacy leg (5 points)"); testCase.verifyEqual(presets(1).pointNames, ... ["iliac"; "hip"; "knee"; "ankle"; "foot"]); @@ -67,7 +67,7 @@ function annotations_inherit_confirm_and_export_round_trip(testCase) videoInfo = struct("path", "synthetic.avi", "frameCount", 4, ... "frameRate", 20, "duration", 0.2, "height", 72, "width", 96); - calibration = labkit.ui.interaction.scaleBarCalibration(40, 2, "mm"); + calibration = labkit.app.interaction.scaleCalibration(40, 2, "mm"); csvPath = fullfile(folder, "markers.csv"); video_marker.markerCsv.writeFile(csvPath, annotations, skeleton, videoInfo, calibration); payload = video_marker.markerCsv.readFile(csvPath); @@ -233,7 +233,7 @@ function coordinate_export_applies_scale_origin_and_y_axis(testCase) annotations, 2, [13 25; 35 45], "confirmed"); videoInfo = struct("path", "synthetic.avi", "frameCount", 2, ... "frameRate", 10, "duration", 0.2, "height", 60, "width", 80); - calibration = labkit.ui.interaction.scaleBarCalibration(20, 2, "mm"); + calibration = labkit.app.interaction.scaleCalibration(20, 2, "mm"); opts = video_marker.coordinateExport.options( ... "startFrame", 1, "endFrame", 2, ... @@ -251,34 +251,75 @@ function coordinate_export_applies_scale_origin_and_y_axis(testCase) testCase.verifyEqual(T.origin_point_id, ["hip"; "hip"]); end - function runtime_v2_project_presenter_and_resume_contracts(testCase) + function exports_default_beside_the_video(testCase) + setupLabKitTestPath(); + folder = string(tempname); + mkdir(folder); + cleanup = onCleanup(@() cleanupFolder(folder)); + videoPath = fullfile(folder, "synthetic.avi"); + + filepath = video_marker.resultFiles.defaultOutputPath( ... + videoPath, "video_marker_coordinates.csv"); + + testCase.verifyEqual(filepath, string(fullfile( ... + folder, "video_marker", ... + "video_marker_coordinates.csv"))); + testCase.verifyTrue(isfolder(fullfile(folder, "video_marker"))); + clear cleanup + end + + function synthetic_diagnostics_launch_through_definition(testCase) + setupLabKitTestPath(); + folder = string(tempname); + cleanup = onCleanup(@() cleanupFolder(folder)); + options = labkit.app.diagnostic.Options( ... + Level="verbose", ArtifactFolder=folder, ... + Sample="synthetic"); + + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + video_marker.definition(), [], struct(), options); + runtimeCleanup = onCleanup(@() runtime.close()); + + testCase.verifyEqual( ... + runtime.State.session.cache.videoInfo.frameCount, 6); + testCase.verifyEqual(numel( ... + runtime.State.project.annotations.skeleton.pointNames), 5); + testCase.verifyTrue(isfile(fullfile(folder, "sample-pack.json"))); + clear runtimeCleanup runtime + clear cleanup + end + + function app_sdk_project_presenter_and_resume_contracts(testCase) setupLabKitTestPath(); definition = video_marker.definition(); - testCase.verifyEqual(definition.contractVersion, 2); - project = definition.project.Create(); + testCase.verifyClass(definition, "labkit.app.Definition"); + project = definition.ProjectSchema.Create(); project.annotations.skeleton = video_marker.skeletonDefinition.fromText( ... "a, b, c, d, e", "a-b, b-c, c-d, d-e"); project.annotations.frames = ... video_marker.frameAnnotations.emptyAnnotations(0, 5); - testCase.verifyTrue(definition.project.Validate(project)); - testCase.verifyTrue(isa(definition.project.Migrate, ... + testCase.verifyTrue(definition.ProjectSchema.Validate(project)); + testCase.verifyTrue(isa(definition.ProjectSchema.Migrate, ... 'function_handle')); testCase.verifyEqual(project.inputs.videoMetadata, ... video_marker.videoSource.emptyMetadata()); testCase.verifyFalse(isfield(project, 'currentImage')); - session = video_marker.createSession(project); + session = video_marker.createSession( ... + project, ... + labkit.app.internal.CallbackContextFactory.disconnected()); state = struct('project', project, 'session', session); - presentation = video_marker.userInterface.presentWorkbench(state); - testCase.verifyTrue(isscalar(presentation)); - testCase.verifyEqual( ... - presentation.controls.keypointTable.Data(:, 2), ... - {'a'; 'b'; 'c'; 'd'; 'e'}); - testCase.verifyEmpty( ... - presentation.previews.videoAxes.Axes.video.Model.imageData); + presentation = video_marker.workbench.present(state); + testCase.verifyClass(presentation, "labkit.app.view.Snapshot"); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, project); + runtimeCleanup = onCleanup(@() runtime.close()); + testCase.verifyTrue( ... + definition.validateViewSnapshot(runtime.Presentation)); session.selection.currentFrame = 7; - resume = definition.project.CreateResume(session, project); + resume = definition.ProjectSchema.CreateResume(session, project); testCase.verifyEqual(resume.currentFrame, 7); + clear runtimeCleanup runtime end function video_metadata_is_durable_validated_and_migrated(testCase) @@ -325,7 +366,7 @@ function legacy_project_import_is_read_only_and_complete(testCase) legacy.annotations = video_marker.frameAnnotations.setFramePoints( ... legacy.annotations, 1, [10 20; 30 40], "confirmed"); legacy.calibration = ... - labkit.ui.interaction.scaleBarCalibration(20, 2, "mm"); + labkit.app.interaction.scaleCalibration(20, 2, "mm"); legacy.exportPreferences = struct( ... "unitMode", "calibrated_physical", ... "originMode", "first_point", "yAxisMode", "up", ... diff --git a/tests/cases/unit/apps/neurophysiology/NerveResponseAnalysisOpsTest.m b/tests/cases/unit/apps/neurophysiology/NerveResponseAnalysisOpsTest.m index 477f07408..fb530bc69 100644 --- a/tests/cases/unit/apps/neurophysiology/NerveResponseAnalysisOpsTest.m +++ b/tests/cases/unit/apps/neurophysiology/NerveResponseAnalysisOpsTest.m @@ -16,8 +16,9 @@ function projectMigrationCombinesRoleSpecificSources(testCase) testCase.verifyEqual(migrated.inputs.sources, ... [filterSource, protocolSource]); - testCase.verifyEqual(definition.project.Version, 2); - testCase.verifyEqual(definition.project.Migrate, spec.Migrate); + testCase.verifyEqual(definition.ProjectSchema.Version, 2); + testCase.verifyEqual( ... + definition.ProjectSchema.Migrate, spec.Migrate); testCase.verifyFalse(any(isfield(migrated.inputs, ... {"filterSource", "protocolSource"}))); end diff --git a/tests/cases/unit/apps/neurophysiology/NeuroProjectSpecTest.m b/tests/cases/unit/apps/neurophysiology/NeuroProjectSpecTest.m index 670a3c2fe..cc90dc21e 100644 --- a/tests/cases/unit/apps/neurophysiology/NeuroProjectSpecTest.m +++ b/tests/cases/unit/apps/neurophysiology/NeuroProjectSpecTest.m @@ -20,7 +20,7 @@ function defaultProjectsAcceptAndRequireSources(testCase) function appSpecificSourceRolesRemainValidated(testCase) setupLabKitTestPath(); - source = labkit.ui.runtime.sourceRecord( ... + source = labkit.app.project.sourceRecord( ... "unexpected", "unexpected", "synthetic.dat", false); nerveSpec = nerve_response_analysis.projectSpec(); diff --git a/tests/cases/unit/apps/neurophysiology/NeurophysiologyDebugSamplePackTest.m b/tests/cases/unit/apps/neurophysiology/NeurophysiologyDebugSamplePackTest.m index 6691e425e..ad84a61ec 100644 --- a/tests/cases/unit/apps/neurophysiology/NeurophysiologyDebugSamplePackTest.m +++ b/tests/cases/unit/apps/neurophysiology/NeurophysiologyDebugSamplePackTest.m @@ -7,40 +7,59 @@ function rhs_and_analysis_debug_packs_read_through_app_facades(testCase) root = string(tempname); cleanup = onCleanup(@() cleanupFolder(root)); mkdir(char(root)); - debug = labkit.ui.debug.context("neuro_debug_sample_test", struct( ... - "logFile", fullfile(char(root), "trace.log"))); + debug = debugSampleContext(root); rhsPack = rhs_preview.debug.writeSamplePack(debug); - [index, status] = labkit.rhs.indexFile(rhsPack.representativeFiles.primaryRhs); + testCase.verifyClass(rhsPack, ... + "labkit.app.diagnostic.SamplePack"); + rhsPath = sourcePath(rhsPack.InitialProject, "recording"); + [index, status] = labkit.rhs.indexFile(rhsPath); testCase.verifyTrue(status.ok, status.message); testCase.verifyGreaterThan(index.durationSec, 0); - [window, windowStatus] = labkit.rhs.readWindow(rhsPack.representativeFiles.primaryRhs, ... + [window, windowStatus] = labkit.rhs.readWindow(rhsPath, ... struct("family", "amplifier", "timeRangeSec", [0 0.02])); testCase.verifyTrue(windowStatus.ok); testCase.verifyFalse(isempty(window.values)); - [~, malformedStatus] = labkit.rhs.indexFile(rhsPack.boundaryFiles.malformedRhs); + malformedRhs = artifactPath(debug, rhsPack, "malformedHeader"); + [~, malformedStatus] = labkit.rhs.indexFile(malformedRhs); testCase.verifyFalse(malformedStatus.ok); analysisPack = nerve_response_analysis.debug.writeSamplePack(debug); - session = jsondecode(fileread(char(analysisPack.representativeFiles.filterRecordJson))); - protocol = jsondecode(fileread(char(analysisPack.representativeFiles.protocolJson))); + filterPath = sourcePath(analysisPack.InitialProject, "filterRecord"); + protocolPath = sourcePath(analysisPack.InitialProject, "protocol"); + session = jsondecode(fileread(char(filterPath))); + protocol = jsondecode(fileread(char(protocolPath))); analysis = nerve_response_analysis.analysisRun.analyzeSession(session, protocol, ... struct("maxRecordings", 1, "maxDurationSec", 0.08)); testCase.verifyEqual(analysis.recordingCount, 2); testCase.verifyGreaterThanOrEqual(analysis.analyzedCount, 1); reviewPack = response_review_stats.debug.writeSamplePack(debug); - T = readtable(char(reviewPack.representativeFiles.segmentCsv)); + segmentPath = sourcePath(reviewPack.InitialProject, "reviewInput"); + T = readtable(char(segmentPath)); segments = response_review_stats.sourceFiles.parseSegmentTable(T); aligned = response_review_stats.analysisRun.alignSegments(segments, struct()); metrics = response_review_stats.analysisRun.measureAlignedSegments(aligned, struct()); testCase.verifyGreaterThan(height(metrics), 0); - payload = jsondecode(fileread(char(reviewPack.representativeFiles.analysisJson))); + analysisPath = artifactPath(debug, reviewPack, "analysisMetrics"); + payload = jsondecode(fileread(char(analysisPath))); testCase.verifyTrue(isfield(payload, "metrics")); end end end +function filepath = sourcePath(project, role) +source = project.inputs.sources(string({project.inputs.sources.role}) == role); +filepath = source.reference.originalPath; +end + +function filepath = artifactPath(context, pack, id) +artifact = pack.Artifacts{find(cellfun( ... + @(value) value.Id == id, pack.Artifacts), 1)}; +parts = cellstr(split(artifact.RelativePath, "/")); +filepath = string(fullfile(context.ArtifactFolder, parts{:})); +end + function cleanupFolder(folder) if strlength(string(folder)) > 0 && exist(char(folder), "dir") == 7 rmdir(char(folder), "s"); diff --git a/tests/cases/unit/apps/neurophysiology/NeurophysiologyWorkflowLayoutTest.m b/tests/cases/unit/apps/neurophysiology/NeurophysiologyWorkflowLayoutTest.m index 848107368..46ab0560d 100644 --- a/tests/cases/unit/apps/neurophysiology/NeurophysiologyWorkflowLayoutTest.m +++ b/tests/cases/unit/apps/neurophysiology/NeurophysiologyWorkflowLayoutTest.m @@ -4,124 +4,34 @@ methods (Test, TestTags = {'Unit'}) function previewOwnsProtocolDraftingSurface(testCase) setupLabKitTestPath(); + definition = rhs_preview.definition(); - layout = rhs_preview.userInterface.buildWorkbenchLayout( ... - appCallbacks(rhs_preview.definitionActions())); - - testCase.verifyEqual(tabTitles(layout), ... - ["Setup", "Protocol", "Filter", "Review", "Log"]); - testCase.verifyTrue(any(sectionTitles(layout) == ... - "Protocol Channel Roles")); - testCase.verifyTrue(any(sectionTitles(layout) == "File Filter")); - testCase.verifyTrue(any(actionLabels(layout) == ... - "Save Protocol Draft")); - testCase.verifyTrue(any(actionLabels(layout) == ... - "Save Filter Record")); - testCase.verifyTrue(any(actionLabels(layout) == "Zoom to ROI")); + expected = ["previewChannelsTable", "fileFilterTable", ... + "saveProtocol", "saveFilterRecord", "zoomToRoiWindow"]; + testCase.verifyTrue(all(ismember(expected, ... + labkit.app.internal.DefinitionInspector.targetIds( ... + definition)))); end function analysisWorkflowKeepsHeavyAnalyzeExplicit(testCase) setupLabKitTestPath(); + definition = nerve_response_analysis.definition(); - layout = nerve_response_analysis.userInterface.buildWorkbenchLayout( ... - appCallbacks(nerve_response_analysis.definitionActions())); - - testCase.verifyEqual(tabTitles(layout), ... - ["Setup", "Protocol", "Review", "Export", "Log"]); - testCase.verifyTrue(any(sectionTitles(layout) == ... - "Filter Record")); - testCase.verifyTrue(any(sectionTitles(layout) == ... - "Protocol (recommended)")); - testCase.verifyTrue(any(actionLabels(layout) == ... - "Analyze Filtered Files")); - testCase.verifyTrue(any(actionLabels(layout) == ... - "Export Analysis")); + expected = ["sessionFile", "protocolFile", ... + "runAnalysis", "exportAnalysis"]; + testCase.verifyTrue(all(ismember(expected, ... + labkit.app.internal.DefinitionInspector.targetIds( ... + definition)))); end function statsWorkflowAutoLoadHasRefreshAndExport(testCase) setupLabKitTestPath(); + definition = response_review_stats.definition(); - layout = response_review_stats.userInterface.buildWorkbenchLayout( ... - appCallbacks(response_review_stats.definitionActions())); - - testCase.verifyEqual(tabTitles(layout), ... - ["Setup", "Review", "Export", "Log"]); - testCase.verifyTrue(any(actionLabels(layout) == ... - "Refresh Metrics")); - testCase.verifyTrue(any(actionLabels(layout) == ... - "Export Metrics")); - end - end -end - -function callbacks = appCallbacks(actions) - callbacks = struct(); - ids = fieldnames(actions); - for k = 1:numel(ids) - callbacks.(ids{k}) = @(varargin) []; - end -end - -function titles = tabTitles(layout) - tabs = layout.props.controlTabs; - titles = strings(1, numel(tabs)); - for k = 1:numel(tabs) - titles(k) = string(tabs{k}.props.title); - end -end - -function titles = sectionTitles(layout) - flat = flattenLayout(layout); - titles = strings(1, numel(flat)); - count = 0; - for k = 1:numel(flat) - if strcmp(flat(k).kind, "section") && isfield(flat(k).props, "title") - count = count + 1; - titles(count) = string(flat(k).props.title); + expected = ["loadMetrics", "exportMetrics"]; + testCase.verifyTrue(all(ismember(expected, ... + labkit.app.internal.DefinitionInspector.targetIds( ... + definition)))); end end - titles = titles(1:count); -end - -function labels = actionLabels(layout) - flat = flattenLayout(layout); - labels = strings(1, numel(flat)); - count = 0; - for k = 1:numel(flat) - if strcmp(flat(k).kind, "action") && isfield(flat(k).props, "label") - count = count + 1; - labels(count) = string(flat(k).props.label); - end - end - labels = labels(1:count); -end - -function flat = flattenLayout(layout) - childLayouts = {}; - if isfield(layout, "children") && iscell(layout.children) - childLayouts = [childLayouts, layout.children]; - end - if isfield(layout, "props") && isstruct(layout.props) && ... - isfield(layout.props, "controlTabs") && iscell(layout.props.controlTabs) - childLayouts = [childLayouts, layout.props.controlTabs]; - end - if isfield(layout, "props") && isstruct(layout.props) && ... - isfield(layout.props, "workspace") && isstruct(layout.props.workspace) - childLayouts{end + 1} = layout.props.workspace; - end - if isfield(layout, "slots") && isstruct(layout.slots) - slotNames = fieldnames(layout.slots); - slotValues = cell(1, numel(slotNames)); - for k = 1:numel(slotNames) - slotValues{k} = layout.slots.(slotNames{k}); - end - childLayouts = [childLayouts, ... - slotValues(cellfun(@isstruct, slotValues))]; - end - parts = cell(1, numel(childLayouts) + 1); - parts{1} = layout; - for k = 1:numel(childLayouts) - parts{k + 1} = flattenLayout(childLayouts{k}); - end - flat = [parts{:}]; end diff --git a/tests/cases/unit/apps/neurophysiology/ResponseReviewStatsOpsTest.m b/tests/cases/unit/apps/neurophysiology/ResponseReviewStatsOpsTest.m index aa0b2d8dd..fe2ac5764 100644 --- a/tests/cases/unit/apps/neurophysiology/ResponseReviewStatsOpsTest.m +++ b/tests/cases/unit/apps/neurophysiology/ResponseReviewStatsOpsTest.m @@ -15,8 +15,8 @@ function projectMigrationAdoptsCanonicalSourceCollection(testCase) testCase.verifyEqual(migrated.inputs.sources, expected); testCase.verifyFalse(isfield(migrated.inputs, "source")); - testCase.verifyEqual(definition.project.Version, 2); - testCase.verifyTrue(isa(definition.project.Migrate, ... + testCase.verifyEqual(definition.ProjectSchema.Version, 2); + testCase.verifyTrue(isa(definition.ProjectSchema.Migrate, ... 'function_handle')); end @@ -75,5 +75,41 @@ function defaultWindowUsesAlignedTimeIntersection(testCase) testCase.verifyEqual(aligned.timeSec, [0; 1; 2]); testCase.verifyEqual(aligned.values, [1 4; 2 5; 3 6]); end + + function refreshMetricsResolvesTheDeclaredSourceRole(testCase) + setupLabKitTestPath(); + folder = string(tempname); + mkdir(folder); + cleanupFolder = onCleanup(@() removeFolder(folder)); + segmentPath = fullfile(folder, "segments.csv"); + writeSegmentCsv(segmentPath); + + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + response_review_stats.definition()); + cleanupRuntime = onCleanup(@() runtime.close()); + runtime.applyFileSelection("inputFile", segmentPath, 1); + runtime.invokeAction("loadMetrics"); + + testCase.verifyEqual(height(runtime.State.session.cache.metrics), 2); + testCase.verifyEqual(height(runtime.State.session.cache.summary), 2); + clear cleanupRuntime cleanupFolder + end + end +end + +function writeSegmentCsv(filepath) + Time_s = (0:0.001:0.020).'; + Signal1 = zeros(size(Time_s)); + Signal2 = zeros(size(Time_s)); + Signal1(Time_s == 0.006) = 2; + Signal1(Time_s == 0.010) = -1; + Signal2(Time_s == 0.007) = 3; + Signal2(Time_s == 0.011) = -2; + writetable(table(Time_s, Signal1, Signal2), filepath); +end + +function removeFolder(folder) + if isfolder(folder) + rmdir(folder, "s"); end end diff --git a/tests/cases/unit/apps/neurophysiology/RhsPreviewViewTest.m b/tests/cases/unit/apps/neurophysiology/RhsPreviewViewTest.m index a479c9bf6..90efb8f81 100644 --- a/tests/cases/unit/apps/neurophysiology/RhsPreviewViewTest.m +++ b/tests/cases/unit/apps/neurophysiology/RhsPreviewViewTest.m @@ -19,8 +19,8 @@ function projectMigrationCombinesRoleSpecificSources(testCase) testCase.verifyEqual(migrated.inputs.sources, ... [rhsSource, protocolSource, filterSources]); - testCase.verifyEqual(definition.project.Version, 2); - testCase.verifyEqual(definition.project.Migrate, spec.Migrate); + testCase.verifyEqual(definition.ProjectSchema.Version, 2); + testCase.verifyEqual(definition.ProjectSchema.Migrate, spec.Migrate); testCase.verifyFalse(any(isfield(migrated.inputs, ... {"rhsSource", "protocolSource", "filterSources"}))); end @@ -40,8 +40,8 @@ function rolePathsPreserveSourceOrder(testCase) function summaryAndDetailsRenderDefaultState(testCase) setupLabKitTestPath(); - rows = rhs_preview.userInterface.summaryTableData(struct()); - lines = rhs_preview.userInterface.detailLines(struct()); + rows = rhs_preview.analysisRun.summaryTableData(struct()); + lines = rhs_preview.analysisRun.detailLines(struct()); testCase.verifyTrue(iscell(rows)); testCase.verifyGreaterThanOrEqual(size(rows, 1), 4); @@ -59,7 +59,7 @@ function detailLinesShowChannelNamesWithoutPaths(testCase) S.info.channelFamilies.amplifier = struct( ... "nativeName", {"C-001", "C-002"}); S.rhsFile = fullfile("synthetic", "primary.rhs"); - lines = rhs_preview.userInterface.detailLines(S); + lines = rhs_preview.analysisRun.detailLines(S); joined = string(strjoin(lines, newline)); testCase.verifyTrue(contains(joined, "C-001")); @@ -134,7 +134,8 @@ function filterRowsExportManualLabels(testCase) testCase.verifyEqual(height(rows), 2); testCase.verifyTrue(all(rows.label == "good")); - data = rhs_preview.userInterface.fileFilterTableData(struct("filterRows", rows)); + data = rhs_preview.analysisRun.fileFilterTableData( ... + struct("filterRows", rows)); data{2, 1} = "bad"; data{2, 3} = "manual reject"; rows = rhs_preview.analysisRun.applyFileFilterTableData(rows, data); @@ -168,6 +169,24 @@ function previewWindowBoundsSummarizeIndexedTiming(testCase) testCase.verifyFalse(bounds.hasIndexedDuration); testCase.verifyEqual(bounds.durationSec, 0); end + + function definitionDeclaresCompleteFivePageSurface(testCase) + setupLabKitTestPath(); + definition = rhs_preview.definition(); + expected = ["rhsFile", "rhsFolder", "channelFamily", ... + "windowStartPanner", "windowSummary", ... + "maxPreviewChannels", "statusField", ... + "refreshPreviewWindow", "zoomToRoiWindow", ... + "resetWorkflow", "protocolFile", "saveProtocol", ... + "previewChannelsTable", "refreshFolderFiles", ... + "saveFilterRecord", "fileFilterTable", ... + "summaryTable", "details", "logPanel", ... + "preview", "previewRange"]; + + testCase.verifyTrue(all(ismember(expected, ... + labkit.app.internal.DefinitionInspector.targetIds( ... + definition)))); + end end end diff --git a/tests/cases/unit/apps/wearable/EcgPrintHelpersTest.m b/tests/cases/unit/apps/wearable/EcgPrintHelpersTest.m index 1a442f32d..6bb5402a4 100644 --- a/tests/cases/unit/apps/wearable/EcgPrintHelpersTest.m +++ b/tests/cases/unit/apps/wearable/EcgPrintHelpersTest.m @@ -15,8 +15,9 @@ function projectMigrationAdoptsCanonicalSourceCollection(testCase) testCase.verifyEqual(migrated.inputs.sources, expected); testCase.verifyFalse(isfield(migrated.inputs, "source")); - testCase.verifyEqual(definition.project.Version, 2); - testCase.verifyEqual(definition.project.Migrate, spec.Migrate); + testCase.verifyEqual(definition.ProjectSchema.Version, 2); + testCase.verifyEqual( ... + definition.ProjectSchema.Migrate, spec.Migrate); end function importOptionsNormalizeUiValues(testCase) @@ -109,7 +110,7 @@ function importStatusTextPreservesMetadataSummary(testCase) 'repairedBackwardCount', 3, ... 'largeGapCount', 1)); - text = ecg_print.userInterface.importStatusText(recording, 2); + text = ecg_print.sourceFiles.importStatusText(recording, 2); testCase.verifyEqual(text, ... ['2 channel(s) | time: time_s | unit: seconds | source: column | ' ... @@ -133,7 +134,8 @@ function summaryRowsReflectSignalAndMeasurementState(testCase) 'SNRdBStd', 0.9876, ... 'TemplateCorrelationMean', 0.8765)); - rows = ecg_print.userInterface.summaryRows(signal, events, segments, measurements); + rows = ecg_print.analysisRun.summaryRows( ... + signal, events, segments, measurements); testCase.verifyEqual(rowValue(rows, 'Status'), 'No signal analyzed'); testCase.verifyEqual(rowValue(rows, 'Channel'), 'Lead I'); @@ -171,6 +173,42 @@ function analysisTableAddsSmoothedColumns(testCase) 'AbsTol', 1e-12); end + function sanitizeParametersRestoresFiniteLegalAnalysisInputs(testCase) + setupLabKitTestPath(); + parameters = struct( ... + "roiStart", NaN, "roiEnd", Inf, ... + "lowCut", -2, "highCut", 500, ... + "peakDistance", 0, "segmentWindow", NaN, ... + "templateTopN", 2.6, "smoothBeats", Inf); + + actual = ecg_print.analysisRun.sanitizeParameters( ... + parameters, 100); + + testCase.verifyEqual(actual.roiStart, 0); + testCase.verifyEqual(actual.roiEnd, 0); + testCase.verifyEqual(actual.lowCut, 0); + testCase.verifyEqual(actual.highCut, 45); + testCase.verifyGreaterThan(actual.peakDistance, 0); + testCase.verifyEqual(actual.segmentWindow, 0.7); + testCase.verifyEqual(actual.templateTopN, 3); + testCase.verifyEqual(actual.smoothBeats, 15); + end + + function manifestSummaryExcludesPerSegmentTable(testCase) + setupLabKitTestPath(); + lastAnalysis = struct( ... + "channel", "ECG", "eventCount", 4, ... + "segmentCount", 3, "summary", struct("mean", 2), ... + "perSegment", table((1:3).', ... + 'VariableNames', {'Index'})); + + summary = ecg_print.resultFiles.manifestSummary(lastAnalysis); + + testCase.verifyFalse(isfield(summary, "perSegment")); + testCase.verifyEqual(summary.channel, "ECG"); + testCase.verifyEqual(summary.eventCount, 4); + end + function waveformPlotRequestPrefersFilteredSignalAndPeakCoordinates(testCase) setupLabKitTestPath(); @@ -184,7 +222,7 @@ function waveformPlotRequestPrefersFilteredSignalAndPeakCoordinates(testCase) 'name', 'filtered'); events = struct('index', [2 4]); - request = ecg_print.userInterface.waveformPlotRequest( ... + request = ecg_print.analysisRun.waveformPlotRequest( ... workingSignal, filteredSignal, events); testCase.verifyTrue(request.ok); @@ -207,7 +245,7 @@ function templatePlotRequestBuildsResidualBandAndWindows(testCase) 'signalWindowSec', [-0.04 0.04], ... 'noiseWindowsSec', [-0.2 -0.1; 0.1 0.2])); - request = ecg_print.userInterface.templatePlotRequest(segments, template, ... + request = ecg_print.analysisRun.templatePlotRequest(segments, template, ... measurements, 'Template + residual band'); testCase.verifyTrue(request.ok); @@ -233,7 +271,7 @@ function templatePlotRequestSelectsRepresentativeSegments(testCase) 'timeOffset', [-0.1 0 0.1]); template = struct('values', [2; 4; 6]); - request = ecg_print.userInterface.templatePlotRequest(segments, template, ... + request = ecg_print.analysisRun.templatePlotRequest(segments, template, ... struct(), 'Template + segments'); testCase.verifyTrue(request.ok); diff --git a/tests/cases/unit/apps/wearable/WearableDebugSamplePackTest.m b/tests/cases/unit/apps/wearable/WearableDebugSamplePackTest.m index ccdf94f17..8aed4ced0 100644 --- a/tests/cases/unit/apps/wearable/WearableDebugSamplePackTest.m +++ b/tests/cases/unit/apps/wearable/WearableDebugSamplePackTest.m @@ -6,12 +6,14 @@ function ecg_debug_sample_pack_reads_through_biosignal_facade(testCase) setupLabKitTestPath(); root = string(tempname); cleanup = onCleanup(@() cleanupFolder(root)); - mkdir(char(root)); - debug = labkit.ui.debug.context("wearable_debug_sample_test", struct( ... - "logFile", fullfile(char(root), "trace.log"))); + context = labkit.app.diagnostic.SampleContext(root); - pack = ecg_print.debug.writeSamplePack(debug); - [recording, status] = labkit.biosignal.readRecording(char(pack.representativeFiles), ... + pack = ecg_print.debug.writeSamplePack(context); + testCase.verifyClass(pack, "labkit.app.diagnostic.SamplePack"); + testCase.verifyEqual(string( ... + pack.InitialProject.inputs.sources.role), "recording"); + [recording, status] = labkit.biosignal.readRecording( ... + artifactPath(context, pack, "recording"), ... ecg_print.sourceFiles.importOptions(500, 1, "Yes", "time_s", "seconds", "ECG,Motion")); testCase.verifyTrue(status.ok, status.message); channels = labkit.biosignal.listChannels(recording); @@ -20,13 +22,13 @@ function ecg_debug_sample_pack_reads_through_biosignal_facade(testCase) testCase.verifyGreaterThan(numel(signal.time), 1000); [headerless, headerlessStatus] = labkit.biosignal.readRecording( ... - char(pack.boundaryFiles.validHeaderlessText), ... + artifactPath(context, pack, "headerless"), ... ecg_print.sourceFiles.importOptions(500, 1, "No", "1", "seconds", "2,3")); testCase.verifyTrue(headerlessStatus.ok); testCase.verifyGreaterThan(numel(labkit.biosignal.listChannels(headerless)), 0); [~, malformedStatus] = labkit.biosignal.readRecording( ... - char(pack.boundaryFiles.malformedCsv), ... + artifactPath(context, pack, "malformed"), ... ecg_print.sourceFiles.importOptions(500, 1, "Yes", "time_s", "seconds", "ECG")); testCase.verifyFalse(malformedStatus.ok, ... "Malformed ECG debug sample should fail cleanly through the biosignal facade."); @@ -34,6 +36,12 @@ function ecg_debug_sample_pack_reads_through_biosignal_facade(testCase) end end +function filepath = artifactPath(context, pack, id) +matches = cellfun(@(artifact) artifact.Id == id, pack.Artifacts); +filepath = char(fullfile(context.ArtifactFolder, ... + pack.Artifacts{matches}.RelativePath)); +end + function cleanupFolder(folder) if strlength(string(folder)) > 0 && exist(char(folder), "dir") == 7 rmdir(char(folder), "s"); diff --git a/tests/cases/unit/labkit_framework/ui/AnchorPathTest.m b/tests/cases/unit/labkit_framework/ui/AnchorPathTest.m index d5c236da5..2f2a00147 100644 --- a/tests/cases/unit/labkit_framework/ui/AnchorPathTest.m +++ b/tests/cases/unit/labkit_framework/ui/AnchorPathTest.m @@ -3,7 +3,7 @@ function buildsCurveAndStraightPaths(testCase) setupLabKitTestPath(); points = [10 30; 30 10; 50 30]; - curve = labkit.ui.interaction.anchorPath( ... + curve = labkit.app.interaction.interpolateAnchorPath( ... points, [40 60], "Style", "Curve"); testCase.verifyGreaterThan(size(curve, 1), size(points, 1)); testCase.verifyEqual(curve(1, :), points(1, :), ... @@ -11,7 +11,7 @@ function buildsCurveAndStraightPaths(testCase) testCase.verifyEqual(curve(end, :), points(end, :), ... 'AbsTol', 1e-12); - straight = labkit.ui.interaction.anchorPath( ... + straight = labkit.app.interaction.interpolateAnchorPath( ... points, [40 60], "Style", "Straight lines"); testCase.verifyEqual(straight, points); end diff --git a/tests/cases/unit/labkit_framework/ui/RuntimeSourceRecordTest.m b/tests/cases/unit/labkit_framework/ui/RuntimeSourceRecordTest.m deleted file mode 100644 index 259f7fe07..000000000 --- a/tests/cases/unit/labkit_framework/ui/RuntimeSourceRecordTest.m +++ /dev/null @@ -1,122 +0,0 @@ -classdef RuntimeSourceRecordTest < matlab.unittest.TestCase - %RUNTIMESOURCERECORDTEST Verify the canonical empty source factory. - - methods (Test, TestTags = {'Unit'}) - function emptyFactoryOwnsCanonicalShape(testCase) - setupLabKitTestPath(); - sources = labkit.ui.runtime.emptySourceRecords(); - testCase.verifySize(sources, [0 1]); - testCase.verifyEqual(string(fieldnames(sources)), ... - ["id"; "required"; "role"; "reference"]); - end - - function pathAccessorHidesPortableReferenceShape(testCase) - setupLabKitTestPath(); - sources = [source("moving", "/data/moving.tif"); ... - source("reference", "/data/reference.tif")]; - - testCase.verifyEqual( ... - labkit.ui.runtime.sourcePaths(sources), ... - ["/data/moving.tif"; "/data/reference.tif"]); - testCase.verifyEqual( ... - labkit.ui.runtime.sourcePaths( ... - sources, ["reference"; "moving"]), ... - ["/data/reference.tif"; "/data/moving.tif"]); - testCase.verifyEqual( ... - labkit.ui.runtime.sourcePaths( ... - sources, ["reference"; "optionalMask"]), ... - ["/data/reference.tif"; ""]); - testCase.verifySize(labkit.ui.runtime.sourcePaths( ... - sources, strings(0, 1)), [0 1]); - end - - function publicFactorySupportsGuiFreeProjectConstruction(testCase) - setupLabKitTestPath(); - sourceValue = labkit.ui.runtime.sourceRecord( ... - "reference", "referenceImage", "/data/reference.tif", false); - - testCase.verifyEqual(sourceValue.id, "reference"); - testCase.verifyEqual(sourceValue.role, "referenceImage"); - testCase.verifyFalse(sourceValue.required); - testCase.verifyEqual( ... - labkit.ui.runtime.sourcePaths(sourceValue), ... - "/data/reference.tif"); - testCase.verifyError(@() labkit.ui.runtime.sourceRecord( ... - "", "image", "file.tif"), ... - 'labkit:ui:runtime:InvalidSourceRecords'); - testCase.verifyError(@() labkit.ui.runtime.sourceRecord( ... - "image", "", "file.tif"), ... - 'labkit:ui:runtime:InvalidSourceRecords'); - testCase.verifyError(@() labkit.ui.runtime.sourceRecord( ... - "image", "image", strings(0, 1)), ... - 'labkit:ui:runtime:InvalidSourceRecords'); - testCase.verifyError(@() labkit.ui.runtime.sourceRecord( ... - "image", "image", "file.tif", 2), ... - 'labkit:ui:runtime:InvalidSourceRecords'); - end - - function pathAccessorRejectsInvalidReferences(testCase) - setupLabKitTestPath(); - sources = source("trace", "/data/trace.csv"); - sources.reference = rmfield(sources.reference, 'originalPath'); - testCase.verifyError(@() labkit.ui.runtime.sourcePaths(sources), ... - 'labkit:ui:runtime:InvalidSourceRecords'); - end - - function publicFactoryPreservesOpaqueImportedReference(testCase) - setupLabKitTestPath(); - imported = struct( ... - "schemaVersion", 1, ... - "relativePath", "../video/source.mov", ... - "originalPath", "/old/location/source.mov", ... - "fileName", "source.mov", ... - "legacyNote", "ignored"); - - sourceValue = labkit.ui.runtime.sourceRecord( ... - "video", "video", imported); - - testCase.verifyEqual(sourceValue.reference, struct( ... - "schemaVersion", 1, ... - "relativePath", "../video/source.mov", ... - "originalPath", "/old/location/source.mov", ... - "fileName", "source.mov")); - testCase.verifyError(@() labkit.ui.runtime.sourceRecord( ... - "video", "video", rmfield(imported, 'fileName')), ... - 'labkit:ui:runtime:InvalidSourceRecords'); - imported.schemaVersion = 2; - testCase.verifyError(@() labkit.ui.runtime.sourceRecord( ... - "video", "video", imported), ... - 'labkit:ui:runtime:InvalidSourceRecords'); - end - - function appIdentityRejectsUnsafeStorageNames(testCase) - setupLabKitTestPath(); - project = struct("Version", 1, "Create", @emptyProject, ... - "Validate", @(value) isstruct(value)); - testCase.verifyError(@() labkit.ui.runtime.define( ... - "Id", "unsafe app/id", "Title", "Unsafe", ... - "Project", project, "Layout", @emptyLayout, ... - "Actions", struct("noop", @(state, varargin) state), ... - "Present", @(state) struct()), ... - 'labkit:ui:runtime:InvalidDefinition'); - end - end -end - -function value = source(id, pathValue) - value = labkit.ui.runtime.sourceRecord(id, "test", pathValue); -end - -function project = emptyProject() - project = struct("inputs", struct(), "parameters", struct(), ... - "annotations", struct(), "results", struct(), ... - "extensions", struct()); -end - -function layout = emptyLayout(varargin) - action = labkit.ui.layout.action("noop", "No operation", []); - layout = labkit.ui.layout.workbench("identityProbe", "Identity", ... - "controlTabs", {labkit.ui.layout.tab("main", "Main", { ... - labkit.ui.layout.section("actions", "Actions", {action})})}, ... - "workspace", labkit.ui.layout.workspace("workspace", "", {})); -end diff --git a/tests/cases/unit/labkit_framework/ui/ScaleBarCalibrationTest.m b/tests/cases/unit/labkit_framework/ui/ScaleBarCalibrationTest.m index 711ecce84..392f51738 100644 --- a/tests/cases/unit/labkit_framework/ui/ScaleBarCalibrationTest.m +++ b/tests/cases/unit/labkit_framework/ui/ScaleBarCalibrationTest.m @@ -18,7 +18,7 @@ function verify_scaleBarCalibration() end function checkTypedCalibration() - cal = labkit.ui.interaction.scaleBarCalibration(80, 20, "mm"); + cal = labkit.app.interaction.scaleCalibration(80, 20, "mm"); assert(cal.isCalibrated, 'Positive reference pixels and length should calibrate.'); assert(cal.pixelsPerUnit == 4, 'Pixels per unit calculation changed.'); assert(strcmp(cal.unit, 'mm'), 'Selected scale unit should be preserved.'); @@ -27,7 +27,7 @@ function checkTypedCalibration() end function checkReferenceLineCalibration() - cal = labkit.ui.interaction.scaleBarCalibration(NaN, 2, "cm", ... + cal = labkit.app.interaction.scaleCalibration(NaN, 2, "cm", ... struct('referenceLine', [0 0; 3 4])); assert(cal.isCalibrated, 'Two reference endpoints should provide reference pixels.'); assert(cal.referencePixels == 5, 'Reference line pixel distance changed.'); @@ -37,7 +37,7 @@ function checkReferenceLineCalibration() end function checkFallbackUnitAndMissingScale() - cal = labkit.ui.interaction.scaleBarCalibration(NaN, 0, "inch"); + cal = labkit.app.interaction.scaleCalibration(NaN, 0, "inch"); assert(~cal.isCalibrated, 'Missing reference scale should remain uncalibrated.'); assert(cal.pixelsPerUnit == 0, 'Missing reference scale should produce zero pixels/unit.'); assert(strcmp(cal.unit, 'm'), 'Unsupported units should fall back to the default unit.'); diff --git a/tests/cases/unit/labkit_framework/ui/UiAuthoringErgonomicsTest.m b/tests/cases/unit/labkit_framework/ui/UiAuthoringErgonomicsTest.m new file mode 100644 index 000000000..7d263e626 --- /dev/null +++ b/tests/cases/unit/labkit_framework/ui/UiAuthoringErgonomicsTest.m @@ -0,0 +1,122 @@ +classdef UiAuthoringErgonomicsTest < matlab.unittest.TestCase + % Verify the replacement SDK's standard authoring path stays low-boilerplate. + methods (Test, TestTags = {'Unit'}) + function staticAppNeedsOnlyApplicationAndLayout(testCase) + setupLabKitTestPath(); + app = minimalApplication(labkit.app.layout.workbench({})); + + testCase.verifyEmpty( ... + labkit.app.internal.DefinitionInspector.targetIds(app)); + testCase.verifyEmpty( ... + labkit.app.internal.DefinitionInspector.signalIds(app)); + testCase.verifyFalse(isprop(app, "TargetIds")); + testCase.verifyFalse(any(ismember(string(methods(app)), [ ... + "createRuntimeForTesting", "createMatlabRuntime", ... + "signalIdsForRuntime", "onStartBindingForRuntime", ... + "hasSignalForRuntime", "platformPlanForRuntime"]))); + end + + function boundFieldNeedsNoCommandOrPresenter(testCase) + setupLabKitTestPath(); + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.field("gain", Kind="numeric", ... + Bind="project.parameters.gain")}); + app = minimalApplication(layout, ... + ProjectSchema=labkit.app.project.Schema( ... + Version=1, Create=@createProject, ... + Validate=@validateProject)); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + + runtime.applyBinding("gain", 3); + + testCase.verifyEqual(runtime.State.project.parameters.gain, 3); + testCase.verifyEmpty( ... + labkit.app.internal.DefinitionInspector.signalIds(app)); + end + + function layoutCallbackNeedsNoHandlerOrCapabilityRegistry(testCase) + setupLabKitTestPath(); + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.button("run", "Run", @runApp, ... + Tooltip="Run the authoring probe.")}); + app = minimalApplication(layout); + + testCase.verifyEqual( ... + labkit.app.internal.DefinitionInspector.signalIds(app), ... + "run__pressed"); + end + + function simpleProjectContractNeedsNoLocalCallbacks(testCase) + setupLabKitTestPath(); + contract = labkit.app.project.Schema(); + + testCase.verifyEqual(contract.Create(), struct()); + testCase.verifyTrue(contract.Validate(struct("value", 1))); + end + + function standardFilePanelNeedsNoCommands(testCase) + setupLabKitTestPath(); + project = labkit.app.project.Schema( ... + Version=1, Create=@createFileProject, ... + Validate=@validateFileProject); + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.fileList("files", ... + Bind="project.inputs.sources", ... + SelectionBind="session.selection.files")}); + app = minimalApplication(layout, ProjectSchema=project, ... + CreateSession=@createFileSession); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + + runtime.applyFileSelection( ... + "files", ["first.csv"; "second.csv"], 2); + + testCase.verifyEmpty( ... + labkit.app.internal.DefinitionInspector.signalIds(app)); + testCase.verifyEqual(runtime.sourcePaths( ... + runtime.State.project.inputs.sources, strings(0, 1)), ... + ["first.csv"; "second.csv"]); + testCase.verifyEqual( ... + runtime.State.session.selection.files.Indices, 2); + end + end +end + +function app = minimalApplication(layout, varargin) + app = labkit.app.Definition( ... + "Entrypoint", "labkit_AuthoringProbe_app", ... + "AppId", "probe.authoring", "Title", "Authoring probe", ... + "Family", "Tests", "AppVersion", "1.0.0", ... + "Updated", "2026-07-19", "Requirements", [], ... + "Workbench", layout, varargin{:}); +end + +function project = createProject() + project = struct("parameters", struct("gain", 1)); +end + +function accepted = validateProject(project) + accepted = isstruct(project) && isscalar(project) && ... + isfield(project, "parameters") && ... + isstruct(project.parameters) && isscalar(project.parameters) && ... + isfield(project.parameters, "gain") && ... + isnumeric(project.parameters.gain) && ... + isscalar(project.parameters.gain) && isfinite(project.parameters.gain); +end + +function state = runApp(state, ~) +end + +function project = createFileProject() +project = struct("inputs", struct("sources", struct([]))); +end + +function accepted = validateFileProject(project) +accepted = isstruct(project) && isscalar(project) && ... + isfield(project, "inputs") && isstruct(project.inputs) && ... + isfield(project.inputs, "sources") && isstruct(project.inputs.sources); +end + +function session = createFileSession(~, ~) +session = struct("selection", struct( ... + "files", labkit.app.event.ListSelection())); +end diff --git a/tests/cases/unit/labkit_framework/ui/UiDiagnosticContractTest.m b/tests/cases/unit/labkit_framework/ui/UiDiagnosticContractTest.m new file mode 100644 index 000000000..bf0a8fc3f --- /dev/null +++ b/tests/cases/unit/labkit_framework/ui/UiDiagnosticContractTest.m @@ -0,0 +1,229 @@ +classdef UiDiagnosticContractTest < matlab.unittest.TestCase + %UIDIAGNOSTICCONTRACTTEST Verify explicit diagnostic public values. + + methods (Test, TestTags = {'Unit'}) + function optionsRejectUnknownAndAmbiguousValues(testCase) + setupLabKitTestPath(); + options = labkit.app.diagnostic.Options( ... + Level="verbose", ArtifactFolder="diagnostics", ... + Sample="synthetic"); + + testCase.verifyEqual(options.Level, "verbose"); + testCase.verifyEqual(options.Sample, "synthetic"); + testCase.verifyTrue(meta.class.fromName( ... + "labkit.app.diagnostic.Options").Sealed); + testCase.verifyError(@() labkit.app.diagnostic.Options( ... + Level="trace"), "labkit:app:contract:InvalidValue"); + testCase.verifyError(@() labkit.app.diagnostic.Options( ... + Enabled=true), "labkit:app:contract:UnknownArgument"); + end + + function sampleValuesStayInsideTheDiagnosticRoot(testCase) + setupLabKitTestPath(); + root = string(tempname); + cleanup = onCleanup(@() removeFolder(root)); + context = labkit.app.diagnostic.SampleContext(root); + inputPath = context.samplePath("probe/input.csv"); + outputPath = context.outputPath("probe/output.csv"); + source = context.sourceRecord( ... + "probe", "diagnosticInput", inputPath, true); + artifact = context.artifact( ... + "probeInput", "source", inputPath); + pack = labkit.app.diagnostic.SamplePack( ... + Scenario="representative", ... + InitialProject=struct("inputs", struct("sources", source)), ... + Artifacts={artifact}); + + testCase.verifyTrue(startsWith(inputPath, context.SampleFolder)); + testCase.verifyTrue(startsWith(outputPath, context.OutputFolder)); + testCase.verifyEqual(pack.Artifacts{1}, artifact); + testCase.verifyError(@() context.samplePath("../outside.csv"), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyError(@() context.artifact( ... + "outside", "source", string(tempname)), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyError(@() labkit.app.diagnostic.Artifact( ... + "bad", "source", "../outside.csv"), ... + "labkit:app:contract:InvalidValue"); + duplicate = labkit.app.diagnostic.Artifact( ... + "probeInput", "other", "samples/probe/other.csv"); + testCase.verifyError(@() labkit.app.diagnostic.SamplePack( ... + Scenario="representative", InitialProject=struct(), ... + Artifacts={artifact, duplicate}), ... + "labkit:app:contract:DuplicateId"); + clear cleanup + end + + function verboseRuntimeRecordsSemanticTransactions(testCase) + setupLabKitTestPath(); + root = string(tempname); + cleanupFolder = onCleanup(@() removeFolder(root)); + definition = diagnosticDefinition(@incrementState); + options = labkit.app.diagnostic.Options( ... + Level="verbose", ArtifactFolder=root); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), options); + cleanupRuntime = onCleanup(@() runtime.close()); + + runtime.invokeAction("run"); + events = runtime.diagnosticEvents(); + callbackEvents = events( ... + string({events.Category}) == "callback"); + appEvents = events(string({events.Category}) == "app"); + + testCase.verifyTrue(any( ... + string({callbackEvents.Outcome}) == "begin")); + testCase.verifyTrue(any( ... + string({callbackEvents.Outcome}) == "completed")); + testCase.verifyTrue(any( ... + string({appEvents.TargetId}) == "probe.increment")); + countEvent = appEvents( ... + string({appEvents.TargetId}) == "probe.count"); + testCase.verifyEqual(countEvent.Count, 1); + testCase.verifyTrue(isfile(fullfile(root, "events.jsonl"))); + testCase.verifyTrue(isfile(fullfile(root, "manifest.json"))); + testCase.verifyFalse(isfile( ... + fullfile(root, "active-operation.json"))); + runtime.close(); + manifest = jsondecode(fileread(fullfile(root, "manifest.json"))); + testCase.verifyEqual(string(manifest.status), "closed"); + clear cleanupRuntime cleanupFolder + end + + function failedRuntimeTransactionIsSanitizedAndRethrown(testCase) + setupLabKitTestPath(); + root = string(tempname); + cleanupFolder = onCleanup(@() removeFolder(root)); + definition = diagnosticDefinition(@failWithSensitivePath); + options = labkit.app.diagnostic.Options( ... + Level="verbose", ArtifactFolder=root); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), options); + cleanupRuntime = onCleanup(@() runtime.close()); + + testCase.verifyError(@() runtime.invokeAction("run"), ... + "labkit:app:runtime:ActionFailed"); + events = runtime.diagnosticEvents(); + rollback = events( ... + string({events.Outcome}) == "rolledBack"); + testCase.verifyNumElements(rollback, 1); + testCase.verifyEqual(rollback.ErrorId, "probe:SensitiveFailure"); + testCase.verifyFalse(contains( ... + rollback.ErrorMessage, "ExampleUser")); + testCase.verifyFalse(contains( ... + rollback.ErrorMessage, "sample.csv")); + clear cleanupRuntime cleanupFolder + end + + function syntheticSampleBuildsTheCurrentProjectBeforeStartup(testCase) + setupLabKitTestPath(); + root = string(tempname); + cleanupFolder = onCleanup(@() removeFolder(root)); + definition = syntheticDefinition(); + options = labkit.app.diagnostic.Options( ... + Level="verbose", ArtifactFolder=root, ... + Sample="synthetic"); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), options); + cleanupRuntime = onCleanup(@() runtime.close()); + + testCase.verifyEqual( ... + string(runtime.State.project.inputs.sources.role), ... + "diagnosticInput"); + testCase.verifyTrue(isfile(fullfile(root, "sample-pack.json"))); + manifest = jsondecode(fileread( ... + fullfile(root, "sample-pack.json"))); + testCase.verifyEqual(string(manifest.scenario), "probe"); + testCase.verifyFalse(isfield(manifest, "initialProject")); + events = runtime.diagnosticEvents(); + sampleEvents = events( ... + string({events.Category}) == "sample"); + testCase.verifyEqual( ... + string({sampleEvents.Outcome}), ["begin", "completed"]); + testCase.verifyError(@() ... + labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, definition.ProjectSchema.Create(), ... + struct(), options), ... + "labkit:app:contract:InvalidValue"); + clear cleanupRuntime cleanupFolder + end + end +end + +function definition = diagnosticDefinition(callback) +layout = labkit.app.layout.workbench({ ... + labkit.app.layout.tab("main", "Main", { ... + labkit.app.layout.section("actions", "Actions", { ... + labkit.app.layout.button("run", "Run", callback, ... + Tooltip="Run the diagnostic probe.")})})}); +definition = labkit.app.Definition( ... + Entrypoint="labkit_DiagnosticProbe_app", ... + AppId="diagnostic.probe", Title="Diagnostic Probe", ... + Family="Tests", AppVersion="1.0.0", Updated="2026-07-19", ... + Requirements=[], Workbench=layout); +end + +function definition = syntheticDefinition() +schema = labkit.app.project.Schema( ... + Version=1, Create=@createSyntheticProject, ... + Validate=@validateSyntheticProject); +definition = labkit.app.Definition( ... + Entrypoint="labkit_DiagnosticSampleProbe_app", ... + AppId="diagnostic.sample.probe", Title="Diagnostic Sample Probe", ... + Family="Tests", AppVersion="1.0.0", Updated="2026-07-19", ... + Requirements=[], Workbench=labkit.app.layout.workbench({}), ... + ProjectSchema=schema, BuildDebugSample=@buildSyntheticSample); +end + +function project = createSyntheticProject() +project = struct("inputs", struct("sources", struct([]))); +end + +function accepted = validateSyntheticProject(project) +accepted = isstruct(project) && isscalar(project) && ... + isfield(project, "inputs") && ... + isfield(project.inputs, "sources"); +end + +function pack = buildSyntheticSample(context) +filepath = context.samplePath("probe/input.txt"); +file = fopen(filepath, "w"); +if file < 0 + error("probe:WriteFailed", "Could not write synthetic input."); +end +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "anonymous synthetic input\n"); +clear cleanup +project = createSyntheticProject(); +project.inputs.sources = context.sourceRecord( ... + "probe", "diagnosticInput", filepath, true); +pack = labkit.app.diagnostic.SamplePack( ... + Scenario="probe", InitialProject=project, ... + Artifacts={context.artifact("probe", "source", filepath)}); +end + +function state = incrementState(state, context) +state.session.count = fieldOrDefault(state.session, "count", 0) + 1; +context.diagnosticCheckpoint("probe.increment"); +context.diagnosticCount("probe.count", state.session.count); +end + +function state = failWithSensitivePath(state, ~) +syntheticPath = fullfile("C:", "Users", "ExampleUser", "sample.csv"); +error("probe:SensitiveFailure", "%s", ... + "Could not read " + syntheticPath); +end + +function value = fieldOrDefault(value, name, fallback) +if isfield(value, name) + value = value.(name); +else + value = fallback; +end +end + +function removeFolder(folder) +if isfolder(folder) + rmdir(folder, "s"); +end +end diff --git a/tests/cases/unit/labkit_framework/ui/UiExplicitContractClosureTest.m b/tests/cases/unit/labkit_framework/ui/UiExplicitContractClosureTest.m new file mode 100644 index 000000000..b90f1dc6b --- /dev/null +++ b/tests/cases/unit/labkit_framework/ui/UiExplicitContractClosureTest.m @@ -0,0 +1,163 @@ +classdef UiExplicitContractClosureTest < matlab.unittest.TestCase + methods (Test, TestTags = {'Unit'}) + function exposesOnlyAcceptedLayoutAndPresentationVocabulary(testCase) + setupLabKitTestPath(); + required = ["button", "field", "rangeField", "slider", ... + "fileList", "plotArea", "dataTable", "logPanel", ... + "statusPanel", "group", "section", "tab", "workspace", ... + "workbench"]; + for name = required + testCase.verifyNotEmpty(which( ... + "labkit.app.layout." + name)); + end + + presentationMethods = string(methods( ... + "labkit.app.view.Snapshot")); + testCase.verifyEmpty(intersect( ... + ["set", "patch", "property"], presentationMethods)); + testCase.verifyTrue(ismember( ... + "workspacePage", presentationMethods)); + end + + function retiredFacadeIsNotCallable(testCase) + setupLabKitTestPath(); + + testCase.verifyEmpty(which("labkit.ui.version")); + testCase.verifyEmpty(which("labkit.ui.runtime.launch")); + testCase.verifyEmpty(which("labkit.ui.layout.workbench")); + end + + function rejectsAliasesStringsAndUntypedPayloads(testCase) + setupLabKitTestPath(); + testCase.verifyError(@() labkit.app.layout.field( ... + "value", "kind", "text"), ... + "labkit:app:contract:UnknownArgument"); + testCase.verifyError(@() labkit.app.layout.button( ... + "run", "Run", "run"), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyError(@() labkit.app.view.Snapshot().listSelection( ... + "table", [1 2]), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyError(@() labkit.app.layout.button( ... + "run", "Run", @variableCommand), ... + "labkit:app:contract:CallbackRoleMismatch"); + end + + function layoutSignalsCompileNamedPayloadClasses(testCase) + setupLabKitTestPath(); + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.dataTable("table", ... + OnCellEdited=@tableEdit, ... + OnCellSelectionChanged=@selectionEdit), ... + labkit.app.layout.field("value", ... + OnValueChanged=@valueEdit)}); + app = labkit.app.Definition( ... + Entrypoint="labkit_SignalProbe_app", ... + AppId="probe.signals", Title="Signals", Family="Tests", ... + AppVersion="1.0.0", Updated="2026-07-19", ... + Requirements=[], Workbench=layout); + plan = ... + labkit.app.internal.DefinitionInspector.platformPlan(app); + tableSignal = plan.Nodes(2).Signals{1}; + selectionSignal = plan.Nodes(2).Signals{2}; + valueSignal = plan.Nodes(3).Signals{1}; + + testCase.verifyEqual(tableSignal.PayloadClass, ... + "labkit.app.event.TableCellEdit"); + testCase.verifyEqual(selectionSignal.PayloadClass, ... + "labkit.app.event.TableCellSelection"); + testCase.verifyEqual(valueSignal.PayloadClass, ""); + end + + function contractDiagnosticsAreDeterministicAndNamed(testCase) + setupLabKitTestPath(); + first = capture(@() labkit.app.result.File( ... + "bad", "primary", "../escape.csv")); + second = capture(@() labkit.app.result.File( ... + "bad", "primary", "../escape.csv")); + + testCase.verifyEqual(string(first.identifier), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyEqual(first.identifier, second.identifier); + testCase.verifyEqual(first.message, second.message); + testCase.verifySubstring(first.message, ... + "File relativePath"); + end + + function composesFeatureSnapshotsWithoutOpenPatchSchema(testCase) + setupLabKitTestPath(); + controls = labkit.app.view.Snapshot().enabled("run", true); + status = labkit.app.view.Snapshot().text("status", "Ready"); + + combined = labkit.app.view.Snapshot() ... + .include(controls) ... + .include(status); + + testCase.verifyError(@() combined.include(controls), ... + "labkit:app:contract:DuplicateId"); + testCase.verifyError(@() combined.include(struct()), ... + "labkit:app:contract:InvalidValue"); + end + + function compilesNamedManagedInteractionWithoutTransportStruct(testCase) + setupLabKitTestPath(); + crop = labkit.app.interaction.rectangle( ... + "cropRegion", @changeRectangle); + plot = labkit.app.layout.plotArea( ... + "preview", @drawInteractionProbe, ... + Interactions={crop}); + layout = labkit.app.layout.workbench({}, ... + Workspace=labkit.app.layout.workspace(plot)); + app = labkit.app.Definition( ... + Entrypoint="labkit_InteractionProbe_app", ... + AppId="probe.interaction", Title="Interaction", ... + Family="Tests", AppVersion="1.0.0", ... + Updated="2026-07-19", Requirements=[], ... + Workbench=layout, ... + PresentWorkbench=@presentInteractionProbe); + + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + cleanup = onCleanup(@() runtime.close()); + + testCase.verifyTrue(any( ... + labkit.app.internal.DefinitionInspector.targetIds(app) == ... + "cropRegion")); + testCase.verifyTrue(any( ... + labkit.app.internal.DefinitionInspector.signalIds(app) == ... + "cropRegion__interactionChanged")); + end + end +end + +function exception = capture(callback) + try + callback(); + error("test:ExpectedFailure", "Callback did not fail."); + catch exception + end +end + +function state = variableCommand(varargin) + state = varargin{1}; +end + +function state = tableEdit(state, ~, ~) +end + +function state = selectionEdit(state, ~, ~) +end + +function state = valueEdit(state, ~, ~) +end + +function state = changeRectangle(state, ~, ~) +end + +function drawInteractionProbe(~, ~) +end + +function view = presentInteractionProbe(~) +view = labkit.app.view.Snapshot() ... + .renderPlot("preview", struct()) ... + .rectangle("cropRegion", [1 1 2 2], ImageSize=[10 10]); +end diff --git a/tests/cases/unit/labkit_framework/ui/UiExplicitContractKernelTest.m b/tests/cases/unit/labkit_framework/ui/UiExplicitContractKernelTest.m new file mode 100644 index 000000000..7541be5d9 --- /dev/null +++ b/tests/cases/unit/labkit_framework/ui/UiExplicitContractKernelTest.m @@ -0,0 +1,252 @@ +classdef UiExplicitContractKernelTest < matlab.unittest.TestCase + methods (Test, TestTags = {'Unit'}) + function compilesAndValidatesCompleteTablePlotContract(testCase) + setupLabKitTestPath(); + [app, view] = representativeContract(); + + testCase.verifyEqual( ... + labkit.app.internal.DefinitionInspector.targetIds(app), ... + ["group", "run", "dataTable", "result"]); + testCase.verifyTrue(app.validateViewSnapshot(view)); + testCase.verifyTrue(meta.class.fromName( ... + "labkit.app.Definition").Sealed); + testCase.verifyTrue(meta.class.fromName( ... + "labkit.app.internal.SignalBinding").Sealed); + testCase.verifyTrue(meta.class.fromName( ... + "labkit.app.internal.LayoutNode").Sealed); + testCase.verifyTrue(meta.class.fromName( ... + "labkit.app.view.Snapshot").Sealed); + end + + function rejectsUnknownArgumentsAndCallbackShapes(testCase) + setupLabKitTestPath(); + testCase.verifyError(@() labkit.app.layout.button( ... + "run", "Run", @variableInput), ... + "labkit:app:contract:CallbackRoleMismatch"); + testCase.verifyError(@() labkit.app.layout.button( ... + "run", "Run", @noOutput), ... + "labkit:app:contract:CallbackRoleMismatch"); + testCase.verifyError(@() labkit.app.layout.field( ... + "changed", OnValueChanged=@invoke), ... + "labkit:app:contract:CallbackRoleMismatch"); + testCase.verifyError(@() labkit.app.layout.field( ... + "changed", Typo=@valueChanged), ... + "labkit:app:contract:UnknownArgument"); + testCase.verifyError(@() labkit.app.layout.plotArea( ... + "plot", @invoke), ... + "labkit:app:contract:CallbackRoleMismatch"); + testCase.verifyError(@() labkit.app.layout.fileList( ... + "files", OnSelectionChanged=@invoke), ... + "labkit:app:contract:CallbackRoleMismatch"); + testCase.verifyError(@() labkit.app.view.Snapshot() ... + .fileItemStatuses("files", 1), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyError(@() labkit.app.layout.button( ... + "emptyTip", "Run", @invoke, Tooltip=""), ... + "labkit:app:contract:InvalidValue"); + end + + function compilesButtonAndFileActionTooltips(testCase) + setupLabKitTestPath(); + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.button("run", "Run", @invoke, ... + Tooltip="Compute the calibrated result."), ... + labkit.app.layout.fileList("files", ... + ChooseLabel="Add data", ... + ChooseTooltip="Add calibrated source data.")}); + plan = labkit.app.internal.DefinitionInspector.platformPlan( ... + applicationFor(layout)); + ids = string({plan.Nodes.Id}); + button = plan.Nodes(ids == "run").Configuration; + files = plan.Nodes(ids == "files").Configuration; + + testCase.verifyEqual(button.Tooltip, ... + "Compute the calibrated result."); + testCase.verifyEqual(files.ChooseTooltip, ... + "Add calibrated source data."); + testCase.verifyEqual(files.RemoveTooltip, files.RemoveLabel); + end + + function rejectsInvalidOwnershipAndDuplicateTargets(testCase) + setupLabKitTestPath(); + field = labkit.app.layout.field("group"); + testCase.verifyError(@() labkit.app.layout.group( ... + "bad", {labkit.app.layout.plotArea("plot", @draw)}), ... + "labkit:app:contract:UnsupportedOperation"); + testCase.verifyError(@() applicationFor( ... + labkit.app.layout.workbench({field, field})), ... + "labkit:app:contract:DuplicateId"); + + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.button("run", "Run", @invoke, ... + Tooltip="Run the ownership probe.")}); + testCase.verifyEqual( ... + labkit.app.internal.DefinitionInspector.signalIds( ... + applicationFor(layout)), ... + "run__pressed"); + testCase.verifyError(@() applicationFor( ... + layout, StrictCapabilities="unknown"), ... + "labkit:app:contract:UnknownArgument"); + end + + function composesMultipleWorkspacePageSurfaces(testCase) + setupLabKitTestPath(); + workspace = labkit.app.layout.workspace(Title="Data and Plot"); + workspace = workspace.page("dataPage", "Data", { ... + labkit.app.layout.dataTable("sourceGrid", ... + Title="Opened table"), ... + labkit.app.layout.dataTable("analysisGrid", ... + Title="Analysis data")}); + app = applicationFor(labkit.app.layout.workbench( ... + {}, Workspace=workspace)); + + view = labkit.app.view.Snapshot() ... + .workspacePage("dataPage") ... + .tableData("sourceGrid", {1}) ... + .tableData("analysisGrid", {2}); + testCase.verifyTrue(app.validateViewSnapshot(view)); + testCase.verifyError(@() workspace.page( ... + "emptyPage", "Empty", {}), ... + "labkit:app:contract:InvalidValue"); + end + + function rejectsInvalidOrIncompletePresentation(testCase) + setupLabKitTestPath(); + [app, view] = representativeContract(); + testCase.verifyError(@() app.validateViewSnapshot( ... + labkit.app.view.Snapshot().value("missing", 1)), ... + "labkit:app:contract:UnknownReference"); + testCase.verifyError(@() app.validateViewSnapshot( ... + labkit.app.view.Snapshot().tableData("group", [1 2])), ... + "labkit:app:contract:UnsupportedOperation"); + testCase.verifyError(@() app.validateViewSnapshot( ... + labkit.app.view.Snapshot().value("group", "A")), ... + "labkit:app:contract:UnknownReference"); + testCase.verifyError(@() view.value("group", "B"), ... + "labkit:app:contract:DuplicateId"); + end + + function compilesAuditedLayoutVocabularyAndWorkspacePages(testCase) + setupLabKitTestPath(); + controls = { + labkit.app.layout.section("inputs", "Inputs", { ... + labkit.app.layout.group("basic", { ... + labkit.app.layout.field("name", ... + OnValueChanged=@valueChanged), ... + labkit.app.layout.rangeField("window", ... + OnValueChanged=@valueChanged), ... + labkit.app.layout.slider("frame", ... + OnValueChanged=@valueChanged), ... + labkit.app.layout.fileList("files", ... + OnSelectionChanged=@listSelectionChanged), ... + labkit.app.layout.button("run", "Run", @invoke, ... + Tooltip="Run the layout-vocabulary probe.")})}), ... + labkit.app.layout.tab("details", "Details", { ... + labkit.app.layout.logPanel("log"), ... + labkit.app.layout.statusPanel("status")}), ... + }; + workspace = labkit.app.layout.workspace( ... + OnPageChanged=@valueChanged); + workspace = workspace.page("tablePage", "Table", ... + labkit.app.layout.dataTable("table", ... + OnCellEdited=@tableEdited, ... + OnCellSelectionChanged=@cellSelectionChanged)); + workspace = workspace.page("plotPage", "Plot", ... + labkit.app.layout.plotArea("plot", @draw, ... + AxisIds=["image", "trace"])); + workspace = workspace.initialPage("tablePage"); + app = applicationFor(labkit.app.layout.workbench( ... + controls, Workspace=workspace)); + + testCase.verifyEqual(workspace.PageIds, ... + ["tablePage", "plotPage"]); + testCase.verifyEqual(workspace.InitialPage, "tablePage"); + testCase.verifyTrue(all(ismember( ... + ["name", "window", "frame", "files", "run", "log", ... + "status", "tablePage", "table", "plotPage", "plot"], ... + labkit.app.internal.DefinitionInspector.targetIds(app)))); + testCase.verifyError(@() workspace.initialPage("missing"), ... + "labkit:app:contract:UnknownReference"); + testCase.verifyError(@() labkit.app.layout.workspace( ... + labkit.app.layout.statusPanel("single")).page( ... + "other", "Other", labkit.app.layout.statusPanel("other")), ... + "labkit:app:contract:UnsupportedOperation"); + end + + function rejectsDuplicateSignalOptionAndInvalidOnStart(testCase) + setupLabKitTestPath(); + testCase.verifyError(@() labkit.app.layout.field( ... + "name", OnValueChanged=@valueChanged, ... + OnValueChanged=@valueChanged), ... + "labkit:app:contract:UnknownArgument"); + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.button("run", "Run", @invoke, ... + Tooltip="Run the startup probe.")}); + testCase.verifyError(@() applicationFor( ... + layout, OnStart=@valueChanged), ... + "labkit:app:contract:CallbackRoleMismatch"); + end + end +end + +function [app, view] = representativeContract() +layout = labkit.app.layout.workbench({ ... + labkit.app.layout.group("inputs", { ... + labkit.app.layout.field("group", ... + OnValueChanged=@valueChanged), ... + labkit.app.layout.button("run", "Run", @invoke, ... + Tooltip="Run the representative contract probe.")}), ... + labkit.app.layout.dataTable("dataTable", ... + OnCellEdited=@tableEdited), ... + labkit.app.layout.plotArea("result", @draw)}); +app = applicationFor(layout, ... + PresentWorkbench=@emptyPresentation); +view = labkit.app.view.Snapshot() ... + .choices("group", ["A", "B"]) ... + .value("group", "A") ... + .tableData("dataTable", [1 2; 3 4]) ... + .enabled("run", true) ... + .renderPlot("result", struct("X", [1 2], "Y", [2 3])); +end + +function app = applicationFor(layout, varargin) +app = labkit.app.Definition( ... + "Entrypoint", "labkit_Probe_app", ... + "AppId", "probe.app", ... + "Title", "Probe", ... + "Family", "Tests", ... + "AppVersion", "1.0.0", ... + "Updated", "2026-07-19", ... + "Requirements", [], ... + "Workbench", layout, varargin{:}); +end + +function state = invoke(state, ~) +end + +function state = valueChanged(state, ~, ~) +end + +function state = tableEdited(state, ~, ~) +end + +function state = cellSelectionChanged(state, ~, ~) +end + +function state = listSelectionChanged(state, selection, ~) +assert(isa(selection, "labkit.app.event.ListSelection")); +end + +function state = variableInput(varargin) +state = varargin{1}; +end + +function noOutput(~, ~) +end + +function draw(~, ~) +end + +function view = emptyPresentation(~) +view = labkit.app.view.Snapshot(); +end diff --git a/tests/cases/unit/labkit_framework/ui/UiExplicitContractValueTest.m b/tests/cases/unit/labkit_framework/ui/UiExplicitContractValueTest.m new file mode 100644 index 000000000..5acde9c9b --- /dev/null +++ b/tests/cases/unit/labkit_framework/ui/UiExplicitContractValueTest.m @@ -0,0 +1,214 @@ +classdef UiExplicitContractValueTest < matlab.unittest.TestCase + methods (Test, TestTags = {'Unit'}) + function projectContractValidatesEveryFixedCallback(testCase) + setupLabKitTestPath(); + contract = labkit.app.project.Schema( ... + Version=2, Create=@createProject, ... + Validate=@validateProject, Migrate=@migrateProject, ... + LegacyImports=struct("legacyProject", @importProject), ... + CreateResume=@createResume, ApplyResume=@applyResume, ... + RelinkSources=@relinkSources); + + testCase.verifyEqual(contract.Version, 2); + testCase.verifyTrue(meta.class.fromName( ... + "labkit.app.project.Schema").Sealed); + testCase.verifyError(@() labkit.app.project.Schema( ... + Version=2, Create=@createProject, ... + Validate=@validateProject), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyError(@() labkit.app.project.Schema( ... + Version=1, Create=@createProject, ... + Validate=@wrongValidate), ... + "labkit:app:contract:CallbackRoleMismatch"); + end + + function payloadValuesRejectAmbiguousTransport(testCase) + setupLabKitTestPath(); + edit = labkit.app.event.TableCellEdit( ... + RowId="row-a", RowIndex=1, ... + ColumnId="group", ColumnIndex=2, ... + PreviousValue="A", NewValue="B", ... + Data={"row-a", "B"}); + selection = labkit.app.event.ListSelection( ... + Ids=["row-a", "row-c"], Indices=[1 3]); + cells = labkit.app.event.TableCellSelection([1 2; 3 1]); + dialog = labkit.app.dialog.Choice("", Cancelled=false); + + testCase.verifyEqual(edit.NewValue, "B"); + testCase.verifyEqual(edit.Data, {"row-a", "B"}); + testCase.verifyEqual(selection.Indices, [1 3]); + testCase.verifyEqual(cells.CellIndices, [1 2; 3 1]); + testCase.verifyFalse(dialog.Cancelled); + testCase.verifyError(@() labkit.app.event.ListSelection( ... + Ids=["a", "b"], Indices=1), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyError(@() ... + labkit.app.event.TableCellSelection([1 1; 1 1]), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyError(@() labkit.app.event.TableCellEdit( ... + RowIndex=0, ColumnIndex=1, ... + PreviousValue=[], NewValue=[]), ... + "labkit:app:contract:InvalidValue"); + end + + function intervalScrollIsTypedAndStrict(testCase) + setupLabKitTestPath(); + event = labkit.app.event.IntervalScroll( ... + Anchor=0.25, Count=-2); + + testCase.verifyEqual(event.Anchor, 0.25); + testCase.verifyEqual(event.Count, -2); + testCase.verifyError(@() labkit.app.event.IntervalScroll( ... + Anchor=0.25, Count=0), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyError(@() labkit.app.event.IntervalScroll( ... + Anchor=NaN, Count=1), ... + "labkit:app:contract:InvalidValue"); + end + + function resultValuesValidatePathsAndUniqueness(testCase) + setupLabKitTestPath(); + output = labkit.app.result.File( ... + "summary", "primary", "summary.csv", ... + MediaType="text/csv"); + result = labkit.app.result.Package(Outputs={output}, ... + Inputs=struct(), Parameters=struct(), ... + Summary=struct("rows", 3)); + + testCase.verifyEqual(result.Outputs{1}.Id, "summary"); + testCase.verifyError(@() labkit.app.result.File( ... + "bad", "primary", "../outside.csv"), ... + "labkit:app:contract:InvalidValue"); + duplicate = labkit.app.result.File( ... + "other", "secondary", "summary.csv"); + testCase.verifyError(@() labkit.app.result.Package( ... + Outputs={output, duplicate}, Inputs=struct(), ... + Parameters=struct(), Summary=struct()), ... + "labkit:app:contract:DuplicateId"); + failed = labkit.app.result.File( ... + "failed", "primary", "failed.csv", Status="failed"); + testCase.verifyError(@() labkit.app.result.Package( ... + Outputs={failed}, Inputs=struct(), ... + Parameters=struct(), Summary=struct()), ... + "labkit:app:contract:InvalidValue"); + end + + function applicationOwnsProjectContract(testCase) + setupLabKitTestPath(); + project = labkit.app.project.Schema( ... + Version=1, Create=@createProject, ... + Validate=@validateProject); + app = labkit.app.Definition( ... + Entrypoint="labkit_Probe_app", AppId="probe.app", ... + Title="Probe", Family="Tests", AppVersion="1.0.0", ... + Updated="2026-07-19", Requirements=[], ... + ProjectSchema=project, Workbench=labkit.app.layout.workbench({})); + + testCase.verifyEqual(app.ProjectSchema.Version, 1); + end + + function simpleProjectContractNeedsNoCallbacks(testCase) + setupLabKitTestPath(); + contract = labkit.app.project.Schema(); + + testCase.verifyEqual(contract.Version, 1); + testCase.verifyEqual(contract.Create(), struct()); + testCase.verifyTrue(contract.Validate(struct("value", 1))); + testCase.verifyFalse(contract.Validate([])); + end + + function projectMigrationCanCreatePortableSourceValue(testCase) + setupLabKitTestPath(); + source = labkit.app.project.sourceRecord( ... + "image1", "cropSource", "synthetic.png"); + + testCase.verifyEqual(source.id, "image1"); + testCase.verifyEqual(source.role, "cropSource"); + testCase.verifyTrue(source.required); + reference = struct("schemaVersion", 1, ... + "relativePath", "../media/synthetic.png", ... + "originalPath", "source/synthetic.png", ... + "fileName", "synthetic.png"); + restored = labkit.app.project.sourceRecord( ... + "image1", "cropSource", reference); + testCase.verifyEqual(restored.reference, reference); + testCase.verifyError(@() labkit.app.project.sourceRecord( ... + "", "cropSource", "synthetic.png"), ... + "labkit:app:contract:InvalidValue"); + end + + function applicationValidatesLifecycleCallbacks(testCase) + setupLabKitTestPath(); + app = labkit.app.Definition( ... + Entrypoint="labkit_Probe_app", AppId="probe.callbacks", ... + Title="Probe", Family="Tests", AppVersion="1.0.0", ... + Updated="2026-07-19", Requirements=[], ... + Workbench=labkit.app.layout.workbench({}), ... + OnStart=@startApp, ... + CreateSession=@createSession, ... + PresentWorkbench=@present, ... + BuildDebugSample=@debugSample); + + testCase.verifyEqual(string(func2str(app.OnStart)), "startApp"); + testCase.verifyTrue(any( ... + labkit.app.internal.DefinitionInspector.signalIds(app) == ... + "application__started")); + testCase.verifyError(@() labkit.app.Definition( ... + Entrypoint="labkit_Probe_app", AppId="probe.bad", ... + Title="Probe", Family="Tests", AppVersion="1.0.0", ... + Updated="2026-07-19", Requirements=[], ... + Workbench=labkit.app.layout.workbench({}), ... + CreateSession=@wrongSession), ... + "labkit:app:contract:CallbackRoleMismatch"); + end + end +end + +function project = createProject() + project = struct(); +end + +function accepted = validateProject(~) + accepted = true; +end + +function accepted = wrongSession(~) + accepted = true; +end + +function accepted = wrongValidate(~, ~) + accepted = true; +end + +function project = migrateProject(project, ~) +end + +function [project, resume] = importProject(~) + project = struct(); + resume = struct(); +end + +function resume = createResume(~, ~) + resume = struct(); +end + +function session = applyResume(session, ~, ~) +end + +function project = relinkSources(project, ~, ~) +end + +function state = startApp(state, ~) +end + +function session = createSession(~, ~) + session = struct(); +end + +function view = present(~) + view = labkit.app.view.Snapshot(); +end + +function pack = debugSample(~) + pack = struct(); +end diff --git a/tests/cases/unit/labkit_framework/ui/UiLayoutTest.m b/tests/cases/unit/labkit_framework/ui/UiLayoutTest.m deleted file mode 100644 index fe281f0d5..000000000 --- a/tests/cases/unit/labkit_framework/ui/UiLayoutTest.m +++ /dev/null @@ -1,288 +0,0 @@ -classdef UiLayoutTest < matlab.unittest.TestCase - %UILAYOUTTEST Verify current LabKit declarative layout contracts. - - methods (Test, TestTags = {'Unit'}) - function test_uiLayout(testCase) - setupLabKitTestPath(); - verify_uiLayout(); - end - end -end - -function verify_uiLayout() -%TEST_UILAYOUT Verify layout grammar and GUI-free validation. - - checkCommonLayoutShape(); - checkChildrenMustBeCellRows(); - checkGroupLayout(); - checkFieldKindWhitelist(); - checkPannerLayout(); - checkFilePanelLayout(); - checkPreviewAreaValidation(); - checkRetiredLayoutOptionsAreRejected(); - checkDuplicateIdsFailBeforeGuiConstruction(); -end - -function checkGroupLayout() - actions = labkit.ui.layout.group('runGroup', 'Run Commands', { ... - labkit.ui.layout.action('run', 'Run', @uiLayoutModeProbe), ... - labkit.ui.layout.action('reset', 'Reset', @uiLayoutModeProbe)}); - assert(strcmp(actions.kind, 'group') && ... - strcmp(actions.props.title, 'Run Commands') && ... - strcmp(actions.props.layout, 'auto') && ... - numel(actions.children) == 2, ... - 'group should preserve title, default layout, and child layout nodes.'); - - inline = labkit.ui.layout.group('plotFlags', '', { ... - labkit.ui.layout.field('showGrid', 'Grid', 'kind', 'checkbox')}, ... - 'layout', 'inline'); - assert(strcmp(inline.props.layout, 'inline'), ... - 'group should preserve app-neutral layout intent.'); - - section = labkit.ui.layout.section('section', 'Section', {actions}); - app = labkit.ui.layout.workbench('groupProbe', 'Group Probe', ... - 'controlTabs', {labkit.ui.layout.tab('setup', 'Setup', {section})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Workspace', {})); - assert(strcmp(app.props.controlTabs{1}.children{1}.children{1}.kind, ... - 'group'), ... - 'group should be valid inside section layouts.'); - - emptyGroup = labkit.ui.layout.group('emptyGroup', '', {}); - emptyApp = labkit.ui.layout.workbench('emptyGroupProbe', 'Empty Group Probe', ... - 'controlTabs', {labkit.ui.layout.tab('setup', 'Setup', { ... - labkit.ui.layout.section('section', 'Section', {emptyGroup})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Workspace', {})); - assertThrows(@() labkit.ui.runtime.create(emptyApp), ... - 'labkit:ui:runtime:EmptyGroup', ... - 'Empty groups should be rejected as incomplete semantic layouts.'); - - badLayout = labkit.ui.layout.group('badLayoutGroup', '', { ... - labkit.ui.layout.field('gain', 'Gain')}, ... - 'layout', 'actions'); - badLayoutApp = labkit.ui.layout.workbench('badLayoutProbe', 'Bad Layout Probe', ... - 'controlTabs', {labkit.ui.layout.tab('setup', 'Setup', { ... - labkit.ui.layout.section('section', 'Section', {badLayout})})}, ... - 'workspace', labkit.ui.layout.workspace('workspace', 'Workspace', {})); - assertThrows(@() labkit.ui.runtime.create(badLayoutApp), ... - 'labkit:ui:runtime:InvalidGroupLayout', ... - 'Action layout groups should reject non-action children.'); -end - -function checkRetiredLayoutOptionsAreRejected() - baseSection = labkit.ui.layout.section('section', 'Section', { ... - labkit.ui.layout.action('run', 'Run', @uiLayoutModeProbe)}); - baseTab = labkit.ui.layout.tab('setup', 'Setup', {baseSection}); - workspace = labkit.ui.layout.workspace('workspace', 'Workspace', {}); - - withPosition = labkit.ui.layout.workbench('positionProbe', 'Position Probe', ... - 'position', [1 2 3 4], ... - 'controlTabs', {baseTab}, ... - 'workspace', workspace); - assertThrows(@() labkit.ui.runtime.create(withPosition), ... - 'labkit:ui:runtime:RetiredLayoutProperty', ... - 'App position should be a shared framework default.'); - - withLeftWidth = labkit.ui.layout.workbench('leftWidthProbe', 'Left Width Probe', ... - 'leftWidth', 360, ... - 'controlTabs', {baseTab}, ... - 'workspace', workspace); - assertThrows(@() labkit.ui.runtime.create(withLeftWidth), ... - 'labkit:ui:runtime:RetiredLayoutProperty', ... - 'App left pane width should be a shared framework default.'); - - layoutProps = { ... - 'height', 80; ... - 'height', 'fit'; ... - 'height', 'flex'; ... - 'minRows', 3; ... - 'minHeight', 160; ... - 'maxColumns', 3; ... - 'rowSpacing', 12; ... - 'columnSpacing', 12; ... - 'padding', [4 4 4 4]; ... - 'chrome', 'none'; ... - 'columnWidth', {{80, '1x'}}; ... - 'rowHeight', {{'fit'}}}; - for k = 1:size(layoutProps, 1) - section = labkit.ui.layout.section('layoutSection', 'Layout Section', { ... - labkit.ui.layout.action('layoutRun', 'Run', @uiLayoutModeProbe)}, ... - layoutProps{k, 1}, layoutProps{k, 2}); - app = labkit.ui.layout.workbench(['layoutProbe' num2str(k)], ... - 'Layout Probe', ... - 'controlTabs', {labkit.ui.layout.tab('layoutTab', 'Layout', {section})}, ... - 'workspace', workspace); - assertThrows(@() labkit.ui.runtime.create(app), ... - 'labkit:ui:runtime:RetiredLayoutProperty', ... - 'App layouts should not own concrete layout properties.'); - end -end - -function checkCommonLayoutShape() - field = labkit.ui.layout.field('gain', 'Gain', ... - 'kind', 'spinner', 'value', 2); - section = labkit.ui.layout.section('settings', 'Settings', {field}); - tab = labkit.ui.layout.tab('setup', 'Setup', {section}); - workspace = labkit.ui.layout.workspace('workspace', 'Preview', { ... - labkit.ui.layout.previewArea('preview', 'Preview')}); - app = labkit.ui.layout.workbench('probeApp', 'Probe App', ... - 'controlTabs', {tab}, 'workspace', workspace); - - assert(strcmp(app.kind, 'app'), 'App layout should preserve kind.'); - assert(strcmp(app.id, 'probeApp'), 'App layout should preserve id.'); - assert(isstruct(app.props) && iscell(app.children) && isstruct(app.slots), ... - 'Layout 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 layout nodes.'); - usageApp = labkit.ui.layout.workbench('usageProbe', 'Usage Probe', ... - 'controlTabs', {tab}, ... - 'workspace', workspace, ... - 'usage', {'Open files.', 'Run analysis.'}); - firstTabChildren = usageApp.props.controlTabs{1}.children; - assert(strcmp(firstTabChildren{end}.children{1}.kind, 'usagePanel'), ... - 'App-level usage should be injected into the first tab.'); -end - -function checkChildrenMustBeCellRows() - child = labkit.ui.layout.field('probe', 'Probe'); - assertThrows(@() labkit.ui.layout.section('bad', 'Bad', [child child]), ... - 'labkit:ui:layout:InvalidChildren', ... - 'Struct-array children should be rejected.'); - assertThrows(@() labkit.ui.layout.tab('badTab', 'Bad', {child; child}), ... - 'labkit:ui:layout:InvalidChildren', ... - 'Column-cell children should be rejected.'); -end - -function checkFieldKindWhitelist() - allowed = {'text', 'number', 'spinner', 'dropdown', 'slider', ... - 'checkbox', 'readonly'}; - for k = 1:numel(allowed) - layoutNode = labkit.ui.layout.field(['field' num2str(k)], 'Field', ... - 'kind', allowed{k}); - assert(strcmpi(layoutNode.props.kind, allowed{k}), ... - 'Allowed field kind should be preserved.'); - end - assertThrows(@() labkit.ui.layout.field('radio', 'Radio', ... - 'kind', 'radioGroup'), 'labkit:ui:layout:InvalidFieldKind', ... - 'Primitive or unproven field kinds should stay out of the public layout grammar.'); -end - -function checkPannerLayout() - layoutNode = labkit.ui.layout.panner('pan', 'Pan', ... - 'limits', [0 12], ... - 'value', 3, ... - 'stepFraction', 0.01, ... - 'minStep', 0.1, ... - 'maxStep', 0.5, ... - 'showTicks', false); - assert(strcmp(layoutNode.kind, 'panner') && isequal(layoutNode.props.limits, [0 12]), ... - 'panner should preserve numeric limits and common layout-node shape.'); - assert(~layoutNode.props.showTicks, ... - 'panner should preserve compact tick visibility options.'); - unbounded = labkit.ui.layout.panner('unboundedPan', 'Unbounded', ... - 'limits', [0 Inf], ... - 'value', 4, ... - 'step', 1); - assert(isinf(unbounded.props.limits(2)) && ~unbounded.props.showTicks, ... - 'panner should support spinner-compatible infinite limits without visible ticks.'); - assertThrows(@() labkit.ui.layout.panner('badPan', 'Bad', ... - 'limits', [1 1]), ... - 'labkit:ui:layout:InvalidPannerLimits', ... - 'panner should reject non-increasing limits.'); -end - -function checkFilePanelLayout() - layoutNode = labkit.ui.layout.filePanel('tasks', 'Files', ... - 'filters', {'*.jpg;*.png', 'Images'}, ... - 'selectionMode', 'multiple', ... - 'maxFiles', 12, ... - 'folderWarningThreshold', 100); - assert(strcmp(layoutNode.kind, 'filePanel') && ... - strcmp(layoutNode.props.selectionMode, 'multiple') && ... - layoutNode.props.maxFiles == 12, ... - 'filePanel should preserve file input options.'); - singleLayout = labkit.ui.layout.filePanel('singleFile', 'Single file', ... - 'mode', 'single'); - assert(strcmp(singleLayout.props.mode, 'single'), ... - 'filePanel should preserve single-file mode.'); - customChoose = labkit.ui.layout.filePanel('customFileLabels', 'Files', ... - 'chooseLabel', 'Add DTA files', ... - 'removeLabel', 'Remove selected files', ... - 'clearLabel', 'Clear all files'); - assert(strcmp(customChoose.props.chooseLabel, 'Add DTA files') && ... - strcmp(customChoose.props.removeLabel, 'Remove selected files') && ... - strcmp(customChoose.props.clearLabel, 'Clear all files'), ... - 'filePanel should preserve app-authored command wording.'); - assertThrows(@() labkit.ui.layout.filePanel('badMode', 'Files', ... - 'mode', 'folder'), ... - 'labkit:ui:layout:InvalidFilePanelMode', ... - 'Unsupported filePanel modes should fail validation.'); - assertThrows(@() labkit.ui.layout.filePanel('badFiles', 'Files', ... - 'selectionMode', 'range'), ... - 'labkit:ui:layout:InvalidFilePanelSelectionMode', ... - 'Unsupported filePanel selection modes should fail validation.'); - assertThrows(@() labkit.ui.layout.filePanel('badMaxFiles', 'Files', ... - 'maxFiles', 0), ... - 'labkit:ui:layout:InvalidFilePanelMaxFiles', ... - 'filePanel maxFiles should reject nonpositive values.'); -end - -function checkPreviewAreaValidation() - pair = labkit.ui.layout.previewArea('pairPreview', 'Pair', ... - 'layout', 'pair', ... - 'axisIds', {'input', 'output'}, ... - 'xLabels', {'Time (s)', 'Time (s)'}, ... - 'yLabels', {'Vf (V)', 'Im (A)'}, ... - 'scrollZoomAxes', {'xy', 'y'}); - 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.'); - assert(isequal(pair.props.scrollZoomAxes, {'xy', 'y'}), ... - 'previewArea should preserve app-authored wheel zoom axes.'); - stack = labkit.ui.layout.previewArea('waveforms', 'Waveforms', ... - 'layout', 'stack', 'count', 4); - assert(stack.props.count == 4, ... - 'Stacked preview areas should support axis count.'); - modes = labkit.ui.layout.previewArea('modePreview', 'Mode Preview', ... - 'viewModes', {'Input', 'Output'}, 'onModeChange', @uiLayoutModeProbe); - assert(isequal(modes.props.viewModes, {'Input', 'Output'}), ... - 'previewArea should preserve declared viewModes.'); - assertThrows(@() labkit.ui.layout.previewArea('badPreview', 'Bad', ... - 'layout', 'grid'), 'labkit:ui:layout:InvalidPreviewLayout', ... - 'Unsupported preview layouts should fail validation.'); - assertThrows(@() labkit.ui.layout.previewArea( ... - 'duplicateAxes', 'Duplicate axes', 'layout', 'pair', ... - 'axisIds', {'image', 'image'}), ... - 'labkit:ui:runtime:DuplicateAxisId', ... - 'Duplicate preview axis ids must fail before axes are constructed.'); -end - -function checkDuplicateIdsFailBeforeGuiConstruction() - tab = labkit.ui.layout.tab('setup', 'Setup', { ... - labkit.ui.layout.section('sectionOne', 'One', { ... - labkit.ui.layout.field('dup', 'First'), ... - labkit.ui.layout.field('dup', 'Second')})}); - workspace = labkit.ui.layout.workspace('workspace', 'Workspace', {}); - layoutNode = labkit.ui.layout.workbench('probeApp', 'Probe App', ... - 'controlTabs', {tab}, 'workspace', workspace); - assertThrows(@() labkit.ui.runtime.create(layoutNode), ... - 'labkit:ui:runtime: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 uiLayoutModeProbe(varargin) -end diff --git a/tests/cases/unit/labkit_framework/ui/UiPortableSourceStoreTest.m b/tests/cases/unit/labkit_framework/ui/UiPortableSourceStoreTest.m new file mode 100644 index 000000000..aaf6cdab4 --- /dev/null +++ b/tests/cases/unit/labkit_framework/ui/UiPortableSourceStoreTest.m @@ -0,0 +1,65 @@ +classdef UiPortableSourceStoreTest < matlab.unittest.TestCase + methods (Test, TestTags = {'Unit'}) + function createsCanonicalRecordsAndReadsPathsById(testCase) + setupLabKitTestPath(); + runtime = sourceRuntime(); + first = runtime.sourceRecord( ... + "first", "reference", "data/first.tif", true); + second = runtime.sourceRecord( ... + "second", "moving", "data/second.tif", false); + records = [first; second]; + + testCase.verifyEqual(string(fieldnames(first)), ... + ["id"; "required"; "role"; "reference"]); + testCase.verifyTrue(first.required); + testCase.verifyFalse(second.required); + testCase.verifyEqual(runtime.sourcePaths( ... + records, ["second"; "missing"; "first"]), ... + ["data/second.tif"; ""; "data/first.tif"]); + end + + function upsertsAndReconcilesWithoutExposingStore(testCase) + setupLabKitTestPath(); + runtime = sourceRuntime(); + first = runtime.sourceRecord( ... + "first", "reference", "first.tif", true); + replacement = runtime.sourceRecord( ... + "first", "replacement", "replacement.tif", false); + second = runtime.sourceRecord( ... + "second", "moving", "second.tif", true); + + records = runtime.upsertSource(first, replacement); + reconciled = runtime.reconcileSources( ... + records, [second; replacement]); + + testCase.verifyEqual(records.role, "replacement"); + testCase.verifyFalse(records.required); + testCase.verifyEqual(string({reconciled.id}).', ... + ["second"; "first"]); + end + + function rejectsDuplicateAndNoncanonicalRecords(testCase) + setupLabKitTestPath(); + runtime = sourceRuntime(); + record = runtime.sourceRecord( ... + "source", "recording", "source.csv", true); + duplicate = record; + duplicate.reference.extra = "not portable"; + + testCase.verifyError(@() runtime.reconcileSources( ... + [record; record], record), ... + "labkit:app:runtime:InvalidSourceRecords"); + testCase.verifyError(@() runtime.upsertSource(record, duplicate), ... + "labkit:app:runtime:InvalidSourceRecords"); + end + end +end + +function runtime = sourceRuntime() +app = labkit.app.Definition( ... + Entrypoint="labkit_SourceProbe_app", AppId="probe.sources", ... + Title="Sources", Family="Tests", AppVersion="1.0.0", ... + Updated="2026-07-19", Requirements=[], ... + Workbench=labkit.app.layout.workbench({})); +runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); +end diff --git a/tests/cases/unit/labkit_framework/ui/UiProjectDocumentStoreTest.m b/tests/cases/unit/labkit_framework/ui/UiProjectDocumentStoreTest.m new file mode 100644 index 000000000..4e4900f18 --- /dev/null +++ b/tests/cases/unit/labkit_framework/ui/UiProjectDocumentStoreTest.m @@ -0,0 +1,333 @@ +classdef UiProjectDocumentStoreTest < matlab.unittest.TestCase + methods (Test, TestTags = {'Unit'}) + function savesAndRestoresCurrentEnvelope(testCase) + setupLabKitTestPath(); + folder = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture); + path = fullfile(string(folder.Folder), "current.mat"); + app = documentApplication(); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + state = struct("project", struct("value", 7), ... + "session", struct("token", "resume-token")); + + saved = runtime.saveProject(state, path); + raw = load(path, "labkitProject"); + runtime.restoreProject(path, false); + restored = runtime.State; + metadata = runtime.documentMetadata(); + + testCase.verifyFalse(saved.Cancelled); + testCase.verifyEqual(saved.Value, path); + testCase.verifyEqual(raw.labkitProject.format, "labkit.project"); + testCase.verifyEqual(raw.labkitProject.formatVersion.major, 1); + testCase.verifyEqual(raw.labkitProject.app.id, "probe.document"); + testCase.verifyEqual(raw.labkitProject.app.payloadVersion, 2); + testCase.verifyEqual(raw.labkitProject.producer.appVersion, "1.2.3"); + testCase.verifyEqual(restored.project.value, 7); + testCase.verifyEqual(restored.session.token, "resume-token"); + testCase.verifyEqual(metadata.path, path); + testCase.verifyFalse(metadata.dirty); + end + + function migratesCurrentAndImportsDeclaredLegacyOnly(testCase) + setupLabKitTestPath(); + folder = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture); + app = documentApplication(); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + currentPath = fullfile(string(folder.Folder), "v1.mat"); + legacyPath = fullfile(string(folder.Folder), "legacy.mat"); + envelope = currentEnvelope("probe.document", 1, struct("value", 4)); + labkitProject = envelope; + save(currentPath, "labkitProject"); + legacyProject = 9; + save(legacyPath, "legacyProject"); + + runtime.restoreProject(currentPath, false); + migrated = runtime.State; + runtime.restoreProject(legacyPath, true); + legacy = runtime.State; + legacyMetadata = runtime.documentMetadata(); + + testCase.verifyEqual(migrated.project.value, 5); + testCase.verifyEqual(legacy.project.value, 9); + testCase.verifyEqual(legacy.session.token, "legacy"); + testCase.verifyEqual(legacyMetadata.path, ""); + testCase.verifyTrue(legacyMetadata.dirty); + end + + function rejectsWrongAppAndNewerPayloadWithoutMutatingMetadata(testCase) + setupLabKitTestPath(); + folder = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture); + app = documentApplication(); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + original = runtime.documentMetadata(); + wrongPath = fullfile(string(folder.Folder), "wrong.mat"); + newerPath = fullfile(string(folder.Folder), "newer.mat"); + labkitProject = currentEnvelope("other.app", 2, struct("value", 1)); + save(wrongPath, "labkitProject"); + labkitProject = currentEnvelope("probe.document", 3, struct("value", 1)); + save(newerPath, "labkitProject"); + + testCase.verifyError(@() runtime.restoreProject(wrongPath, false), ... + "labkit:app:runtime:WrongProjectApp"); + testCase.verifyEqual(runtime.documentMetadata(), original); + testCase.verifyError(@() runtime.restoreProject(newerPath, false), ... + "labkit:app:runtime:NewerProjectPayload"); + testCase.verifyEqual(runtime.documentMetadata(), original); + end + + function failedSaveDoesNotMutateMetadata(testCase) + setupLabKitTestPath(); + app = documentApplication(); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + original = runtime.documentMetadata(); + state = struct("project", struct("value", 1), ... + "session", struct("token", "unsaved")); + + testCase.verifyError(@() runtime.saveProject(state, ... + fullfile(tempdir, "missing-document-folder", "project.mat")), ... + "labkit:app:runtime:ProjectWriteFailed"); + testCase.verifyEqual(runtime.documentMetadata(), original); + end + + function failedPlatformCommitDoesNotPublishRestoredDocument(testCase) + setupLabKitTestPath(); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture); + path = fullfile(string(folder.Folder), "candidate.mat"); + app = documentApplication(); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + originalState = runtime.State; + originalMetadata = runtime.documentMetadata(); + labkitProject = currentEnvelope( ... + "probe.document", 2, struct("value", 12)); + save(path, "labkitProject"); + runtime.failNextCommit(); + + testCase.verifyError(@() runtime.restoreProject(path, false), ... + "labkit:app:runtime:ProjectRestoreFailed"); + testCase.verifyEqual(runtime.State, originalState); + testCase.verifyEqual(runtime.documentMetadata(), originalMetadata); + end + + function callbackContextRestoreUsesTheActiveTransaction(testCase) + setupLabKitTestPath(); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture); + path = fullfile(string(folder.Folder), "callback-candidate.mat"); + labkitProject = currentEnvelope( ... + "probe.document", 2, struct("value", 14)); + save(path, "labkitProject"); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + callbackRestoreApplication(path)); + + runtime.invokeAction("restoreDocument"); + + testCase.verifyEqual(runtime.State.project.value, 14); + testCase.verifyEqual(runtime.State.session.token, "from-file"); + metadata = runtime.documentMetadata(); + testCase.verifyEqual(metadata.path, path); + testCase.verifyFalse(metadata.dirty); + + failed = labkit.app.internal.RuntimeFactory.createHeadless( ... + callbackRestoreApplication(path)); + originalState = failed.State; + originalMetadata = failed.documentMetadata(); + failed.failNextCommit(); + testCase.verifyError(@() failed.invokeAction("restoreDocument"), ... + "labkit:app:runtime:ActionFailed"); + testCase.verifyEqual(failed.State, originalState); + testCase.verifyEqual(failed.documentMetadata(), originalMetadata); + end + + function callbackContextNewProjectResetsDocumentIdentity(testCase) + setupLabKitTestPath(); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture); + path = fullfile(string(folder.Folder), "named.mat"); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + callbackRestoreApplication(path)); + named = struct("project", struct("value", 9), ... + "session", struct("token", "named")); + runtime.saveProject(named, path); + namedMetadata = runtime.documentMetadata(); + + runtime.invokeAction("newDocument"); + + testCase.verifyEqual(runtime.State.project.value, 0); + testCase.verifyEqual(runtime.State.session.token, "fresh"); + metadata = runtime.documentMetadata(); + testCase.verifyEqual(metadata.path, ""); + testCase.verifyTrue(metadata.dirty); + testCase.verifyNotEqual(metadata.id, namedMetadata.id); + end + + function filePanelBindingOwnsPortableProjectSources(testCase) + setupLabKitTestPath(); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture); + projectFolder = fullfile(string(folder.Folder), "projects"); + sourceFolder = fullfile(string(folder.Folder), "sources"); + mkdir(projectFolder); + mkdir(sourceFolder); + sourcePath = fullfile(sourceFolder, "trace.csv"); + file = fopen(sourcePath, "w"); + cleaner = onCleanup(@() fclose(file)); + fprintf(file, "time,value\n0,1\n"); + clear cleaner + projectPath = fullfile(projectFolder, "bound.mat"); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + sourceDocumentApplication()); + state = runtime.State; + state.project.inputs.sources = runtime.sourceRecord( ... + "trace", "recording", sourcePath, true); + + runtime.saveProject(state, projectPath); + saved = load(projectPath, "labkitProject"); + runtime.restoreProject(projectPath, false); + paths = runtime.sourcePaths( ... + runtime.State.project.inputs.sources, strings(0, 1)); + + testCase.verifyEqual( ... + saved.labkitProject.sources.reference.relativePath, ... + "../sources/trace.csv"); + testCase.verifyEqual( ... + saved.labkitProject.payload.inputs.sources.reference.relativePath, ... + "../sources/trace.csv"); + testCase.verifyEqual(paths, string(sourcePath)); + end + + function missingRequiredBoundSourceRejectsRestore(testCase) + setupLabKitTestPath(); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture); + sourcePath = fullfile(string(folder.Folder), "temporary.csv"); + file = fopen(sourcePath, "w"); + cleaner = onCleanup(@() fclose(file)); + fprintf(file, "synthetic"); + clear cleaner + projectPath = fullfile(string(folder.Folder), "bound.mat"); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + sourceDocumentApplication()); + state = runtime.State; + state.project.inputs.sources = runtime.sourceRecord( ... + "trace", "recording", sourcePath, true); + runtime.saveProject(state, projectPath); + delete(sourcePath); + restored = labkit.app.internal.RuntimeFactory.createHeadless( ... + sourceDocumentApplication()); + original = restored.State; + + testCase.verifyError(@() ... + restored.restoreProject(projectPath, false), ... + "labkit:app:runtime:MissingProjectSource"); + testCase.verifyEqual(restored.State, original); + end + end +end + +function app = documentApplication() +project = labkit.app.project.Schema(Version=2, Create=@createProject, ... + Validate=@validateProject, Migrate=@migrateProject, ... + LegacyImports=struct("legacyProject", @importLegacy), ... + CreateResume=@createResume, ApplyResume=@applyResume); +app = labkit.app.Definition(Entrypoint="labkit_DocumentProbe_app", ... + AppId="probe.document", Title="Document", Family="Tests", ... + AppVersion="1.2.3", Updated="2026-07-19", Requirements=[], ... + ProjectSchema=project, CreateSession=@createSession, ... + Workbench=labkit.app.layout.workbench({labkit.app.layout.field("value")}), ... + PresentWorkbench=@present); +end + +function app = callbackRestoreApplication(filepath) +project = labkit.app.project.Schema(Version=2, Create=@createProject, ... + Validate=@validateProject, Migrate=@migrateProject, ... + LegacyImports=struct("legacyProject", @importLegacy), ... + CreateResume=@createResume, ApplyResume=@applyResume); +layout = labkit.app.layout.workbench({ ... + labkit.app.layout.field("value"), ... + labkit.app.layout.button("restoreDocument", "Open", @restoreDocument, ... + Tooltip="Open the saved probe document."), ... + labkit.app.layout.button("newDocument", "New", @newDocument, ... + Tooltip="Create a new probe document.")}); +app = labkit.app.Definition(Entrypoint="labkit_CallbackDocumentProbe_app", ... + AppId="probe.document", Title="Document", Family="Tests", ... + AppVersion="1.2.3", Updated="2026-07-19", Requirements=[], ... + ProjectSchema=project, CreateSession=@createSession, ... + Workbench=layout, PresentWorkbench=@present); + + function state = restoreDocument(~, context) + state = context.restoreProjectDocument(filepath); + end + + function state = newDocument(~, context) + state = context.newProjectDocument(); + end +end + +function project = createProject() +project = struct("value", 0); +end + +function accepted = validateProject(project) +accepted = isstruct(project) && isscalar(project) && isfield(project, "value") && ... + isnumeric(project.value) && isscalar(project.value) && project.value >= 0; +end + +function project = migrateProject(project, fromVersion) +project.value = project.value + fromVersion; +end + +function [project, resume] = importLegacy(value) +project = struct("value", value); +resume = struct("token", "legacy"); +end + +function session = createSession(~, ~) +session = struct("token", "fresh"); +end + +function resume = createResume(session, ~) +resume = struct("token", session.token); +end + +function session = applyResume(session, resume, ~) +session.token = resume.token; +end + +function view = present(state) +view = labkit.app.view.Snapshot().value("value", state.project.value); +end + +function app = sourceDocumentApplication() +project = labkit.app.project.Schema(Version=1, ... + Create=@createSourceProject, Validate=@validateSourceProject); +layout = labkit.app.layout.workbench({ ... + labkit.app.layout.fileList("sources", ... + Bind="project.inputs.sources")}); +app = labkit.app.Definition(Entrypoint="labkit_SourceDocumentProbe_app", ... + AppId="probe.source.document", Title="Source document", Family="Tests", ... + AppVersion="1.0.0", Updated="2026-07-19", Requirements=[], ... + ProjectSchema=project, Workbench=layout); +end + +function project = createSourceProject() +project = struct("inputs", struct("sources", struct([]))); +end + +function accepted = validateSourceProject(project) +accepted = isstruct(project) && isscalar(project) && ... + isfield(project, "inputs") && isstruct(project.inputs) && ... + isscalar(project.inputs) && isfield(project.inputs, "sources") && ... + isstruct(project.inputs.sources); +end + +function envelope = currentEnvelope(appId, payloadVersion, payload) +envelope = struct("format", "labkit.project", ... + "formatVersion", struct("major", 1, "minor", 0), ... + "app", struct("id", appId, "payloadVersion", payloadVersion), ... + "document", struct("id", "document-id", ... + "createdAtUtc", "2026-01-01T00:00:00Z", ... + "modifiedAtUtc", "2026-01-01T00:00:00Z", "revision", uint64(0)), ... + "producer", struct("appVersion", "1.0.0"), ... + "payload", payload, "resume", struct("token", "from-file")); +end diff --git a/tests/cases/unit/labkit_framework/ui/UiResultWriterTest.m b/tests/cases/unit/labkit_framework/ui/UiResultWriterTest.m new file mode 100644 index 000000000..fbd624b38 --- /dev/null +++ b/tests/cases/unit/labkit_framework/ui/UiResultWriterTest.m @@ -0,0 +1,117 @@ +classdef UiResultWriterTest < matlab.unittest.TestCase + methods (Test, TestTags = {'Unit'}) + function writesVerifiedManifestAtomically(testCase) + setupLabKitTestPath(); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeText(fullfile(folder, "summary.csv"), "value\n1\n"); + result = labkit.app.result.Package( ... + Outputs={labkit.app.result.File( ... + "summary", "primary", "summary.csv", ... + MediaType="text/csv")}, ... + Inputs=struct("source", "synthetic"), ... + Parameters=struct("limit", 1), Summary=struct("rows", 1), ... + Warnings="reviewed", ManifestName="result.json"); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + testApplication()); + + written = runtime.writeResult(folder, result); + manifest = jsondecode(fileread(written.Value)); + + testCase.verifyFalse(written.Cancelled); + testCase.verifyEqual(string(manifest.format), "labkit.result"); + testCase.verifyEqual(string(manifest.app.id), "test.result"); + testCase.verifyEqual(string(manifest.app.version), "1.0.0"); + testCase.verifyEqual(string(manifest.run.status), "success"); + testCase.verifyFalse(isfield(manifest, "project")); + testCase.verifyEqual(manifest.outputs.bytes, 10); + testCase.verifyEqual(strlength(string(manifest.outputs.sha256)), 64); + testCase.verifyEqual(string(manifest.outputs.status), "success"); + testCase.verifyFalse(isfield(manifest, "extensions")); + testCase.verifyFalse(isfile(string(written.Value) + ".tmp")); + end + + function marksMissingDeclaredOutputFailedAndAggregatesPartial(testCase) + setupLabKitTestPath(); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeText(fullfile(folder, "present.txt"), "present"); + result = labkit.app.result.Package( ... + Outputs={ ... + labkit.app.result.File("present", "primary", "present.txt"), ... + labkit.app.result.File("missing", "secondary", "missing.txt")}, ... + Inputs=struct(), Parameters=struct(), Summary=struct()); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + testApplication()); + + written = runtime.writeResult(folder, result); + manifest = jsondecode(fileread(written.Value)); + + testCase.verifyEqual(string(manifest.run.status), "partial"); + testCase.verifyEqual(string({manifest.outputs.status}), ... + ["success", "failed"]); + testCase.verifyEqual(string(manifest.outputs(2).message), ... + "Output file was not found after export."); + testCase.verifyFalse(isfield(manifest, "project")); + end + + function preservesFailedAndSkippedDeclarations(testCase) + setupLabKitTestPath(); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeText(fullfile(folder, "present.txt"), "present"); + result = labkit.app.result.Package( ... + Outputs={ ... + labkit.app.result.File("present", "primary", "present.txt"), ... + labkit.app.result.File("failed", "aux", "failed.txt", ... + Status="failed", Message="export failed"), ... + labkit.app.result.File("skipped", "aux", "skip.txt", ... + Status="skipped", Message="not requested")}, ... + Inputs=struct(), Parameters=struct(), Summary=struct()); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + testApplication()); + + written = runtime.writeResult(folder, result); + manifest = jsondecode(fileread(written.Value)); + + testCase.verifyEqual(string(manifest.run.status), "partial"); + testCase.verifyEqual(string({manifest.outputs.status}), ... + ["success", "failed", "skipped"]); + testCase.verifyEqual(manifest.outputs(2).bytes, 0); + testCase.verifyEqual(string(manifest.outputs(3).sha256), ""); + end + + function marksAllMissingDeclaredOutputsFailed(testCase) + setupLabKitTestPath(); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + result = labkit.app.result.Package( ... + Outputs={labkit.app.result.File( ... + "missing", "primary", "missing.txt")}, ... + Inputs=struct(), Parameters=struct(), Summary=struct()); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + testApplication()); + + written = runtime.writeResult(folder, result); + manifest = jsondecode(fileread(written.Value)); + + testCase.verifyEqual(string(manifest.run.status), "failed"); + testCase.verifyEqual(string(manifest.outputs.status), "failed"); + end + end +end + +function app = testApplication() + app = labkit.app.Definition( ... + Entrypoint="labkit_ResultWriterTest_app", AppId="test.result", ... + Title="Result writer test", Family="Tests", AppVersion="1.0.0", ... + Updated="2026-07-19", Requirements=[], ... + Workbench=labkit.app.layout.workbench({})); +end + +function writeText(filepath, content) + file = fopen(filepath, "w"); + cleaner = onCleanup(@() fclose(file)); + fwrite(file, content, "char"); + clear cleaner +end diff --git a/tests/cases/unit/labkit_framework/ui/UiRuntimeContextContractTest.m b/tests/cases/unit/labkit_framework/ui/UiRuntimeContextContractTest.m new file mode 100644 index 000000000..e7b817044 --- /dev/null +++ b/tests/cases/unit/labkit_framework/ui/UiRuntimeContextContractTest.m @@ -0,0 +1,97 @@ +classdef UiRuntimeContextContractTest < matlab.unittest.TestCase + methods (Test, TestTags = {'Unit'}) + function noCapabilityContextRejectsOperations(testCase) + setupLabKitTestPath(); + context = ... + labkit.app.internal.CallbackContextFactory.disconnected(); + + testCase.verifyTrue(meta.class.fromName( ... + "labkit.app.CallbackContext").Sealed); + testCase.verifyError(@() context.alert("message", "title"), ... + "labkit:app:runtime:InvariantFailure"); + end + + function namedOperationsUseOneSealedBackend(testCase) + setupLabKitTestPath(); + store = containers.Map("KeyType", "char", "ValueType", "any"); + backend = struct( ... + "appendStatus", @appendStatus, ... + "choose", @choose, ... + "chooseInputFolder", @chooseFolder, ... + "chooseOutputFolder", @chooseFolder, ... + "sourcePaths", @sourcePaths, ... + "setResource", @setResource, ... + "getResource", @getResource, ... + "removeResource", @removeResource, ... + "clearResourceScope", @clearResourceScope); + context = labkit.app.internal.CallbackContextFactory.create(backend); + context.appendStatus("ready"); + choice = context.chooseOption("Continue?", ["yes", "no"], ... + Title="Continue operation?", DefaultChoice="no", ... + CancelChoice="no"); + inputFolder = context.chooseInputFolder("input"); + outputFolder = context.chooseOutputFolder("output"); + context.setResource("document", "reader", 42, []); + value = context.getResource("document", "reader"); + paths = context.resolveSourcePaths(struct(), ["first", "second"]); + + testCase.verifyEqual(choice.Value, "yes"); + testCase.verifyEqual(store("choiceTitle"), ... + "Continue operation?"); + testCase.verifyEqual(store("choiceDefault"), "no"); + testCase.verifyEqual(store("choiceCancel"), "no"); + testCase.verifyError(@() context.chooseOption( ... + "Continue?", ["yes", "no"], DefaultChoice="later"), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyError(@() context.chooseOption( ... + "Continue?", ["yes", "yes"]), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyEqual(inputFolder.Value, "input/selected"); + testCase.verifyEqual(outputFolder.Value, "output/selected"); + testCase.verifyEqual(value, 42); + testCase.verifyEqual(store("status"), "ready"); + testCase.verifyEqual(paths, ["first"; "second"] + ".dat"); + testCase.verifyError(@() context.reportError( ... + "operation", MException("probe:error", "failure")), ... + "labkit:app:runtime:InvariantFailure"); + function appendStatus(message) + store("status") = message; + end + + function result = choose(~, choices, title, ... + defaultChoice, cancelChoice) + store("choiceTitle") = title; + store("choiceDefault") = defaultChoice; + store("choiceCancel") = cancelChoice; + result = labkit.app.dialog.Choice(choices(1)); + end + + function result = chooseFolder(startPath) + result = labkit.app.dialog.Choice( ... + string(startPath) + "/selected"); + end + + function paths = sourcePaths(~, ids) + paths = ids + ".dat"; + end + + function setResource(scope, id, resource, ~) + store(char(scope + ":" + id)) = resource; + end + + function resource = getResource(scope, id) + resource = store(char(scope + ":" + id)); + end + + function removeResource(scope, id) + remove(store, char(scope + ":" + id)); + end + + function clearResourceScope(scope) + keys = string(store.keys); + selected = startsWith(keys, scope + ":"); + remove(store, cellstr(keys(selected))); + end + end + end +end diff --git a/tests/cases/unit/labkit_framework/ui/UiRuntimeKernelTest.m b/tests/cases/unit/labkit_framework/ui/UiRuntimeKernelTest.m new file mode 100644 index 000000000..f46160663 --- /dev/null +++ b/tests/cases/unit/labkit_framework/ui/UiRuntimeKernelTest.m @@ -0,0 +1,337 @@ +classdef UiRuntimeKernelTest < matlab.unittest.TestCase + methods (Test, TestTags = {'Unit'}) + function invokesLayoutCallbackAndCommitsState(testCase) + setupLabKitTestPath(); + app = counterApplication(@incrementValue); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + + runtime.invokeAction("increment"); + + testCase.verifyEqual(runtime.State.project.value, 1); + testCase.verifyEqual( ... + runtime.StatusLog, ["Ready.", "incremented"]); + testCase.verifyEqual(runtime.commitCount(), 2); + end + + function invokesOnStartAfterFirstPresentation(testCase) + setupLabKitTestPath(); + app = counterApplication(@incrementValue, OnStart=@markStarted); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + + testCase.verifyTrue(runtime.State.session.started); + testCase.verifyEqual(runtime.commitCount(), 2); + end + + function rollsBackStateAndPresentationOnCommitFailure(testCase) + setupLabKitTestPath(); + app = counterApplication(@incrementValue); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + before = runtime.State; + runtime.failNextCommit(); + + testCase.verifyError(@() runtime.invokeAction("increment"), ... + "labkit:app:runtime:ActionFailed"); + testCase.verifyEqual(runtime.State, before); + testCase.verifyEqual(runtime.commitCount(), 1); + end + + function disposesReplacementEventAndApplicationResourcesOnce(testCase) + setupLabKitTestPath(); + counts = containers.Map({'event', 'application'}, [0, 0]); + app = counterApplication(@setResources); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + runtime.invokeAction("increment"); + runtime.close(); + runtime.close(); + + testCase.verifyEqual(counts("event"), 2); + testCase.verifyEqual(counts("application"), 1); + + function state = setResources(state, context) + context.setResource("event", "temporary", 1, ... + @(~) incrementCount("event")); + context.setResource("event", "temporary", 2, ... + @(~) incrementCount("event")); + context.setResource("application", "reader", 3, ... + @(~) incrementCount("application")); + end + + function incrementCount(name) + counts(name) = counts(name) + 1; + end + end + + function cleanupFailureDoesNotSkipRemainingResources(testCase) + setupLabKitTestPath(); + counts = containers.Map({'failed', 'remaining'}, [0, 0]); + app = counterApplication(@setResources); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + runtime.invokeAction("increment"); + + testCase.verifyError(@() runtime.close(), ... + "labkit:app:runtime:ResourceCleanupFailed"); + testCase.verifyEqual(counts("failed"), 1); + testCase.verifyEqual(counts("remaining"), 1); + + function state = setResources(state, context) + context.setResource("application", "failure", 1, ... + @(~) failCleanup()); + context.setResource("application", "remaining", 2, ... + @(~) incrementCount("remaining")); + end + + function failCleanup() + incrementCount("failed"); + error("labkit:test:InjectedCleanupFailure", ... + "Injected cleanup failure."); + end + + function incrementCount(name) + counts(name) = counts(name) + 1; + end + end + + function boundFieldNeedsNoCallbackOrPresenter(testCase) + setupLabKitTestPath(); + project = labkit.app.project.Schema( ... + Version=1, Create=@createBoundProject, ... + Validate=@validateBoundProject); + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.field("threshold", Kind="numeric", ... + Bind="project.parameters.threshold")}); + app = labkit.app.Definition( ... + Entrypoint="labkit_BoundProbe_app", AppId="probe.bound", ... + Title="Bound probe", Family="Tests", AppVersion="1.0.0", ... + Updated="2026-07-19", Requirements=[], ... + ProjectSchema=project, Workbench=layout); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + + runtime.applyBinding("threshold", 2.5); + + testCase.verifyEqual( ... + runtime.State.project.parameters.threshold, 2.5); + testCase.verifyEqual(runtime.commitCount(), 2); + testCase.verifyError(@() labkit.app.layout.field( ... + "bad", Bind="project.values(1)"), ... + "labkit:app:contract:InvalidValue"); + end + + function dispatchesTypedTableEditAndCellSelection(testCase) + setupLabKitTestPath(); + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.dataTable("data", ... + Columns=["Group", "Value"], ... + ColumnEditable=[true true], ... + OnCellEdited=@editTable, ... + OnCellSelectionChanged=@selectCells)}); + app = labkit.app.Definition( ... + Entrypoint="labkit_TableProbe_app", AppId="probe.table", ... + Title="Table probe", Family="Tests", AppVersion="1.0.0", ... + Updated="2026-07-19", Requirements=[], Workbench=layout, ... + CreateSession=@createTableSession, ... + PresentWorkbench=@presentTable); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + data = {"A", 1; "B", 2}; + + runtime.applyTableEdit("data", labkit.app.event.TableCellEdit( ... + RowIndex=2, ColumnIndex=2, PreviousValue=2, ... + NewValue=3, Data=data)); + runtime.applyTableSelection("data", [1 1; 2 2]); + + testCase.verifyEqual(runtime.State.session.data, data); + testCase.verifyEqual(runtime.State.session.cells, [1 1; 2 2]); + end + + function sharedSourceBindingKeepsFileListsSeparatedByRole(testCase) + setupLabKitTestPath(); + project = labkit.app.project.Schema( ... + Version=1, Create=@createSourceProject, ... + Validate=@validateSourceProject); + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.fileList("reference", ... + Bind="project.inputs.sources", ... + SourceRole="reference", SourceIdPrefix="reference", ... + MaxFiles=1, SelectionMode="single"), ... + labkit.app.layout.fileList("mask", ... + Bind="project.inputs.sources", ... + SourceRole="mask", SourceIdPrefix="mask", ... + MaxFiles=1, SelectionMode="single")}); + app = labkit.app.Definition( ... + Entrypoint="labkit_SourceProbe_app", ... + AppId="probe.sources", Title="Source probe", ... + Family="Tests", AppVersion="1.0.0", ... + Updated="2026-07-19", Requirements=[], ... + ProjectSchema=project, Workbench=layout); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + + runtime.applyFileSelection("reference", "reference.png"); + runtime.applyFileSelection("mask", "mask.png"); + + sources = runtime.State.project.inputs.sources; + testCase.verifyEqual(string({sources.role}), ... + ["reference", "mask"]); + testCase.verifyEqual(string({sources.id}), ... + ["reference-1", "mask-1"]); + end + + function taskFileListPreservesRepeatedPortablePaths(testCase) + setupLabKitTestPath(); + project = labkit.app.project.Schema( ... + Version=1, Create=@createSourceProject, ... + Validate=@validateSourceProject); + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.fileList("tasks", ... + Bind="project.inputs.sources", ... + SourceRole="task", SourceIdPrefix="task", ... + AllowDuplicatePaths=true)}); + app = labkit.app.Definition( ... + Entrypoint="labkit_TaskSourceProbe_app", ... + AppId="probe.task-sources", Title="Task source probe", ... + Family="Tests", AppVersion="1.0.0", ... + Updated="2026-07-20", Requirements=[], ... + ProjectSchema=project, Workbench=layout); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + + runtime.applyFileSelection( ... + "tasks", ["shared.png", "shared.png"], [1 2]); + sources = runtime.State.project.inputs.sources; + testCase.verifyEqual(string({sources.id}), ... + ["task-1", "task-2"]); + sourcePaths = string(arrayfun( ... + @(source) source.reference.originalPath, sources, ... + "UniformOutput", false)); + testCase.verifyEqual(reshape(sourcePaths, 1, []), ... + ["shared.png", "shared.png"]); + + runtime.removeFileSelection("tasks", 1); + sources = runtime.State.project.inputs.sources; + testCase.verifyEqual(numel(sources), 1); + testCase.verifyEqual(string(sources.id), "task-2"); + end + + function dispatchPreservesCellInteractionPayload(testCase) + setupLabKitTestPath(); + interaction = labkit.app.interaction.pairedAnchors( ... + "pairs", @storePointPairs, Axes=["left", "right"]); + workspace = labkit.app.layout.workspace( ... + labkit.app.layout.plotArea("preview", @drawNothing, ... + AxisIds=["left", "right"], ... + Interactions={interaction})); + app = labkit.app.Definition( ... + Entrypoint="labkit_PairProbe_app", AppId="probe.pairs", ... + Title="Pair probe", Family="Tests", ... + AppVersion="1.0.0", Updated="2026-07-19", ... + Requirements=[], CreateSession=@createPairSession, ... + Workbench=labkit.app.layout.workbench({}, ... + Workspace=workspace), ... + PresentWorkbench=@presentPairs); + runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + pairs = {[1 2; 3 4], [5 6; 7 8]}; + + runtime.applyInteraction( ... + "pairs", "interactionChanged", pairs); + + testCase.verifyEqual(runtime.State.session.pairs, pairs); + end + end +end + +function app = counterApplication(onPressed, varargin) +project = labkit.app.project.Schema( ... + Version=1, Create=@createProject, Validate=@validateProject); +app = labkit.app.Definition( ... + "Entrypoint", "labkit_Counter_app", "AppId", "probe.counter", ... + "Title", "Counter", "Family", "Tests", "AppVersion", "1.0.0", ... + "Updated", "2026-07-19", "Requirements", [], ... + "ProjectSchema", project, "CreateSession", @createSession, ... + "Workbench", labkit.app.layout.workbench({ ... + labkit.app.layout.field("value"), ... + labkit.app.layout.button("increment", "Increment", onPressed, ... + Tooltip="Increment the probe counter.")}), ... + "PresentWorkbench", @present, varargin{:}); +end + +function project = createProject() +project = struct("value", 0); +end + +function accepted = validateProject(project) +accepted = isstruct(project) && isscalar(project) && ... + isfield(project, "value") && isnumeric(project.value) && ... + isscalar(project.value) && isfinite(project.value); +end + +function session = createSession(~, ~) +session = struct("started", false); +end + +function view = present(state) +view = labkit.app.view.Snapshot().value("value", state.project.value); +end + +function state = incrementValue(state, context) +state.project.value = state.project.value + 1; +context.appendStatus("incremented"); +end + +function state = markStarted(state, ~) +state.session.started = true; +end + +function project = createBoundProject() +project = struct("parameters", struct("threshold", 1)); +end + +function accepted = validateBoundProject(project) +accepted = isstruct(project) && isscalar(project) && ... + isfield(project, "parameters") && ... + isstruct(project.parameters) && isscalar(project.parameters) && ... + isfield(project.parameters, "threshold") && ... + isnumeric(project.parameters.threshold) && ... + isscalar(project.parameters.threshold) && ... + isfinite(project.parameters.threshold); +end + +function project = createSourceProject() +project = struct("inputs", struct("sources", struct([]))); +end + +function accepted = validateSourceProject(project) +accepted = isstruct(project) && isscalar(project) && ... + isfield(project, "inputs") && isfield(project.inputs, "sources"); +end + +function session = createTableSession(~, ~) +session = struct("data", {cell(0, 2)}, "cells", zeros(0, 2)); +end + +function view = presentTable(state) +view = labkit.app.view.Snapshot().tableData( ... + "data", state.session.data, Columns=["Group", "Value"], ... + ColumnEditable=[true true]); +end + +function state = editTable(state, edit, ~) +state.session.data = edit.Data; +end + +function state = selectCells(state, selection, ~) +state.session.cells = selection.CellIndices; +end + +function session = createPairSession(~, ~) +session = struct("pairs", {{zeros(0, 2), zeros(0, 2)}}); +end + +function view = presentPairs(state) +view = labkit.app.view.Snapshot() ... + .renderPlot("preview", struct()) ... + .pairedAnchors("pairs", state.session.pairs, ImageSize=[10 10]); +end + +function state = storePointPairs(state, pairs, ~) +state.session.pairs = pairs; +end + +function drawNothing(~, ~) +end diff --git a/tests/runner/labkitPublicHelpContractDefects.m b/tests/runner/labkitPublicHelpContractDefects.m index e7f8b43f6..7fd6ceaad 100644 --- a/tests/runner/labkitPublicHelpContractDefects.m +++ b/tests/runner/labkitPublicHelpContractDefects.m @@ -1,7 +1,7 @@ function defects = labkitPublicHelpContractDefects(root, filepath) %LABKITPUBLICHELPCONTRACTDEFECTS Audit one public MATLAB help contract. % Expected caller: documentation guardrails. Inputs are the repository root -% and one public function file. Output is a string column of missing sections, +% and one public function or class file. Output is a string column of missing sections, % signature names, or option-field explanations. Side effects: reads source. [signature, helpLines] = publicFunctionHelp(filepath); @@ -23,7 +23,10 @@ end usage = helpSection(helpLines, "Usage"); - if ~any(contains(usage, symbol + "(")) || any(contains(usage, "function ")) + classApi = startsWith(strip(signature), "classdef"); + publicCall = any(contains(usage, symbol + "(")) || ... + (classApi && any(contains(usage, symbol + "."))); + if ~publicCall || any(contains(usage, "function ")) defects(end + 1, 1) = rel + ... " -> Usage must show a public call, not a source declaration"; end @@ -182,7 +185,16 @@ function [signature, helpLines] = publicFunctionHelp(filepath) lines = readlines(filepath, "EmptyLineRule", "read"); - first = find(startsWith(strtrim(lines), "function "), 1); + functionStart = find(startsWith(strtrim(lines), "function "), 1); + classStart = find(startsWith(strtrim(lines), "classdef"), 1); + starts = [functionStart, classStart]; + starts = starts(~isnan(starts) & starts > 0); + if isempty(starts) + error("LabKit:Docs:MissingDeclaration", ... + "Public API file has no function or class declaration: %s", ... + filepath); + end + first = min(starts); finish = first; while finish < numel(lines) && endsWith(strip(lines(finish)), "...") finish = finish + 1; @@ -219,6 +231,10 @@ end function names = signatureInputs(signature) + if startsWith(strip(signature), "classdef") + names = strings(0, 1); + return; + end token = regexp(char(signature), '\(([^)]*)\)', "tokens", "once"); if isempty(token) || strlength(strip(string(token{1}))) == 0 names = strings(0, 1); @@ -230,6 +246,10 @@ end function names = signatureOutputs(signature) + if startsWith(strip(signature), "classdef") + names = strings(0, 1); + return; + end left = extractBefore(string(signature), "="); if left == signature names = strings(0, 1); diff --git a/tests/runner/labkitValidationPlanForChangedPaths.m b/tests/runner/labkitValidationPlanForChangedPaths.m index 07d6ceb62..73f7f478a 100644 --- a/tests/runner/labkitValidationPlanForChangedPaths.m +++ b/tests/runner/labkitValidationPlanForChangedPaths.m @@ -77,12 +77,12 @@ packageName = erase(parts(2), "+"); switch packageName - case "ui" + case "app" steps = [ ... planStep("labkit_framework_ui", "labkit_framework/ui", true, ... - "Reason", "labkit.ui change needs reusable UI coverage"), ... + "Reason", "labkit.app change needs reusable App SDK coverage"), ... planStep("gui_apps", "gui/apps", true, ... - "Reason", "labkit.ui change can affect downstream app GUI contracts")]; + "Reason", "labkit.app change can affect downstream app GUI contracts")]; case "dta" steps = [ ... planStep("labkit_framework_dta", "labkit_framework/dta", false, ... @@ -400,18 +400,18 @@ function tests = fastRepresentativeAppGuiTests() tests = [ ... "image_enhance_workflow_applies_tool_and_exports", ... - "batch_crop_workflow_exports_synthetic_crop"]; + "cropTasksCenterAndExportSyntheticImages"]; end function tests = fastUiRepresentativeTests() tests = [ ... - "test_gui_layout_ui_declarative_app", ... - "controlled_interactions_suppress_programmatic_events", ... - "test_gui_layout_ui_debug_trace"]; + "reconcilesChronoLikeSemanticTree", ... + "nativeCallbacksUseTypedRuntimeEntrypoints", ... + "replacesChoicesWhenCurrentValueDisappears"]; end function tests = fastUiGestureRepresentativeTests() - tests = "controlled_region_selection_registers_transient_gesture"; + tests = "reconcilesManagedRectangleAndDispatchesDirectCallback"; end function mode = parseMode(varargin) diff --git a/tests/shared/architectureTestHelpers.m b/tests/shared/architectureTestHelpers.m index 3d23a70fe..14690b8ef 100644 --- a/tests/shared/architectureTestHelpers.m +++ b/tests/shared/architectureTestHelpers.m @@ -31,8 +31,7 @@ [appName ' should expose one public app entry-point source file.']); assertUsesGuiFoundation(appOwnedSource, appName); delegatesDefinitionToLaunch = ... - contains(appSource, 'labkit.ui.runtime.launch(') && ... - contains(appSource, '.definition'); + contains(appSource, '.definition().launch('); assert(delegatesDefinitionToLaunch, ... [appName ' should delegate product metadata and launch behavior ' ... 'through its single definition.']); @@ -177,8 +176,8 @@ function assertGaitAppBoundary(source, appName) end function assertUsesGuiFoundation(source, appName) - assert(contains(source, 'labkit.ui.runtime.launch('), ... - [appName ' should build from the reusable GUI foundation.']); + assert(contains(source, 'labkit.app.Definition('), ... + [appName ' should build from the reusable App SDK foundation.']); end function assertTopLevelMFiles(packageDir, expectedFiles, label) @@ -219,7 +218,7 @@ function assertPackageSourcesDoNotContain(packageDir, forbiddenWords, label) function assertAppUsesManagedImageInteractions(source, appName) assert(~contains(source, 'WindowScrollWheelFcn'), ... - [appName ' should declare image navigation through Runtime V2.']); + [appName ' should declare image navigation through the App SDK.']); assert(~contains(source, 'WindowButtonMotionFcn') && ~contains(source, 'WindowButtonUpFcn'), ... [appName ' should not own image-tool drag callbacks directly.']); assert(~contains(source, '.ButtonDownFcn'), ... diff --git a/tests/shared/assertLauncherPackageCheckboxSelection.m b/tests/shared/assertLauncherPackageCheckboxSelection.m index e303103b6..876ed4684 100644 --- a/tests/shared/assertLauncherPackageCheckboxSelection.m +++ b/tests/shared/assertLauncherPackageCheckboxSelection.m @@ -1,7 +1,7 @@ function assertLauncherPackageCheckboxSelection(fig, guiHelpers) % Verify launcher package checkboxes update details and survive app refresh. - ui = getappdata(fig, 'labkitUiRegistry'); + ui = getappdata(fig, 'labkitLauncherView'); tableHandle = ui.controls.appTable.table; assert(tableHandle.ColumnEditable(1), ... 'Launcher Package column should be editable.'); diff --git a/tests/shared/debugSampleContext.m b/tests/shared/debugSampleContext.m new file mode 100644 index 000000000..dfda97089 --- /dev/null +++ b/tests/shared/debugSampleContext.m @@ -0,0 +1,4 @@ +function context = debugSampleContext(root) +%DEBUGSAMPLECONTEXT Typed filesystem context for App debug-sample tests. + context = labkit.app.diagnostic.SampleContext(string(root)); +end diff --git a/tests/shared/guiTestHelpers.m b/tests/shared/guiTestHelpers.m index ec6882959..77ff360e1 100644 --- a/tests/shared/guiTestHelpers.m +++ b/tests/shared/guiTestHelpers.m @@ -21,7 +21,6 @@ h.assertAnyTableColumns = @assertAnyTableColumns; h.assertFigureMinimumSize = @assertFigureMinimumSize; h.assertStartupSucceeded = @assertStartupSucceeded; - h.assertStandardWorkbenchLayout = @assertStandardWorkbenchLayout; h.findControlsByClass = @findControlsByClass; h.assertDropdownCallbacksPresent = @assertDropdownCallbacksPresent; h.invokeDropdownValue = @invokeDropdownValue; @@ -199,47 +198,10 @@ function assertFigureMinimumSize(fig, minWidth, minHeight) assert(pos(4) >= minHeight, 'Expected figure height >= %d, found %.0f.', minHeight, pos(4)); end -function assertStandardWorkbenchLayout(fig) - assertStartupSucceeded(fig); - assert(isappdata(fig, 'labkitUiRegistry'), ... - 'App should publish the shared LabKit workbench registry.'); - ui = getappdata(fig, 'labkitUiRegistry'); - required = {'figure', 'main', 'leftPanel', 'rightPanel'}; - assert(all(isfield(ui, required)) && isequal(ui.figure, fig), ... - 'App should expose the semantic LabKit workbench shell.'); -end - function assertStartupSucceeded(fig) - [settled, detail] = waitForCondition(fig, @() startupSettled(fig), 5.0); - if ~settled - error('LabKit:Tests:GuiStartupTimeout', ... - 'App startup did not settle. %s', waitDiagnostic(detail)); - end - if ~isappdata(fig, 'labkitUiStartup') - return; - end - state = getappdata(fig, 'labkitUiStartup'); - if isstruct(state) && isfield(state, 'failed') && logical(state.failed) - message = "Startup failed without a diagnostic message."; - if isfield(state, 'message') && strlength(string(state.message)) > 0 - message = string(state.message); - end - error('LabKit:Tests:GuiStartupFailed', '%s', char(message)); - end -end - -function tf = startupSettled(fig) - tf = false; - if ~isvalid(fig) - return; - end - if ~isappdata(fig, 'labkitUiStartup') - tf = true; - return; - end - state = getappdata(fig, 'labkitUiStartup'); - tf = isstruct(state) && isfield(state, 'failed') && ... - isscalar(state.failed) && logical(state.failed); + drawnow; + assert(isvalid(fig), ... + 'The synchronous App SDK launch did not leave a valid figure.'); end function controls = findControlsByClass(fig, classNamePart) diff --git a/tests/shared/labkitWorkflowDriver.m b/tests/shared/labkitWorkflowDriver.m deleted file mode 100644 index a57c16c65..000000000 --- a/tests/shared/labkitWorkflowDriver.m +++ /dev/null @@ -1,162 +0,0 @@ -function driver = labkitWorkflowDriver(fig) -%LABKITWORKFLOWDRIVER Semantic GUI workflow helper for LabKit app tests. -% -% Expected caller: hidden GUI workflow acceptance tests. Inputs are a LabKit -% app figure with labkitUiRegistry appdata. Output is a small struct of -% app-neutral operations for injecting filePanel choices, invoking controls, -% setting active tool anchors, and reading semantic UI state. The helper -% mutates only test-injected dialog providers, active LabKit UI tools, and GUI -% controls through their public callbacks. - - h = guiTestHelpers(); - driver = struct(); - driver.registry = @registry; - driver.chooseFiles = @chooseFiles; - driver.click = @(text) h.invokeButton(fig, text); - driver.dropdown = @(value) h.invokeDropdownValue(fig, value); - driver.checkbox = @(text, value) h.invokeCheckbox(fig, text, value); - driver.fileStatus = @fileStatus; - driver.fileListItems = @fileListItems; - driver.fileSelection = @fileSelection; - driver.selectFile = @selectFile; - driver.tableData = @tableData; - driver.textAreaValue = @textAreaValue; - driver.logValue = @logValue; - driver.enabled = @enabled; - driver.previewChildCount = @previewChildCount; - driver.setAnchorPoints = @setAnchorPoints; - - function ui = registry() - assert(isappdata(fig, 'labkitUiRegistry'), ... - 'Workflow driver requires a LabKit UI registry on the app figure.'); - ui = getappdata(fig, 'labkitUiRegistry'); - end - - function chooseFiles(panelId, paths) - ui = registry(); - id = char(string(panelId)); - assert(isfield(ui.controls, id), ... - 'Workflow filePanel id not found: %s.', id); - ui.controls.(id).props.dialogProvider = @(~) string(paths); - setappdata(fig, 'labkitUiRegistry', ui); - end - - function text = fileStatus(panelId) - control = filePanel(panelId); - text = string(control.status.Value); - end - - function items = fileListItems(panelId) - control = filePanel(panelId); - items = string(control.listbox.Items); - end - - function value = fileSelection(panelId) - control = filePanel(panelId); - value = string(control.listbox.Value); - end - - function selectFile(panelId, pathOrLabel) - control = filePanel(panelId); - labels = string(control.listbox.Items); - match = find(contains(labels, string(pathOrLabel)), 1, 'first'); - assert(~isempty(match), ... - 'Workflow filePanel %s has no item matching %s.', ... - char(string(panelId)), char(string(pathOrLabel))); - control.listbox.Value = control.listbox.Items{match}; - callback = control.listbox.ValueChangedFcn; - assert(isa(callback, 'function_handle'), ... - 'Workflow filePanel %s has no selection callback.', ... - char(string(panelId))); - callback(control.listbox, struct()); - drawnow; - end - - function value = textAreaValue(controlId) - ui = registry(); - id = char(string(controlId)); - assert(isfield(ui.controls, id) && isfield(ui.controls.(id), 'textArea'), ... - 'Workflow text area id not found: %s.', id); - value = ui.controls.(id).textArea.Value; - end - - function value = logValue(controlId) - value = textAreaValue(controlId); - end - - function data = tableData(controlId) - ui = registry(); - id = char(string(controlId)); - assert(isfield(ui.controls, id) && isfield(ui.controls.(id), 'table'), ... - 'Workflow table id not found: %s.', id); - data = ui.controls.(id).table.Data; - end - - function tf = enabled(controlId) - control = semanticControl(controlId); - handles = controlHandles(control); - enabledValues = false(size(handles)); - for k = 1:numel(handles) - enabledValues(k) = isprop(handles{k}, 'Enable') && ... - strcmp(char(handles{k}.Enable), 'on'); - end - tf = any(enabledValues); - end - - function n = previewChildCount(controlId) - control = semanticControl(controlId); - assert(isfield(control, 'primaryAxes'), ... - 'Workflow preview id not found: %s.', char(string(controlId))); - n = numel(control.primaryAxes.Children); - end - - function setAnchorPoints(controlId, points) - control = semanticControl(controlId); - assert(isfield(control, 'primaryAxes'), ... - 'Workflow preview id not found: %s.', char(string(controlId))); - ax = control.primaryAxes; - key = 'labkit_ui_activeAnchorEditor'; - assert(isappdata(ax, key), ... - 'No active LabKit anchor editor is registered for preview %s.', ... - char(string(controlId))); - registered = getappdata(ax, key); - assert(isstruct(registered) && isfield(registered, 'editor'), ... - 'Registered anchor editor for preview %s is malformed.', ... - char(string(controlId))); - editor = registered.editor; - assert(isstruct(editor) && isfield(editor, 'setPoints') && ... - isa(editor.setPoints, 'function_handle'), ... - 'Registered anchor editor for preview %s does not support setPoints.', ... - char(string(controlId))); - editor.setPoints(points); - end - - function control = filePanel(panelId) - control = semanticControl(panelId); - assert(isfield(control, 'status') && isfield(control, 'listbox'), ... - 'Control %s is not a multi-file filePanel.', char(string(panelId))); - end - - function control = semanticControl(controlId) - ui = registry(); - id = char(string(controlId)); - assert(isfield(ui.controls, id), ... - 'Workflow control id not found: %s.', id); - control = ui.controls.(id); - end - - function handles = controlHandles(control) - names = fieldnames(control); - handles = cell(1, numel(names)); - count = 0; - for k = 1:numel(names) - value = control.(names{k}); - if isscalar(value) && isobject(value) && isvalid(value) && ... - isprop(value, 'Enable') - count = count + 1; - handles{count} = value; - end - end - handles = handles(1:count); - end -end diff --git a/tools/docs/private/loadLabKitDocumentation.m b/tools/docs/private/loadLabKitDocumentation.m index b0da32924..82fb1a1a4 100644 --- a/tools/docs/private/loadLabKitDocumentation.m +++ b/tools/docs/private/loadLabKitDocumentation.m @@ -165,7 +165,7 @@ id = "framework"; kind = "explanation"; nav = "App Framework"; - components = "labkit.ui"; + components = "labkit.app"; elseif startsWith(source, "framework/") group = pathGroup(source, "framework"); if group == "Guides" @@ -222,11 +222,11 @@ function components = frameworkComponents(source) if contains(source, "/runtime") - components = "labkit.ui.runtime"; + components = "labkit.app"; elseif contains(source, "/contracts") components = "labkit.contract"; else - components = "labkit.ui"; + components = "labkit.app"; end end @@ -450,6 +450,12 @@ if contains(filepath, filesep + "private" + filesep) continue; end + if contains(filepath, filesep + "@") + continue; + end + if isHiddenClassFile(filepath) + continue; + end item = readApiItem(repoRoot, filepath, "library", "labkit"); api(end + 1, 1) = item; end @@ -457,6 +463,13 @@ api = api(order); end +function tf = isHiddenClassFile(filepath) + lines = strip(readlines(filepath, "EmptyLineRule", "skip")); + lines = lines(~startsWith(lines, "%")); + tf = ~isempty(lines) && startsWith(lines(1), "classdef") && ... + contains(lines(1), "Hidden"); +end + function api = discoverAppPublicApi(repoRoot, apps) entries = dir(fullfile(repoRoot, "apps", "**", "*.m")); api = repmat(emptyApi(), 0, 1); @@ -516,10 +529,19 @@ symbol = strjoin([packageParts; functionName], "."); lines = readlines(filepath, "EmptyLineRule", "read"); - start = find(startsWith(strtrim(lines), "function"), 1); + functionStart = find(startsWith(strtrim(lines), "function"), 1); + classStart = find(startsWith(strtrim(lines), "classdef"), 1); + starts = [functionStart, classStart]; + starts = starts(~isnan(starts) & starts > 0); + if isempty(starts) + start = []; + else + start = min(starts); + end if isempty(start) - error("LabKit:Docs:MissingFunction", ... - "Public API file has no function declaration: %s", relative); + error("LabKit:Docs:MissingDeclaration", ... + "Public API file has no function or class declaration: %s", ... + relative); end finish = start; while finish < numel(lines) && endsWith(strip(lines(finish)), "...") diff --git a/tools/docs/private/renderLabKitApiIndex.m b/tools/docs/private/renderLabKitApiIndex.m index 0fc78471f..68996fc0d 100644 --- a/tools/docs/private/renderLabKitApiIndex.m +++ b/tools/docs/private/renderLabKitApiIndex.m @@ -79,18 +79,26 @@ switch key case "labkit.contract" title = "Framework Compatibility (labkit.contract)"; - case "labkit.ui.runtime" - title = "Framework Runtime (labkit.ui.runtime)"; - case "labkit.ui.layout" - title = "Framework Layout (labkit.ui.layout)"; - case "labkit.ui.plot" - title = "Framework Plot (labkit.ui.plot)"; - case "labkit.ui.interaction" - title = "Framework Interaction (labkit.ui.interaction)"; - case "labkit.ui.debug" - title = "Framework Debug (labkit.ui.debug)"; - case "labkit.ui" - title = "Framework Version (labkit.ui)"; + case "labkit.app" + title = "App SDK Core (labkit.app)"; + case "labkit.app.diagnostic" + title = "App SDK Diagnostics (labkit.app.diagnostic)"; + case "labkit.app.dialog" + title = "App SDK Dialogs (labkit.app.dialog)"; + case "labkit.app.event" + title = "App SDK Events (labkit.app.event)"; + case "labkit.app.interaction" + title = "App SDK Interactions (labkit.app.interaction)"; + case "labkit.app.layout" + title = "App SDK Layout (labkit.app.layout)"; + case "labkit.app.plot" + title = "App SDK Plot Mechanics (labkit.app.plot)"; + case "labkit.app.project" + title = "App SDK Projects (labkit.app.project)"; + case "labkit.app.result" + title = "App SDK Results (labkit.app.result)"; + case "labkit.app.view" + title = "App SDK Views (labkit.app.view)"; otherwise title = key; end @@ -100,18 +108,26 @@ switch key case "labkit.contract" description = "Version and MathWorks-product requirement contracts."; - case "labkit.ui.runtime" - description = "App definition, launch, lifecycle, busy work, persistence, and portable file references."; - case "labkit.ui.layout" - description = "Data-only workbench, panel, field, action, and results specifications."; - case "labkit.ui.plot" - description = "Axes presentation, fit, clearing, messages, and coordinate helpers."; - case "labkit.ui.interaction" - description = "Managed anchors, popouts, and scale-bar geometry."; - case "labkit.ui.debug" - description = "Runtime debug context exposed to app callbacks."; - case "labkit.ui" - description = "UI facade version metadata."; + case "labkit.app" + description = "App identity, launch, callback capabilities, and facade version metadata."; + case "labkit.app.diagnostic" + description = "Sanitized standard and verbose runtime diagnostic contracts."; + case "labkit.app.dialog" + description = "Typed outcomes for native user-choice and path dialogs."; + case "labkit.app.event" + description = "Typed semantic callback payloads independent of native controls."; + case "labkit.app.interaction" + description = "Managed plot gestures, editors, and typed interaction payloads."; + case "labkit.app.layout" + description = "Semantic controls, containers, workspaces, callbacks, and renderers."; + case "labkit.app.plot" + description = "Domain-neutral axes clearing, fitting, messages, and annotation mechanics."; + case "labkit.app.project" + description = "Durable project schemas, portable sources, and synthetic sample contracts."; + case "labkit.app.result" + description = "Validated result files and package manifest requests."; + case "labkit.app.view" + description = "Complete immutable visible-state snapshots."; case "labkit.image" description = "Generic image file IO, normalization, resizing, filtering, and enhancement primitives."; case "labkit.thermal" diff --git a/tools/docs/private/renderLabKitPage.m b/tools/docs/private/renderLabKitPage.m index 2d8b23986..d7ded8090 100644 --- a/tools/docs/private/renderLabKitPage.m +++ b/tools/docs/private/renderLabKitPage.m @@ -161,8 +161,10 @@ packagePrefix = strjoin(parts(1:end - 1), "."); if packagePrefix == "labkit.contract" groupTitle = "Framework Compatibility"; - elseif startsWith(packagePrefix, "labkit.ui") - groupTitle = "Framework " + titleCasePackage(parts(end - 1)); + elseif packagePrefix == "labkit.app" + groupTitle = "App SDK Core"; + elseif startsWith(packagePrefix, "labkit.app.") + groupTitle = "App SDK " + titleCasePackage(parts(end - 1)); end html = localSubgroup(groupTitle, strjoin(links, "")); end diff --git a/tools/docs/private/renderLabKitPageApiLinks.m b/tools/docs/private/renderLabKitPageApiLinks.m index 8db28defb..04c95b24f 100644 --- a/tools/docs/private/renderLabKitPageApiLinks.m +++ b/tools/docs/private/renderLabKitPageApiLinks.m @@ -10,15 +10,11 @@ owner = replace(extractAfter(page.id, "app-"), "-", "_"); matches = string({api.origin}).' == "app" & ... string({api.owner}).' == owner; - elseif ~isempty(page.components) - components = string(page.components(:)); + else + sourceText = string(fileread(page.sourcePath)); symbols = string({api.symbol}).'; - for k = 1:numel(components) - component = components(k); - if startsWith(component, "labkit.") - matches = matches | symbols == component | ... - startsWith(symbols, component + "."); - end + for k = 1:numel(symbols) + matches(k) = contains(sourceText, symbols(k)); end end items = api(matches);