diff --git a/+labkit/+app/+dialog/Choice.m b/+labkit/+app/+dialog/Choice.m index 2badbb7e1..76702402c 100644 --- a/+labkit/+app/+dialog/Choice.m +++ b/+labkit/+app/+dialog/Choice.m @@ -36,7 +36,7 @@ methods function obj = Choice(value, varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.dialog.Choice", "Cancelled", varargin{:}); cancelled = false; if isfield(options, "Cancelled") diff --git a/+labkit/+app/+event/IntervalScroll.m b/+labkit/+app/+event/IntervalScroll.m index 25784b233..08cfa8099 100644 --- a/+labkit/+app/+event/IntervalScroll.m +++ b/+labkit/+app/+event/IntervalScroll.m @@ -34,7 +34,7 @@ methods function obj = IntervalScroll(varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.event.IntervalScroll", ... ["Anchor", "Count"], varargin{:}); for name = ["Anchor", "Count"] diff --git a/+labkit/+app/+event/ListSelection.m b/+labkit/+app/+event/ListSelection.m index 77bdc67fb..b6ef3e798 100644 --- a/+labkit/+app/+event/ListSelection.m +++ b/+labkit/+app/+event/ListSelection.m @@ -37,7 +37,7 @@ methods function obj = ListSelection(varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.event.ListSelection", ["Ids", "Indices"], varargin{:}); obj.Ids = ids(optionValue(options, "Ids", strings(1, 0))); obj.Indices = indices( ... diff --git a/+labkit/+app/+event/TableCellEdit.m b/+labkit/+app/+event/TableCellEdit.m index 5190efac0..630fb3852 100644 --- a/+labkit/+app/+event/TableCellEdit.m +++ b/+labkit/+app/+event/TableCellEdit.m @@ -52,7 +52,7 @@ function obj = TableCellEdit(varargin) names = ["RowId", "RowIndex", "ColumnId", "ColumnIndex", ... "PreviousValue", "NewValue", "Data"]; - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.event.TableCellEdit", names, varargin{:}); for name = ["RowIndex", "ColumnIndex", ... "PreviousValue", "NewValue"] diff --git a/+labkit/+app/+interaction/anchorPath.m b/+labkit/+app/+interaction/anchorPath.m index 19472a247..014a7bd73 100644 --- a/+labkit/+app/+interaction/anchorPath.m +++ b/+labkit/+app/+interaction/anchorPath.m @@ -7,6 +7,10 @@ % Description: % Creates the semantic declaration for a managed multi-anchor path editor; % the runtime owns native graphics, viewport preservation, and dispatch. +% On an open path, a point placed beyond the visible start is prepended, a +% point beyond the visible end is appended, and all other points are inserted +% after the nearest visible curve segment. This ordering is independent of +% the current axes zoom. % % Inputs: % id - Unique MATLAB identifier for this interaction. @@ -34,7 +38,7 @@ end function spec = makeSpec(kind, id, callback, names, varargin) -options = labkit.app.internal.OptionParser.parse( ... +options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.interaction." + kind, names, varargin{:}); -spec = labkit.app.internal.InteractionSpec(kind, id, callback, options); +spec = labkit.app.internal.interaction.InteractionSpec(kind, id, callback, options); end diff --git a/+labkit/+app/+interaction/interval.m b/+labkit/+app/+interaction/interval.m index 3ef6720db..22b0bab94 100644 --- a/+labkit/+app/+interaction/interval.m +++ b/+labkit/+app/+interaction/interval.m @@ -31,9 +31,9 @@ % % See also labkit.app.layout.plotArea, % labkit.app.event.IntervalScroll -options = labkit.app.internal.OptionParser.parse( ... +options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.interaction.interval", ... ["Axis", "Style", "Instruction", "ViewportPolicy", "OnScrolled"], ... varargin{:}); -spec = labkit.app.internal.InteractionSpec("interval",id,onChanged,options); +spec = labkit.app.internal.interaction.InteractionSpec("interval",id,onChanged,options); end diff --git a/+labkit/+app/+interaction/pairedAnchors.m b/+labkit/+app/+interaction/pairedAnchors.m index 301a123a1..70959a53a 100644 --- a/+labkit/+app/+interaction/pairedAnchors.m +++ b/+labkit/+app/+interaction/pairedAnchors.m @@ -28,13 +28,13 @@ % spec = labkit.app.interaction.pairedAnchors("matches",@changeMatches); % % See also labkit.app.layout.plotArea -options = labkit.app.internal.OptionParser.parse( ... +options = labkit.app.internal.contract.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( ... +spec = labkit.app.internal.interaction.InteractionSpec( ... "pairedAnchors", id, onChanged, options); end diff --git a/+labkit/+app/+interaction/pointSlots.m b/+labkit/+app/+interaction/pointSlots.m index 45f3d5dd5..eeb206bad 100644 --- a/+labkit/+app/+interaction/pointSlots.m +++ b/+labkit/+app/+interaction/pointSlots.m @@ -28,8 +28,8 @@ % spec = labkit.app.interaction.pointSlots("markers",@changeMarkers); % % See also labkit.app.layout.plotArea -options = labkit.app.internal.OptionParser.parse( ... +options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.interaction.pointSlots", ... ["Axis", "Style", "Instruction", "ViewportPolicy"], varargin{:}); -spec = labkit.app.internal.InteractionSpec("pointSlots",id,onChanged,options); +spec = labkit.app.internal.interaction.InteractionSpec("pointSlots",id,onChanged,options); end diff --git a/+labkit/+app/+interaction/rectangle.m b/+labkit/+app/+interaction/rectangle.m index 4c252a802..40adc5305 100644 --- a/+labkit/+app/+interaction/rectangle.m +++ b/+labkit/+app/+interaction/rectangle.m @@ -32,9 +32,9 @@ % spec = labkit.app.interaction.rectangle("crop",@moveCrop); % % See also labkit.app.layout.plotArea -options = labkit.app.internal.OptionParser.parse( ... +options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.interaction.rectangle", ... ["Axis", "Style", "Instruction", "ViewportPolicy", ... "OnBackgroundPressed"], varargin{:}); -spec = labkit.app.internal.InteractionSpec("rectangle",id,onChanged,options); +spec = labkit.app.internal.interaction.InteractionSpec("rectangle",id,onChanged,options); end diff --git a/+labkit/+app/+interaction/regionSelection.m b/+labkit/+app/+interaction/regionSelection.m index b4238f2bf..18e97a0b3 100644 --- a/+labkit/+app/+interaction/regionSelection.m +++ b/+labkit/+app/+interaction/regionSelection.m @@ -31,10 +31,10 @@ % OnBackgroundPressed=@measurePoint); % % See also labkit.app.layout.plotArea -options = labkit.app.internal.OptionParser.parse( ... +options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.interaction.regionSelection", ... ["Axis", "Style", "Instruction", "ViewportPolicy", ... "OnBackgroundPressed"], varargin{:}); -spec = labkit.app.internal.InteractionSpec( ... +spec = labkit.app.internal.interaction.InteractionSpec( ... "regionSelection",id,onSelected,options); end diff --git a/+labkit/+app/+interaction/scaleReference.m b/+labkit/+app/+interaction/scaleReference.m index f636d15e4..e7372cfb2 100644 --- a/+labkit/+app/+interaction/scaleReference.m +++ b/+labkit/+app/+interaction/scaleReference.m @@ -28,9 +28,9 @@ % spec = labkit.app.interaction.scaleReference("scale",@changeScale); % % See also labkit.app.layout.plotArea -options = labkit.app.internal.OptionParser.parse( ... +options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.interaction.scaleReference", ... ["Axis", "Style", "Instruction", "ViewportPolicy"], varargin{:}); -spec = labkit.app.internal.InteractionSpec( ... +spec = labkit.app.internal.interaction.InteractionSpec( ... "scaleReference",id,onChanged,options); end diff --git a/+labkit/+app/+internal/+artifact/Store.m b/+labkit/+app/+internal/+artifact/Store.m new file mode 100644 index 000000000..134bddbdd --- /dev/null +++ b/+labkit/+app/+internal/+artifact/Store.m @@ -0,0 +1,63 @@ +classdef (Hidden, Sealed) Store + % Own App-specific artifact naming and repository scratch destinations. + % Caller: RuntimeKernel. Inputs are domain-neutral artifact tokens; output + % paths always remain below the checkout's ignored artifacts directory. + + properties (Access = private) + AppId (1, 1) string + end + + methods + function obj = Store(appId) + obj.AppId = artifactToken(appId, "App ID"); + end + + function destination = destination(obj, category, stem, extension) + category = artifactToken(category, "category"); + filename = obj.filename(stem, extension); + folder = artifactFolder(category); + if exist(char(folder), "dir") ~= 7 + [created, message] = mkdir(char(folder)); + if ~created + error("labkit:app:runtime:ArtifactWriteFailed", ... + "Could not create the LabKit artifacts folder: %s", ... + message); + end + end + destination = fullfile(folder, filename); + end + + function filename = filename(obj, stem, extension) + stem = artifactToken(stem, "stem"); + extension = string(extension); + if ~isscalar(extension) || ... + isempty(regexp(char(extension), ... + '^\.[a-z0-9]+$', "once")) + error("labkit:app:runtime:InvariantFailure", ... + "Artifact extension must be a lowercase file extension."); + end + timestamp = string(datetime("now", TimeZone="UTC", ... + Format="yyyyMMdd-HHmmss")); + nonce = extractBefore( ... + string(java.util.UUID.randomUUID()), 9); + filename = "labkit-" + stem + "-" + obj.AppId + "-" + ... + timestamp + "-" + nonce + extension; + end + end +end + +function folder = artifactFolder(category) +versionPath = string(which("labkit.app.version")); +root = string(fileparts(fileparts(fileparts(versionPath)))); +folder = fullfile(root, "artifacts", category); +end + +function value = artifactToken(value, label) +value = lower(strip(string(value))); +value = regexprep(value, "[^a-z0-9]+", "-"); +value = regexprep(value, "(^-+|-+$)", ""); +if ~isscalar(value) || strlength(value) == 0 + error("labkit:app:runtime:InvariantFailure", ... + "Artifact %s must contain letters or digits.", label); +end +end diff --git a/+labkit/+app/+internal/CompiledDefinition.m b/+labkit/+app/+internal/+contract/CompiledDefinition.m similarity index 97% rename from +labkit/+app/+internal/CompiledDefinition.m rename to +labkit/+app/+internal/+contract/CompiledDefinition.m index 4f2fb80ba..a89a64038 100644 --- a/+labkit/+app/+internal/CompiledDefinition.m +++ b/+labkit/+app/+internal/+contract/CompiledDefinition.m @@ -14,14 +14,14 @@ methods (Access = ?labkit.app.Definition) function obj = CompiledDefinition(layout, startCallback) - if ~isa(layout, "labkit.app.internal.LayoutNode") || ... + if ~isa(layout, "labkit.app.internal.contract.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( ... + onStart = labkit.app.internal.contract.SignalBinding( ... "application", "started", startCallback); end nodes = layout.flattenForCompiler(); diff --git a/+labkit/+app/+internal/DefinitionInspector.m b/+labkit/+app/+internal/+contract/DefinitionInspector.m similarity index 100% rename from +labkit/+app/+internal/DefinitionInspector.m rename to +labkit/+app/+internal/+contract/DefinitionInspector.m diff --git a/+labkit/+app/+internal/LayoutNode.m b/+labkit/+app/+internal/+contract/LayoutNode.m similarity index 51% rename from +labkit/+app/+internal/LayoutNode.m rename to +labkit/+app/+internal/+contract/LayoutNode.m index 7aa4d7dd7..ba77bc0a6 100644 --- a/+labkit/+app/+internal/LayoutNode.m +++ b/+labkit/+app/+internal/+contract/LayoutNode.m @@ -2,17 +2,17 @@ %LAYOUT Compose an immutable semantic UI ownership graph. % % Usage: - % node = labkit.app.internal.LayoutNode.button(id, label, onPressed, Name=Value) + % node = labkit.app.internal.contract.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.statusPanel(id) + % node = labkit.app.internal.contract.LayoutNode.slider(id, Name=Value) + % node = labkit.app.internal.contract.LayoutNode.fileList(id, Name=Value) + % node = labkit.app.internal.contract.LayoutNode.plotArea(id, Name=Value) + % node = labkit.app.internal.contract.LayoutNode.dataTable(id, Name=Value) + % node = labkit.app.internal.contract.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) + % node = labkit.app.internal.contract.LayoutNode.tab(id, title, children) % workspace = labkit.app.layout.workspace() % layout = labkit.app.layout.workbench(children, Workspace=workspace) % @@ -28,7 +28,7 @@ % 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. + % children - Row cell array of labkit.app.internal.contract.LayoutNode values. % % Name-Value Arguments: % OnValueChanged - Callback state = callback(state,value,context) for @@ -66,9 +66,11 @@ % Mode - "files" or "folder" for fileList. Default: "files". % SelectionMode - "single" or "multiple" for fileList. Default: % "multiple". + % PathFilter - Optional fileList callback accepted = callback(paths). + % Default: empty. % % Outputs: - % node - Immutable semantic labkit.app.internal.LayoutNode value. + % node - Immutable semantic labkit.app.internal.contract.LayoutNode value. % workspace - Workspace value supporting page and initialPage methods. % layout - Root workbench value accepted by labkit.app.Definition. % @@ -88,10 +90,10 @@ % labkit:app:contract:UnsupportedOperation - Nesting is illegal. % % Typical Call: - % controls = {labkit.app.internal.LayoutNode.button( ... + % controls = {labkit.app.internal.contract.LayoutNode.button( ... % "run", "Run", @runAnalysis)}; % workspace = labkit.app.layout.workspace( ... - % labkit.app.internal.LayoutNode.plotArea( ... + % labkit.app.internal.contract.LayoutNode.plotArea( ... % "result", @drawResult)); % layout = labkit.app.layout.workbench(controls, Workspace=workspace); % @@ -131,19 +133,19 @@ methods (Static) function obj = button(id, label, onPressed, varargin) - options = labkit.app.internal.OptionParser.parse("labkit.app.layout.button", ... + options = labkit.app.internal.contract.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( ... + signal = labkit.app.internal.contract.LayoutNodeValues.bindSignal(id, "pressed", onPressed); + label = labkit.app.internal.contract.LayoutNodeValues.nonemptyText( ... label, "action label"); configuration = struct( ... "Label", label, ... - "BusyMessage", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + "BusyMessage", labkit.app.internal.contract.LayoutNodeValues.scalarText(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "BusyMessage", ""), "BusyMessage"), ... - "Enabled", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Enabled", labkit.app.internal.contract.LayoutNodeValues.logicalValue(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Enabled", true), "Enabled"), ... - "Tooltip", labkit.app.internal.LayoutNodeValues.nonemptyText( ... - labkit.app.internal.LayoutNodeValues.optionValue( ... + "Tooltip", labkit.app.internal.contract.LayoutNodeValues.nonemptyText( ... + labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Tooltip", label), "Tooltip")); obj = makeLeaf("button", id, ... ["enabled", "visible", "text"], {signal}, configuration); @@ -153,90 +155,90 @@ names = ["Label", "Kind", "Value", "Choices", "Limits", "Step", "Bind", ... "ValueDisplayFormat", "ShowTicks", "Enabled", ... "OnValueChanged"]; - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.layout.field", names, varargin{:}); - kind = labkit.app.internal.LayoutNodeValues.enumText(labkit.app.internal.LayoutNodeValues.optionValue(options, "Kind", "text"), ... + kind = labkit.app.internal.contract.LayoutNodeValues.enumText(labkit.app.internal.contract.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( ... + signal = labkit.app.internal.contract.LayoutNodeValues.optionalSignal( ... + id, "valueChanged", labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "OnValueChanged", [])); configuration = struct( ... - "Label", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue(options, ... + "Label", labkit.app.internal.contract.LayoutNodeValues.scalarText(labkit.app.internal.contract.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( ... + "Value", labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "Value", []), ... + "Choices", labkit.app.internal.contract.LayoutNodeValues.textRow(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Choices", strings(1, 0)), "Choices"), ... - "Limits", labkit.app.internal.LayoutNodeValues.optionalLimits(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Limits", labkit.app.internal.contract.LayoutNodeValues.optionalLimits(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Limits", []), "Limits"), ... - "Step", labkit.app.internal.LayoutNodeValues.optionalPositive(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Step", labkit.app.internal.contract.LayoutNodeValues.optionalPositive(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Step", []), "Step"), ... - "ValueDisplayFormat", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + "ValueDisplayFormat", labkit.app.internal.contract.LayoutNodeValues.scalarText(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "ValueDisplayFormat", ""), ... "ValueDisplayFormat"), ... - "ShowTicks", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + "ShowTicks", labkit.app.internal.contract.LayoutNodeValues.logicalValue(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "ShowTicks", false), "ShowTicks"), ... - "Enabled", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Enabled", labkit.app.internal.contract.LayoutNodeValues.logicalValue(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Enabled", true), "Enabled"), ... - "Bind", labkit.app.internal.LayoutNodeValues.bindingPath(labkit.app.internal.LayoutNodeValues.optionValue(options, "Bind", ""))); + "Bind", labkit.app.internal.contract.LayoutNodeValues.bindingPath(labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "Bind", ""))); obj = makeLeaf("field", id, ... ["value", "choices", "limits", "enabled", "visible", "text"], ... - labkit.app.internal.LayoutNodeValues.signalCell(signal), configuration); + labkit.app.internal.contract.LayoutNodeValues.signalCell(signal), configuration); end function obj = rangeField(id, varargin) - options = labkit.app.internal.OptionParser.parse("labkit.app.layout.rangeField", ... + options = labkit.app.internal.contract.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", [])); + signal = labkit.app.internal.contract.LayoutNodeValues.optionalSignal(id, "valueChanged", ... + labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "OnValueChanged", [])); configuration = struct( ... - "Label", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue(options, ... + "Label", labkit.app.internal.contract.LayoutNodeValues.scalarText(labkit.app.internal.contract.LayoutNodeValues.optionValue(options, ... "Label", id), "Label"), ... - "Value", labkit.app.internal.LayoutNodeValues.optionalPair(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Value", labkit.app.internal.contract.LayoutNodeValues.optionalPair(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Value", []), "Value"), ... - "Limits", labkit.app.internal.LayoutNodeValues.optionalLimits(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Limits", labkit.app.internal.contract.LayoutNodeValues.optionalLimits(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Limits", []), "Limits"), ... - "Enabled", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Enabled", labkit.app.internal.contract.LayoutNodeValues.logicalValue(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Enabled", true), "Enabled"), ... - "Bind", labkit.app.internal.LayoutNodeValues.bindingPath(labkit.app.internal.LayoutNodeValues.optionValue(options, "Bind", ""))); + "Bind", labkit.app.internal.contract.LayoutNodeValues.bindingPath(labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "Bind", ""))); obj = makeLeaf("rangeField", id, ... ["value", "limits", "enabled", "visible"], ... - labkit.app.internal.LayoutNodeValues.signalCell(signal), configuration); + labkit.app.internal.contract.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( ... + options = labkit.app.internal.contract.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( ... + signal = labkit.app.internal.contract.LayoutNodeValues.optionalSignal(id, "valueChanged", ... + labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "OnValueChanged", [])); + limits = labkit.app.internal.contract.LayoutNodeValues.optionalLimits(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Limits", [0 1]), "Limits"); - value = labkit.app.internal.LayoutNodeValues.optionValue(options, "Value", limits(1)); + value = labkit.app.internal.contract.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", labkit.app.internal.contract.LayoutNodeValues.scalarText(labkit.app.internal.contract.LayoutNodeValues.optionValue(options, ... "Label", id), "Label"), ... "Value", double(value), "Limits", limits, ... - "Step", labkit.app.internal.LayoutNodeValues.optionalPositive(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Step", labkit.app.internal.contract.LayoutNodeValues.optionalPositive(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Step", []), "Step"), ... - "ShowTicks", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + "ShowTicks", labkit.app.internal.contract.LayoutNodeValues.logicalValue(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "ShowTicks", false), "ShowTicks"), ... - "ValueDisplayFormat", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + "ValueDisplayFormat", labkit.app.internal.contract.LayoutNodeValues.scalarText(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "ValueDisplayFormat", ""), ... "ValueDisplayFormat"), ... - "Enabled", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Enabled", labkit.app.internal.contract.LayoutNodeValues.logicalValue(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Enabled", true), "Enabled"), ... - "Bind", labkit.app.internal.LayoutNodeValues.bindingPath(labkit.app.internal.LayoutNodeValues.optionValue(options, "Bind", ""))); + "Bind", labkit.app.internal.contract.LayoutNodeValues.bindingPath(labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "Bind", ""))); obj = makeLeaf("slider", id, ... ["value", "limits", "enabled", "visible", "text"], ... - labkit.app.internal.LayoutNodeValues.signalCell(signal), configuration); + labkit.app.internal.contract.LayoutNodeValues.signalCell(signal), configuration); end function obj = fileList(id, varargin) @@ -245,48 +247,49 @@ "ChooseLabel", "FolderLabel", "RecursiveFolderLabel", ... "RemoveLabel", "ClearLabel", "EmptyText", "Bind", ... "SelectionBind", "SourceRole", "SourceIdPrefix", "Required", ... - "AllowDuplicatePaths", "OnSelectionChanged", ... + "AllowDuplicatePaths", "PathFilter", ... + "PathFilterDescription", "OnSelectionChanged", ... "ChooseTooltip", "FolderTooltip", ... "RecursiveFolderTooltip", "RemoveTooltip", "ClearTooltip"]; - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.layout.fileList", names, varargin{:}); - selectionSignal = labkit.app.internal.LayoutNodeValues.optionalSignal( ... - id, "listSelectionChanged", labkit.app.internal.LayoutNodeValues.optionValue( ... + selectionSignal = labkit.app.internal.contract.LayoutNodeValues.optionalSignal( ... + id, "listSelectionChanged", labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "OnSelectionChanged", [])); - chooseLabel = labkit.app.internal.LayoutNodeValues.scalarText( ... - labkit.app.internal.LayoutNodeValues.optionValue( ... + chooseLabel = labkit.app.internal.contract.LayoutNodeValues.scalarText( ... + labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "ChooseLabel", "Choose"), "ChooseLabel"); - folderLabel = labkit.app.internal.LayoutNodeValues.scalarText( ... - labkit.app.internal.LayoutNodeValues.optionValue( ... + folderLabel = labkit.app.internal.contract.LayoutNodeValues.scalarText( ... + labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "FolderLabel", "Choose Folder"), "FolderLabel"); - recursiveFolderLabel = labkit.app.internal.LayoutNodeValues.scalarText( ... - labkit.app.internal.LayoutNodeValues.optionValue(options, ... + recursiveFolderLabel = labkit.app.internal.contract.LayoutNodeValues.scalarText( ... + labkit.app.internal.contract.LayoutNodeValues.optionValue(options, ... "RecursiveFolderLabel", "Choose Folder Recursively"), ... "RecursiveFolderLabel"); - removeLabel = labkit.app.internal.LayoutNodeValues.scalarText( ... - labkit.app.internal.LayoutNodeValues.optionValue( ... + removeLabel = labkit.app.internal.contract.LayoutNodeValues.scalarText( ... + labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "RemoveLabel", "Remove"), "RemoveLabel"); - clearLabel = labkit.app.internal.LayoutNodeValues.scalarText( ... - labkit.app.internal.LayoutNodeValues.optionValue( ... + clearLabel = labkit.app.internal.contract.LayoutNodeValues.scalarText( ... + labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "ClearLabel", "Clear"), "ClearLabel"); configuration = struct( ... - "Label", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Label", labkit.app.internal.contract.LayoutNodeValues.scalarText(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Label", id), "Label"), ... - "Mode", labkit.app.internal.LayoutNodeValues.enumText(labkit.app.internal.LayoutNodeValues.optionValue(options, "Mode", "files"), ... + "Mode", labkit.app.internal.contract.LayoutNodeValues.enumText(labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "Mode", "files"), ... ["files", "folder"], "fileList Mode"), ... - "Filters", labkit.app.internal.LayoutNodeValues.textRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Filters", labkit.app.internal.contract.LayoutNodeValues.textRow(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Filters", strings(1, 0)), "Filters"), ... - "SelectionMode", labkit.app.internal.LayoutNodeValues.enumText(labkit.app.internal.LayoutNodeValues.optionValue( ... + "SelectionMode", labkit.app.internal.contract.LayoutNodeValues.enumText(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "SelectionMode", "multiple"), ... ["single", "multiple"], "SelectionMode"), ... - "MaxFiles", labkit.app.internal.LayoutNodeValues.positiveOrInf(labkit.app.internal.LayoutNodeValues.optionValue( ... + "MaxFiles", labkit.app.internal.contract.LayoutNodeValues.positiveOrInf(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "MaxFiles", Inf), "MaxFiles"), ... - "FolderWarningThreshold", labkit.app.internal.LayoutNodeValues.positiveOrInf(labkit.app.internal.LayoutNodeValues.optionValue( ... + "FolderWarningThreshold", labkit.app.internal.contract.LayoutNodeValues.positiveOrInf(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "FolderWarningThreshold", 500), ... "FolderWarningThreshold"), ... - "ShowStatus", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + "ShowStatus", labkit.app.internal.contract.LayoutNodeValues.logicalValue(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "ShowStatus", true), "ShowStatus"), ... - "StartPath", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + "StartPath", labkit.app.internal.contract.LayoutNodeValues.scalarText(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "StartPath", ""), "StartPath"), ... "ChooseLabel", chooseLabel, ... "FolderLabel", folderLabel, ... @@ -303,24 +306,32 @@ "RemoveTooltip", removeLabel), ... "ClearTooltip", tooltipValue(options, ... "ClearTooltip", clearLabel), ... - "EmptyText", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + "EmptyText", labkit.app.internal.contract.LayoutNodeValues.scalarText(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "EmptyText", "No files selected"), "EmptyText"), ... - "AllowDuplicatePaths", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + "AllowDuplicatePaths", labkit.app.internal.contract.LayoutNodeValues.logicalValue(labkit.app.internal.contract.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( ... + "PathFilter", labkit.app.internal.contract.LayoutNodeValues.pathFilterCallback( ... + labkit.app.internal.contract.LayoutNodeValues.optionValue( ... + options, "PathFilter", [])), ... + "PathFilterDescription", ... + labkit.app.internal.contract.LayoutNodeValues.nonemptyText( ... + labkit.app.internal.contract.LayoutNodeValues.optionValue( ... + options, "PathFilterDescription", "supported"), ... + "PathFilterDescription"), ... + "Bind", labkit.app.internal.contract.LayoutNodeValues.bindingPath(labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "Bind", "")), ... + "SelectionBind", labkit.app.internal.contract.LayoutNodeValues.bindingPath(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "SelectionBind", "")), ... - "SourceRole", labkit.app.internal.LayoutNodeValues.nonemptyText(labkit.app.internal.LayoutNodeValues.optionValue( ... + "SourceRole", labkit.app.internal.contract.LayoutNodeValues.nonemptyText(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "SourceRole", id), "SourceRole"), ... - "SourceIdPrefix", labkit.app.internal.LayoutNodeValues.nonemptyText(labkit.app.internal.LayoutNodeValues.optionValue( ... + "SourceIdPrefix", labkit.app.internal.contract.LayoutNodeValues.nonemptyText(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "SourceIdPrefix", id), "SourceIdPrefix"), ... - "Required", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Required", labkit.app.internal.contract.LayoutNodeValues.logicalValue(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Required", true), "Required")); obj = makeLeaf("fileList", id, ... ["filePaths", "fileItemStatuses", "listSelection", ... "enabled", "visible", "text"], ... - labkit.app.internal.LayoutNodeValues.signalCell(selectionSignal), configuration); + labkit.app.internal.contract.LayoutNodeValues.signalCell(selectionSignal), configuration); end function obj = plotArea(id, renderer, varargin) @@ -329,60 +340,60 @@ "XLabels", "YLabels", "ColumnWidths", "RowHeights", ... "ScrollZoomAxes", "ViewModes", "OnValueChanged", ... "Interactions"]; - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.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( ... + signal = labkit.app.internal.contract.LayoutNodeValues.optionalSignal(id, "valueChanged", ... + labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "OnValueChanged", [])); + axisIds = labkit.app.internal.contract.LayoutNodeValues.idRow(labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "AxisIds", "main"), "axis"); + renderer = labkit.app.internal.contract.LayoutNodeValues.rendererCallback(renderer); + interactions = labkit.app.internal.contract.LayoutNodeValues.interactionSpecs(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Interactions", {}), id, axisIds); axisCount = numel(axisIds); configuration = struct( ... - "Title", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue(options, ... + "Title", labkit.app.internal.contract.LayoutNodeValues.scalarText(labkit.app.internal.contract.LayoutNodeValues.optionValue(options, ... "Title", ""), "Title"), ... - "Layout", labkit.app.internal.LayoutNodeValues.enumText(labkit.app.internal.LayoutNodeValues.optionValue(options, ... - "Layout", labkit.app.internal.LayoutNodeValues.defaultAxesLayout(axisCount)), ... + "Layout", labkit.app.internal.contract.LayoutNodeValues.enumText(labkit.app.internal.contract.LayoutNodeValues.optionValue(options, ... + "Layout", labkit.app.internal.contract.LayoutNodeValues.defaultAxesLayout(axisCount)), ... ["single", "pair", "stack"], "Layout"), ... - "AxisTitles", labkit.app.internal.LayoutNodeValues.optionalAxisText(options, ... + "AxisTitles", labkit.app.internal.contract.LayoutNodeValues.optionalAxisText(options, ... "AxisTitles", axisCount), ... - "XLabels", labkit.app.internal.LayoutNodeValues.optionalAxisText(options, ... + "XLabels", labkit.app.internal.contract.LayoutNodeValues.optionalAxisText(options, ... "XLabels", axisCount), ... - "YLabels", labkit.app.internal.LayoutNodeValues.optionalAxisText(options, ... + "YLabels", labkit.app.internal.contract.LayoutNodeValues.optionalAxisText(options, ... "YLabels", axisCount), ... - "ColumnWidths", {labkit.app.internal.LayoutNodeValues.optionalLayoutSizes(options, ... + "ColumnWidths", {labkit.app.internal.contract.LayoutNodeValues.optionalLayoutSizes(options, ... "ColumnWidths", axisCount)}, ... - "RowHeights", {labkit.app.internal.LayoutNodeValues.optionalLayoutSizes(options, ... + "RowHeights", {labkit.app.internal.contract.LayoutNodeValues.optionalLayoutSizes(options, ... "RowHeights", axisCount)}, ... - "ScrollZoomAxes", labkit.app.internal.LayoutNodeValues.scrollZoomAxes(options, axisCount), ... - "ViewModes", labkit.app.internal.LayoutNodeValues.textRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + "ScrollZoomAxes", labkit.app.internal.contract.LayoutNodeValues.scrollZoomAxes(options, axisCount), ... + "ViewModes", labkit.app.internal.contract.LayoutNodeValues.textRow(labkit.app.internal.contract.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), ... + labkit.app.internal.contract.LayoutNodeValues.assertAxesLayout(configuration.Layout, axisCount); + obj = labkit.app.internal.contract.LayoutNode("plotArea", labkit.app.internal.contract.LayoutNodeValues.normalizeId(id), {}, ... + ["renderPlot", "value", "visible"], labkit.app.internal.contract.LayoutNodeValues.signalCell(signal), ... renderer, axisIds, configuration); end function obj = dataTable(id, varargin) - options = labkit.app.internal.OptionParser.parse("labkit.app.layout.dataTable", ... + options = labkit.app.internal.contract.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", ... + labkit.app.internal.contract.LayoutNodeValues.namedSignal(id, options, "OnCellEdited", "cellEdited") + labkit.app.internal.contract.LayoutNodeValues.namedSignal(id, options, "OnCellSelectionChanged", ... "cellSelectionChanged")}.'; signals = signals(~cellfun(@isempty, signals)); - columns = labkit.app.internal.LayoutNodeValues.textRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + columns = labkit.app.internal.contract.LayoutNodeValues.textRow(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Columns", strings(1, 0)), "Columns"); - editable = labkit.app.internal.LayoutNodeValues.logicalRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + editable = labkit.app.internal.contract.LayoutNodeValues.logicalRow(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "ColumnEditable", false), "ColumnEditable"); - labkit.app.internal.LayoutNodeValues.assertEditableWidth(editable, columns); + labkit.app.internal.contract.LayoutNodeValues.assertEditableWidth(editable, columns); configuration = struct( ... - "Title", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Title", labkit.app.internal.contract.LayoutNodeValues.scalarText(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Title", ""), "data table Title"), ... "Columns", columns, ... - "RowNames", labkit.app.internal.LayoutNodeValues.textRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + "RowNames", labkit.app.internal.contract.LayoutNodeValues.textRow(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "RowNames", strings(1, 0)), "RowNames"), ... "ColumnEditable", editable); obj = makeLeaf("dataTable", id, ... @@ -392,10 +403,10 @@ end function obj = statusPanel(id, varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.layout.statusPanel", ... ["Title", "Text", "Lines"], varargin{:}); - lines = labkit.app.internal.LayoutNodeValues.optionValue( ... + lines = labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Lines", 5); if ~isnumeric(lines) || ~isscalar(lines) || ... ~isfinite(lines) || lines ~= fix(lines) || ... @@ -404,9 +415,9 @@ "statusPanel Lines must be an integer from 1 through 12."); end configuration = struct( ... - "Title", labkit.app.internal.LayoutNodeValues.nonemptyText(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Title", labkit.app.internal.contract.LayoutNodeValues.nonemptyText(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Title", "Status"), "status panel Title"), ... - "Text", labkit.app.internal.LayoutNodeValues.textRow(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Text", labkit.app.internal.contract.LayoutNodeValues.textRow(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Text", strings(1, 0)), ... "status panel Text"), ... "Lines", double(lines)); @@ -415,81 +426,81 @@ end function obj = group(id, children, varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.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"); + children = labkit.app.internal.contract.LayoutNodeValues.normalizeChildren(children); + labkit.app.internal.contract.LayoutNodeValues.validateChildKinds(children, labkit.app.internal.contract.LayoutNodeValues.controlGroupKinds(), "group"); configuration = struct( ... - "Layout", labkit.app.internal.LayoutNodeValues.enumText(labkit.app.internal.LayoutNodeValues.optionValue(options, "Layout", "auto"), ... + "Layout", labkit.app.internal.contract.LayoutNodeValues.enumText(labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "Layout", "auto"), ... ["auto", "vertical", "horizontal"], "group Layout"), ... - "Title", labkit.app.internal.LayoutNodeValues.scalarText(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Title", labkit.app.internal.contract.LayoutNodeValues.scalarText(labkit.app.internal.contract.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", ... + options = labkit.app.internal.contract.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"); + children = labkit.app.internal.contract.LayoutNodeValues.normalizeChildren(children); + labkit.app.internal.contract.LayoutNodeValues.validateChildKinds(children, labkit.app.internal.contract.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( ... + "Title", labkit.app.internal.contract.LayoutNodeValues.nonemptyText(title, "section title"), ... + "Collapsible", labkit.app.internal.contract.LayoutNodeValues.logicalValue(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Collapsible", false), "Collapsible"), ... - "Expanded", labkit.app.internal.LayoutNodeValues.logicalValue(labkit.app.internal.LayoutNodeValues.optionValue( ... + "Expanded", labkit.app.internal.contract.LayoutNodeValues.logicalValue(labkit.app.internal.contract.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"); + children = labkit.app.internal.contract.LayoutNodeValues.normalizeChildren(children); + labkit.app.internal.contract.LayoutNodeValues.validateChildKinds(children, ... + [labkit.app.internal.contract.LayoutNodeValues.leafAndGroupKinds(), "section"], "tab"); obj = makeContainer("tab", id, children, ... - struct("Title", labkit.app.internal.LayoutNodeValues.nonemptyText(title, "tab title"))); + struct("Title", labkit.app.internal.contract.LayoutNodeValues.nonemptyText(title, "tab title"))); end function obj = workspace(varargin) content = {}; - if ~isempty(varargin) && isa(varargin{1}, "labkit.app.internal.LayoutNode") + if ~isempty(varargin) && isa(varargin{1}, "labkit.app.internal.contract.LayoutNode") content = varargin(1); varargin = varargin(2:end); end - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.layout.workspace", ... ["Title", "OnPageChanged"], varargin{:}); - signal = labkit.app.internal.LayoutNodeValues.optionalSignal("workspace", "pageChanged", ... - labkit.app.internal.LayoutNodeValues.optionValue(options, "OnPageChanged", [])); + signal = labkit.app.internal.contract.LayoutNodeValues.optionalSignal("workspace", "pageChanged", ... + labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "OnPageChanged", [])); if ~isempty(content) - labkit.app.internal.LayoutNodeValues.validateChildKinds(content, labkit.app.internal.LayoutNodeValues.workspaceContentKinds(), ... + labkit.app.internal.contract.LayoutNodeValues.validateChildKinds(content, labkit.app.internal.contract.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"), ... + obj = labkit.app.internal.contract.LayoutNode("workspace", "workspace", content, ... + strings(1, 0), labkit.app.internal.contract.LayoutNodeValues.signalCell(signal), [], ... + strings(1, 0), struct("Title", labkit.app.internal.contract.LayoutNodeValues.nonemptyText( ... + labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "Title", "Workspace"), ... "workspace title"))); end function obj = workbench(children, varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.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( ... + children = labkit.app.internal.contract.LayoutNodeValues.normalizeChildren(children); + labkit.app.internal.contract.LayoutNodeValues.validateChildKinds(children, ... + [labkit.app.internal.contract.LayoutNodeValues.leafAndGroupKinds(), "section", "tab"], "workbench"); + usage = labkit.app.internal.contract.LayoutNodeValues.textRow(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "Usage", strings(1, 0)), "Usage"); if ~isempty(usage) - usageTitle = labkit.app.internal.LayoutNodeValues.nonemptyText(labkit.app.internal.LayoutNodeValues.optionValue( ... + usageTitle = labkit.app.internal.contract.LayoutNodeValues.nonemptyText(labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, "UsageTitle", "Usage"), "UsageTitle"); - usagePanel = labkit.app.internal.LayoutNode.statusPanel( ... + usagePanel = labkit.app.internal.contract.LayoutNode.statusPanel( ... "applicationUsage", Title=usageTitle, Text=usage); - usageSection = labkit.app.internal.LayoutNode.section( ... + usageSection = labkit.app.internal.contract.LayoutNode.section( ... "applicationUsageSection", usageTitle, {usagePanel}); if ~isempty(children) && children{1}.Kind == "tab" first = children{1}; - children{1} = labkit.app.internal.LayoutNode( ... + children{1} = labkit.app.internal.contract.LayoutNode( ... "tab", first.Id, ... [first.Children, {usageSection}], ... first.Capabilities, first.Signals, ... @@ -499,16 +510,16 @@ children{end + 1} = usageSection; end end - workspace = labkit.app.internal.LayoutNodeValues.optionValue(options, "Workspace", []); + workspace = labkit.app.internal.contract.LayoutNodeValues.optionValue(options, "Workspace", []); if ~isempty(workspace) - if ~isa(workspace, "labkit.app.internal.LayoutNode") || ... + if ~isa(workspace, "labkit.app.internal.contract.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, ... + obj = labkit.app.internal.contract.LayoutNode("workbench", "application", children, ... strings(1, 0), {}, [], strings(1, 0), struct()); end end @@ -524,25 +535,25 @@ "A single-content workspace cannot also declare " + ... "named pages."); end - id = labkit.app.internal.LayoutNodeValues.normalizeId(id); + id = labkit.app.internal.contract.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") + if isa(content, "labkit.app.internal.contract.LayoutNode") content = {content}; else - content = labkit.app.internal.LayoutNodeValues.normalizeChildren(content); + content = labkit.app.internal.contract.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(), ... + labkit.app.internal.contract.LayoutNodeValues.validateChildKinds(content, labkit.app.internal.contract.LayoutNodeValues.workspaceContentKinds(), ... "workspace page"); - pageNode = labkit.app.internal.LayoutNode("workspacePage", id, content, ... + pageNode = labkit.app.internal.contract.LayoutNode("workspacePage", id, content, ... "workspacePage", {}, [], strings(1, 0), ... - struct("Title", labkit.app.internal.LayoutNodeValues.nonemptyText(title, "workspace page title"))); + struct("Title", labkit.app.internal.contract.LayoutNodeValues.nonemptyText(title, "workspace page title"))); obj.Children{end + 1} = pageNode; obj.PageIds(end + 1) = id; if strlength(obj.InitialPage) == 0 @@ -555,7 +566,7 @@ error("labkit:app:contract:UnsupportedOperation", ... "Layout initialPage is available only on a workspace."); end - id = labkit.app.internal.LayoutNodeValues.normalizeId(id); + id = labkit.app.internal.contract.LayoutNodeValues.normalizeId(id); if ~any(obj.PageIds == id) error("labkit:app:contract:UnknownReference", ... "Workspace initial page is undeclared: %s.", id); @@ -564,7 +575,7 @@ end end - methods (Access = ?labkit.app.internal.CompiledDefinition) + methods (Access = ?labkit.app.internal.contract.CompiledDefinition) function nodes = flattenForCompiler(obj) chunks = cell(1, 1 + numel(obj.Children)); chunks{1} = {obj}; @@ -581,17 +592,17 @@ end function obj = makeLeaf(kind, id, capabilities, signals, configuration) - obj = labkit.app.internal.LayoutNode(kind, labkit.app.internal.LayoutNodeValues.normalizeId(id), {}, capabilities, ... + obj = labkit.app.internal.contract.LayoutNode(kind, labkit.app.internal.contract.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, ... + obj = labkit.app.internal.contract.LayoutNode(kind, labkit.app.internal.contract.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( ... +value = labkit.app.internal.contract.LayoutNodeValues.nonemptyText( ... + labkit.app.internal.contract.LayoutNodeValues.optionValue( ... options, name, defaultValue), name); end diff --git a/+labkit/+app/+internal/LayoutNodeValues.m b/+labkit/+app/+internal/+contract/LayoutNodeValues.m similarity index 85% rename from +labkit/+app/+internal/LayoutNodeValues.m rename to +labkit/+app/+internal/+contract/LayoutNodeValues.m index 7c411b995..a5dc76bd8 100644 --- a/+labkit/+app/+internal/LayoutNodeValues.m +++ b/+labkit/+app/+internal/+contract/LayoutNodeValues.m @@ -12,7 +12,7 @@ end function value = normalizeId(value) - values = labkit.app.internal.LayoutNodeValues.idRow(value, "layout"); + values = labkit.app.internal.contract.LayoutNodeValues.idRow(value, "layout"); if numel(values) ~= 1 error("labkit:app:contract:InvalidValue", ... "Layout id must be a scalar MATLAB identifier."); @@ -21,7 +21,7 @@ end function values = idRow(values, label) - values = labkit.app.internal.LayoutNodeValues.textRow(values, label + " IDs"); + values = labkit.app.internal.contract.LayoutNodeValues.textRow(values, label + " IDs"); if any(strlength(values) == 0) || ... any(~arrayfun(@(value) isvarname(char(value)), values)) || ... numel(unique(values)) ~= numel(values) @@ -32,7 +32,7 @@ function children = normalizeChildren(children) if ~iscell(children) || (~isempty(children) && ~isrow(children)) || ... - ~all(cellfun(@(value) isa(value, "labkit.app.internal.LayoutNode"), children)) + ~all(cellfun(@(value) isa(value, "labkit.app.internal.contract.LayoutNode"), children)) error("labkit:app:contract:InvalidValue", ... "Layout children must be a row cell array of Layout values."); end @@ -58,7 +58,7 @@ function validateChildKinds(children, allowed, parent) end function kinds = workspaceContentKinds() - kinds = [labkit.app.internal.LayoutNodeValues.leafAndGroupKinds(), "section"]; + kinds = [labkit.app.internal.contract.LayoutNodeValues.leafAndGroupKinds(), "section"]; end function values = signalCell(signal) @@ -69,19 +69,19 @@ function validateChildKinds(children, allowed, parent) end function signal = namedSignal(target, options, optionName, signalName) - signal = labkit.app.internal.LayoutNodeValues.optionalSignal( ... - target, signalName, labkit.app.internal.LayoutNodeValues.optionValue(options, optionName, [])); + signal = labkit.app.internal.contract.LayoutNodeValues.optionalSignal( ... + target, signalName, labkit.app.internal.contract.LayoutNodeValues.optionValue(options, optionName, [])); end function signal = optionalSignal(target, signalName, callback) signal = []; if ~isempty(callback) - signal = labkit.app.internal.LayoutNodeValues.bindSignal(target, signalName, callback); + signal = labkit.app.internal.contract.LayoutNodeValues.bindSignal(target, signalName, callback); end end function signal = bindSignal(target, signalName, callback) - signal = labkit.app.internal.SignalBinding(target, signalName, callback); + signal = labkit.app.internal.contract.SignalBinding(target, signalName, callback); end function callback = rendererCallback(callback) @@ -95,6 +95,21 @@ function validateChildKinds(children, allowed, parent) end end + function callback = pathFilterCallback(callback) + if isempty(callback) + return; + end + if ~isa(callback, "function_handle") || ~isscalar(callback) + error("labkit:app:contract:InvalidValue", ... + "layout.fileList PathFilter must be a function handle."); + end + if nargin(callback) ~= 1 || nargout(callback) ~= 1 + error("labkit:app:contract:CallbackRoleMismatch", ... + "layout.fileList PathFilter must accept paths and return " + ... + "one logical mask."); + end + end + function specs = interactionSpecs(specs, plotId, axisIds) if isempty(specs) specs = {}; @@ -102,7 +117,7 @@ function validateChildKinds(children, allowed, parent) end if ~iscell(specs) || ~isrow(specs) || ... ~all(cellfun(@(value) ... - isa(value, "labkit.app.internal.InteractionSpec"), specs)) + isa(value, "labkit.app.internal.interaction.InteractionSpec"), specs)) error("labkit:app:contract:InvalidValue", ... "layout.plotArea Interactions must be a row cell array of " + ... "labkit.app.interaction declarations."); @@ -126,7 +141,7 @@ function validateChildKinds(children, allowed, parent) end function value = nonemptyText(value, label) - value = labkit.app.internal.LayoutNodeValues.scalarText(value, label); + value = labkit.app.internal.contract.LayoutNodeValues.scalarText(value, label); if strlength(value) == 0 error("labkit:app:contract:InvalidValue", ... "Layout %s must be nonempty.", label); @@ -134,7 +149,7 @@ function validateChildKinds(children, allowed, parent) end function value = enumText(value, allowed, label) - value = labkit.app.internal.LayoutNodeValues.scalarText(value, label); + value = labkit.app.internal.contract.LayoutNodeValues.scalarText(value, label); if ~any(value == allowed) error("labkit:app:contract:InvalidValue", ... "Layout %s has an unsupported value: %s.", label, value); @@ -142,7 +157,7 @@ function validateChildKinds(children, allowed, parent) end function value = bindingPath(value) - value = labkit.app.internal.LayoutNodeValues.scalarText(value, "Bind"); + value = labkit.app.internal.contract.LayoutNodeValues.scalarText(value, "Bind"); if strlength(value) == 0 return; end @@ -175,7 +190,7 @@ function validateChildKinds(children, allowed, parent) function values = optionalAxisText(options, name, axisCount) values = strings(1, 0); if isfield(options, name) - values = labkit.app.internal.LayoutNodeValues.textRow(options.(name), name); + values = labkit.app.internal.contract.LayoutNodeValues.textRow(options.(name), name); if numel(values) ~= axisCount error("labkit:app:contract:InvalidValue", ... "Layout %s must contain one value per axis.", name); @@ -217,7 +232,7 @@ function validateChildKinds(children, allowed, parent) if ~isfield(options, "ScrollZoomAxes") return; end - values = labkit.app.internal.LayoutNodeValues.textRow(options.ScrollZoomAxes, "ScrollZoomAxes"); + values = labkit.app.internal.contract.LayoutNodeValues.textRow(options.ScrollZoomAxes, "ScrollZoomAxes"); if numel(values) ~= axisCount || ... any(~ismember(values, ["xy", "x", "y"])) error("labkit:app:contract:InvalidValue", ... diff --git a/+labkit/+app/+internal/OptionParser.m b/+labkit/+app/+internal/+contract/OptionParser.m similarity index 100% rename from +labkit/+app/+internal/OptionParser.m rename to +labkit/+app/+internal/+contract/OptionParser.m diff --git a/+labkit/+app/+internal/SignalBinding.m b/+labkit/+app/+internal/+contract/SignalBinding.m similarity index 100% rename from +labkit/+app/+internal/SignalBinding.m rename to +labkit/+app/+internal/+contract/SignalBinding.m diff --git a/+labkit/+app/+internal/+diagnostics/RuntimeDiagnostics.m b/+labkit/+app/+internal/+diagnostics/RuntimeDiagnostics.m new file mode 100644 index 000000000..4cf2482d9 --- /dev/null +++ b/+labkit/+app/+internal/+diagnostics/RuntimeDiagnostics.m @@ -0,0 +1,185 @@ +classdef (Hidden, Sealed) RuntimeDiagnostics < handle + % Own Runtime diagnostic viewing, capture, and export workflows. + % Caller: RuntimeKernel. SessionDiagnostics remains the event/journal + % primitive; this owner coordinates user choices, artifacts, and fallback. + + properties (Access = private) + Recorder + Context + Artifacts + DisplayName (1, 1) string + NotifyUser + end + + methods + function obj = RuntimeDiagnostics( ... + recorder, context, artifacts, displayName, notifyUser) + if ~isa(recorder, ... + "labkit.app.internal.diagnostics.SessionDiagnostics") || ... + ~isscalar(recorder) || ... + ~isa(context, "labkit.app.CallbackContext") || ... + ~isscalar(context) || ... + ~isa(artifacts, ... + "labkit.app.internal.artifact.Store") || ... + ~isscalar(artifacts) || ... + ~isa(notifyUser, "function_handle") || ... + ~isscalar(notifyUser) + error("labkit:app:runtime:InvariantFailure", ... + "Runtime diagnostic dependencies are invalid."); + end + obj.Recorder = recorder; + obj.Context = context; + obj.Artifacts = artifacts; + obj.DisplayName = string(displayName); + obj.NotifyUser = notifyUser; + end + + function events = events(obj) + events = obj.Recorder.events(); + end + + function snapshot = snapshot(obj) + snapshot = obj.Recorder.captureSnapshot(); + end + + function title = title(obj) + title = obj.DisplayName + " — Session Log"; + end + + function token = subscribe(obj, callback) + token = obj.Recorder.subscribe(callback); + end + + function unsubscribe(obj, token) + obj.Recorder.unsubscribe(token); + end + + function setTraceCapture(obj, enabled) + obj.Recorder.setTraceEnabled(enabled); + end + + function destination = exportBundle( ... + obj, destination, state, stateMode) + if nargin < 4 + stateMode = "exact"; + end + stateMode = validateStateMode(stateMode); + operation = obj.Recorder.begin( ... + "runtime.lifecycle", "diagnostics.bundle_exported", ... + "Exporting diagnostic bundle."); + try + destination = obj.Recorder.exportBundle( ... + destination, operation.Id, state, stateMode); + obj.Recorder.finish( ... + operation, "completed", "notApplicable", []); + catch cause + obj.Recorder.finish( ... + operation, "failed", "notApplicable", cause); + destination = obj.exportTextFallback( ... + destination, cause, stateMode); + end + end + + function destination = exportInteractive(obj, state) + selection = obj.Context.chooseOption( ... + "Every bundle contains complete sensitive logs and App " + ... + "state. Compact MAT replaces supported state values over " + ... + "1 MiB with structural synthetic placeholders.", ... + ["Complete bundle (exact MAT)", ... + "Complete bundle (compact synthetic MAT)", ... + "Cancel"], ... + Title="Export Diagnostic Bundle", ... + DefaultChoice="Complete bundle (exact MAT)", ... + CancelChoice="Cancel"); + if selection.Cancelled || selection.Value == "Cancel" + destination = ""; + return + end + stateMode = "exact"; + if selection.Value == "Complete bundle (compact synthetic MAT)" + stateMode = "compact"; + end + destination = ""; + try + automaticDestination = obj.automaticDestination(stateMode); + destination = obj.exportBundle( ... + automaticDestination, state, stateMode); + if endsWith(destination, ".txt", IgnoreCase=true) + obj.alertTextFallback(destination); + else + obj.NotifyUser( ... + "Diagnostic bundle written to:" + newline + ... + string(destination), ... + "Diagnostic Bundle Exported"); + end + return + catch automaticFailure + fallbackName = diagnosticFallbackName( ... + obj.automaticFilename(stateMode)); + end + choice = obj.Context.chooseOutputFile( ... + {"*.txt", "Diagnostic text fallback (*.txt)"}, ... + fallbackName); + if choice.Cancelled + return + end + destination = obj.exportTextFallback( ... + choice.Value, automaticFailure, stateMode); + obj.alertTextFallback(destination); + end + + function destination = exportTextFallback( ... + obj, preferredDestination, cause, stateMode) + if nargin < 4 + stateMode = "exact"; + end + stateMode = validateStateMode(stateMode); + obj.Recorder.log( ... + "warning", "diagnostics.text_fallback.started", ... + "Diagnostic ZIP export failed; writing a plain-text fallback.", ... + Category="runtime.lifecycle", Audience="user", ... + Exception=cause); + destination = obj.Recorder.exportTextFallback( ... + preferredDestination, cause, stateMode); + end + + function alertTextFallback(obj, destination) + obj.Context.alert( ... + "The diagnostic ZIP could not be exported. A plain-text " + ... + "diagnostic fallback was written to:" + newline + ... + string(destination), ... + "Diagnostic Text Fallback"); + end + end + + methods (Access = private) + function destination = automaticDestination(obj, stateMode) + destination = obj.Artifacts.destination( ... + "diagnostics", diagnosticArtifactStem(stateMode), ".zip"); + end + + function filename = automaticFilename(obj, stateMode) + filename = obj.Artifacts.filename( ... + diagnosticArtifactStem(stateMode), ".zip"); + end + end +end + +function stateMode = validateStateMode(stateMode) +stateMode = ... + labkit.app.internal.diagnostics.SessionDiagnosticStateProjection.validateMode( ... + stateMode); +end + +function stem = diagnosticArtifactStem(stateMode) +if stateMode == "exact" + stem = "diagnostics-sensitive-state"; +else + stem = "diagnostics-sensitive-compact-state"; +end +end + +function filename = diagnosticFallbackName(zipFilename) +[~, name] = fileparts(string(zipFilename)); +filename = name + "-fallback.txt"; +end diff --git a/+labkit/+app/+internal/SessionDiagnosticBundle.m b/+labkit/+app/+internal/+diagnostics/SessionDiagnosticBundle.m similarity index 65% rename from +labkit/+app/+internal/SessionDiagnosticBundle.m rename to +labkit/+app/+internal/+diagnostics/SessionDiagnosticBundle.m index 69aacdc26..65ceb7527 100644 --- a/+labkit/+app/+internal/SessionDiagnosticBundle.m +++ b/+labkit/+app/+internal/+diagnostics/SessionDiagnosticBundle.m @@ -1,11 +1,26 @@ classdef (Hidden, Sealed) SessionDiagnosticBundle - %SESSIONDIAGNOSTICBUNDLE Write one privacy-safe diagnostic ZIP snapshot. - % SessionDiagnostics supplies canonical events and manifest metadata. - % This writer never receives App state, source files, results, or images. + %SESSIONDIAGNOSTICBUNDLE Write one diagnostic ZIP snapshot. + % SessionDiagnostics supplies full retained events, manifest metadata, and + % current App state. Every ZIP contains complete events plus either an + % exact or structurally compact diagnostic MAT projection. methods (Static) - function destination = write(snapshot, destination) + function destination = write( ... + snapshot, destination, privateState, stateMode) + if nargin < 3 + privateState = []; + end + if nargin < 4 + stateMode = "exact"; + end snapshot = validateSnapshot(snapshot); + stateMode = ... + labkit.app.internal.diagnostics.SessionDiagnosticStateProjection.validateMode( ... + stateMode); + if ~isstruct(privateState) || ~isscalar(privateState) + error("labkit:app:runtime:InvariantFailure", ... + "Diagnostic App state must be one scalar struct."); + end destination = diagnosticZipPath(destination); parent = string(fileparts(destination)); if strlength(parent) == 0 @@ -19,8 +34,16 @@ staging = string(tempname); mkdir(char(staging)); cleanup = onCleanup(@() removeStaging(staging)); + [diagnosticState, stateReview] = ... + labkit.app.internal.diagnostics.SessionDiagnosticStateProjection.project( ... + privateState, stateMode); + stateFilename = diagnosticStateFilename(stateMode); + stateFilepath = fullfile(staging, stateFilename); + writePrivateState(stateFilepath, diagnosticState); + details = dir(stateFilepath); + stateReview.matFileBytes = double(details.bytes); writeText(fullfile(staging, "README.txt"), ... - readmeLines(snapshot)); + readmeLines(snapshot, stateReview, stateFilename)); writeJson(fullfile(staging, "manifest.json"), ... snapshot.manifest); writeEvents(fullfile(staging, "events.jsonl"), ... @@ -29,8 +52,8 @@ timeline(snapshot.events)); writeJson(fullfile(staging, "errors.json"), ... errorRecords(snapshot.events)); - writeJson(fullfile(staging, "redaction-report.json"), ... - redactionReport(snapshot)); + writeJson(fullfile(staging, "bundle-report.json"), ... + bundleReport(snapshot, stateReview, stateFilename)); temporaryZip = string(tempname(char(parent))) + ".zip"; zipCleanup = onCleanup(@() removeFile(temporaryZip)); @@ -40,7 +63,8 @@ "events.jsonl" "session.log.txt" "errors.json" - "redaction-report.json" + "bundle-report.json" + stateFilename ]; zip(char(temporaryZip), cellstr(files), char(staging)); [moved, message] = movefile( ... @@ -52,27 +76,27 @@ clear zipCleanup cleanup end - function destination = writeFallback(snapshot, preferredDestination) - % RuntimeKernel supplies sanitized in-memory records. Prefer a - % surviving selected folder, then MATLAB's writable temp folder. + function destination = writeFallback( ... + snapshot, preferredDestination, stateMode) + if nargin < 3 + stateMode = "exact"; + end snapshot = validateFallbackSnapshot(snapshot); - folders = fallbackFolders(preferredDestination); - failure = []; - for index = 1:numel(folders) - destination = availableFallbackPath(folders(index)); - try - writeText(destination, fallbackLines(snapshot)); - return; - catch cause - failure = cause; - end + stateMode = ... + labkit.app.internal.diagnostics.SessionDiagnosticStateProjection.validateMode( ... + stateMode); + destination = fallbackPath(preferredDestination); + folder = string(fileparts(destination)); + if strlength(folder) == 0 + folder = string(pwd); + destination = fullfile(folder, destination); end - if isempty(failure) + if exist(char(folder), "dir") ~= 7 error("labkit:app:runtime:DiagnosticWriteFailed", ... - "No diagnostic text fallback folder is available."); + "The diagnostic text fallback folder is unavailable."); end - error("labkit:app:runtime:DiagnosticWriteFailed", ... - "Could not write the diagnostic text fallback."); + writeText(destination, fallbackLines( ... + snapshot, stateMode)); end end end @@ -113,15 +137,17 @@ function validateRecord(record) "stateDisposition", "durationSeconds", "exception"]; if ~isstruct(record) || ~isscalar(record) || ... ~isequal(string(fieldnames(record)), fields.') || ... - ~labkit.app.internal.SessionEventValidator.canonicalTerminalPair( ... + ~labkit.app.internal.diagnostics.SessionEventValidator.canonicalTerminalPair( ... record.operationResult, record.stateDisposition) error("labkit:app:runtime:InvariantFailure", ... "Diagnostic bundle record is not canonical."); end -labkit.app.internal.SessionEventValidator.privacySafeText( ... - record.message, "message"); -labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... - record.attributes); +try + jsonencode(record); +catch + error("labkit:app:runtime:InvariantFailure", ... + "Diagnostic bundle record is not serializable."); +end end function snapshot = validateFallbackSnapshot(snapshot) @@ -165,14 +191,13 @@ function validateRecord(record) end end -function value = readmeLines(snapshot) +function value = readmeLines(snapshot, stateReview, stateFilename) capture = snapshot.capture; degradation = snapshot.degradation; value = [ "LabKit Diagnostic Bundle" "" - "This bundle contains privacy-safe Runtime session records only." - "It does not contain projects, scientific inputs or results, images, screenshots, or source files." + detailDescription(stateReview, stateFilename) "" "Capture notes:" "- TRACE enabled at export: " + yesNo(capture.traceEnabled) @@ -187,18 +212,32 @@ function validateRecord(record) "- Expired journal segments: " + ... numericField(degradation, "expiredSegmentCount") "" - "Use manifest.json for session/component context, events.jsonl for structured history, session.log.txt for a readable timeline, errors.json for failures, and redaction-report.json for excluded-data categories." + "Use manifest.json for session/component context, events.jsonl for structured history, session.log.txt for a readable timeline, errors.json for failures, bundle-report.json for the state review, and the MAT file for App state." ]; end -function value = fallbackLines(snapshot) +function value = detailDescription(stateReview, stateFilename) +value = [ ... + "This bundle contains complete sensitive diagnostic details." + "Session events include full retained messages, attributes, exception messages, and stack locations and may include paths, filenames, and scientific values." + string(stateFilename) + " contains current App project and session state. External source files and screenshots are not copied separately." + ]; +if string(stateReview.mode) == "compact" + value(end + 1, 1) = ... + "Large supported state values were replaced with deterministic compressible placeholders. The compact MAT is diagnostic evidence, not scientifically valid input."; +else + value(end + 1, 1) = ... + "The exact MAT retains all state values, including decoded caches when the App keeps them in memory."; +end +end + +function value = fallbackLines(snapshot, stateMode) application = snapshot.application; capture = snapshot.capture; value = [ "LabKit Diagnostic Text Fallback" "" - "The normal diagnostic ZIP could not be written. This single text file contains the surviving privacy-safe Runtime session records." - "It does not contain projects, scientific inputs or results, images, screenshots, source files, paths, or original filenames." + fallbackDetailLines(stateMode) "" "Application:" "- Name: " + textField(application, "title") @@ -219,11 +258,34 @@ function validateRecord(record) "Session timeline:" timeline(snapshot.events) "" + "Structured session records:" + fallbackEventLines(snapshot.events) + "" "Structured failure records:" fallbackErrorLines(snapshot.events) ]; end +function value = fallbackDetailLines(stateMode) +stateFilename = diagnosticStateFilename(stateMode); +value = [ ... + "The normal diagnostic ZIP could not be written. This fallback preserves complete sensitive event details." + "It contains full retained messages, attributes, exception messages, and stack locations and may contain sensitive paths, filenames, and scientific values." + "The selected " + stateFilename + " could not be represented in the plain-text fallback and is not included." + ]; +end + +function value = fallbackEventLines(events) +if isempty(events) + value = "(none)"; + return; +end +value = strings(numel(events), 1); +for index = 1:numel(events) + value(index) = string(jsonencode(events(index))); +end +end + function value = fallbackErrorLines(events) records = errorRecords(events); if isempty(records) @@ -251,44 +313,20 @@ function validateRecord(record) end end -function folders = fallbackFolders(preferredDestination) -folders = strings(0, 1); -if ischar(preferredDestination) || ... - (isstring(preferredDestination) && isscalar(preferredDestination)) - preferredDestination = strip(string(preferredDestination)); - if strlength(preferredDestination) > 0 - folder = string(fileparts(preferredDestination)); - if strlength(folder) == 0 - folder = string(pwd); - end - if exist(char(folder), "dir") == 7 - folders(end + 1, 1) = folder; - end - end -end -temporaryFolder = string(tempdir); -if exist(char(temporaryFolder), "dir") == 7 - folders(end + 1, 1) = temporaryFolder; -end -folders = unique(folders, "stable"); -end - -function destination = availableFallbackPath(folder) -destination = fullfile(folder, "labkit-diagnostics-fallback.txt"); -if exist(char(destination), "file") ~= 2 && ... - exist(char(destination), "dir") ~= 7 - return; +function destination = fallbackPath(preferredDestination) +if ~(ischar(preferredDestination) || ... + (isstring(preferredDestination) && isscalar(preferredDestination))) || ... + strlength(strip(string(preferredDestination))) == 0 + error("labkit:app:contract:InvalidValue", ... + "Diagnostic text fallback destination must be nonempty scalar text."); end -for index = 2:1000 - candidate = fullfile(folder, ... - "labkit-diagnostics-fallback-" + string(index) + ".txt"); - if exist(char(candidate), "file") ~= 2 && ... - exist(char(candidate), "dir") ~= 7 - destination = candidate; - return; - end +preferredDestination = string(preferredDestination); +[folder, name, extension] = fileparts(preferredDestination); +if strcmpi(extension, ".txt") + destination = preferredDestination; +else + destination = fullfile(folder, name + "-fallback.txt"); end -destination = string(tempname(char(folder))) + ".txt"; end function value = timeline(events) @@ -331,26 +369,30 @@ function validateRecord(record) "rootActionId", "", "exception", struct()); end -function value = redactionReport(snapshot) +function value = bundleReport(snapshot, stateReview, stateFilename) value = struct( ... "schemaVersion", 1, ... - "privacyBoundary", "validated-before-retention", ... - "exportProjection", "canonical-safe-events-only", ... - "excludedData", [ ... - "paths" - "filenames" - "input-content" - "scientific-data" - "workspace-values" - "projects" - "images" - "screenshots" - "source-files" - ], ... - "removedValueCount", 0, ... + "eventProjection", "complete-retained-events", ... + "containsSensitiveDetails", true, ... + "stateFilename", stateFilename, ... + "stateReview", stateReview, ... + "notSeparatelyAttached", ["external-source-files"; "screenshots"], ... "degradation", snapshot.degradation); end +function filename = diagnosticStateFilename(stateMode) +if stateMode == "compact" + filename = "app-state-compact.mat"; +else + filename = "app-state.mat"; +end +end + +function writePrivateState(filepath, privateState) +applicationState = privateState; +save(char(filepath), "applicationState", "-mat"); +end + function writeEvents(filepath, events) file = fopen(char(filepath), "w", "n", "UTF-8"); if file < 0 diff --git a/+labkit/+app/+internal/+diagnostics/SessionDiagnosticStateProjection.m b/+labkit/+app/+internal/+diagnostics/SessionDiagnosticStateProjection.m new file mode 100644 index 000000000..5fb5f2ac6 --- /dev/null +++ b/+labkit/+app/+internal/+diagnostics/SessionDiagnosticStateProjection.m @@ -0,0 +1,164 @@ +classdef (Hidden, Sealed) SessionDiagnosticStateProjection + %SESSIONDIAGNOSTICSTATEPROJECTION Prepare exact or compact diagnostic state. + % RuntimeKernel and SessionDiagnosticBundle validate modes; the Bundle is + % the sole project caller. Input and output are one scalar App state struct. + % Compact mode preserves containers, field names, leaf classes, and array + % dimensions while replacing large supported leaf values with deterministic + % compressible data. It has no external side effects. The returned report + % records structural state paths only and never includes replaced values. + + methods (Static) + function [applicationState, report] = project(applicationState, mode) + if ~isstruct(applicationState) || ~isscalar(applicationState) + error("labkit:app:runtime:InvariantFailure", ... + "Diagnostic App state must be one scalar struct."); + end + mode = ... + labkit.app.internal.diagnostics.SessionDiagnosticStateProjection.validateMode( ... + mode); + replacements = emptyReplacements(); + retainedLargeValues = emptyRetainedLargeValues(); + if mode == "compact" + [applicationState, replacements, retainedLargeValues] = ... + projectValue(applicationState, "applicationState", ... + replacements, retainedLargeValues); + end + report = struct( ... + "schemaVersion", 1, ... + "mode", mode, ... + "largeValueThresholdBytes", compactThresholdBytes(), ... + "replacementCount", numel(replacements), ... + "replacements", replacements, ... + "retainedLargeValueCount", numel(retainedLargeValues), ... + "retainedLargeValues", retainedLargeValues); + end + + function mode = validateMode(mode) + mode = stateMode(mode); + end + end +end + +function mode = stateMode(mode) +if ~(ischar(mode) || (isstring(mode) && isscalar(mode))) + invalidMode(); +end +mode = lower(string(mode)); +if ~any(mode == ["exact", "compact"]) + invalidMode(); +end +end + +function invalidMode() +error("labkit:app:contract:InvalidValue", ... + "Diagnostic state mode must be exact or compact."); +end + +function [value, replacements, retained] = projectValue( ... + value, path, replacements, retained) +if isstruct(value) + names = fieldnames(value); + for elementIndex = 1:numel(value) + elementPath = indexedPath(path, elementIndex, numel(value), "(", ")"); + for fieldIndex = 1:numel(names) + name = names{fieldIndex}; + childPath = elementPath + "." + string(name); + [value(elementIndex).(name), replacements, retained] = ... + projectValue(value(elementIndex).(name), childPath, ... + replacements, retained); + end + end + return +end +if iscell(value) + for index = 1:numel(value) + childPath = indexedPath(path, index, numel(value), "{", "}"); + [value{index}, replacements, retained] = projectValue( ... + value{index}, childPath, replacements, retained); + end + return +end +if istable(value) + names = string(value.Properties.VariableNames); + for index = 1:numel(names) + name = names(index); + [value.(name), replacements, retained] = projectValue( ... + value.(name), path + "." + name, replacements, retained); + end + return +end +originalBytes = valueBytes(value); +if originalBytes <= compactThresholdBytes() + return +end +if ~isReplaceable(value) + retained(end + 1, 1) = struct( ... + "statePath", path, ... + "valueClass", string(class(value)), ... + "dimensions", double(size(value)), ... + "originalBytes", double(originalBytes), ... + "reason", "unsupported-value-type"); + return +end +replacement = syntheticValue(value); +record = struct( ... + "statePath", path, ... + "valueClass", string(class(value)), ... + "dimensions", double(size(value)), ... + "originalBytes", double(originalBytes), ... + "replacement", "deterministic-compressible-placeholder"); +replacements(end + 1, 1) = record; +value = replacement; +end + +function path = indexedPath(path, index, count, opening, closing) +if count > 1 + path = path + opening + string(index) + closing; +end +end + +function tf = isReplaceable(value) +tf = isnumeric(value) || islogical(value) || ischar(value) || isstring(value); +end + +function value = syntheticValue(source) +if ischar(source) + value = repmat('x', size(source)); +elseif isstring(source) + value = repmat("", size(source)); +elseif issparse(source) + value = sparse(size(source, 1), size(source, 2)); + if islogical(source) + value = logical(value); + end +else + value = zeros(size(source), "like", source); +end +end + +function bytes = valueBytes(value) +details = whos('value'); +bytes = double(details.bytes); +end + +function bytes = compactThresholdBytes() +bytes = 1024 * 1024; +end + +function value = emptyReplacements() +value = repmat(struct( ... + "statePath", "", ... + "valueClass", "", ... + "dimensions", zeros(1, 0), ... + "originalBytes", 0, ... + "replacement", ""), 0, 1); +end + +function value = emptyRetainedLargeValues() +value = repmat(struct( ... + "statePath", "", ... + "valueClass", "", ... + "dimensions", zeros(1, 0), ... + "originalBytes", 0, ... + "reason", ""), 0, 1); +end diff --git a/+labkit/+app/+internal/SessionDiagnostics.m b/+labkit/+app/+internal/+diagnostics/SessionDiagnostics.m similarity index 88% rename from +labkit/+app/+internal/SessionDiagnostics.m rename to +labkit/+app/+internal/+diagnostics/SessionDiagnostics.m index 79d9b6d5e..3d194caae 100644 --- a/+labkit/+app/+internal/SessionDiagnostics.m +++ b/+labkit/+app/+internal/+diagnostics/SessionDiagnostics.m @@ -20,18 +20,18 @@ error("labkit:app:runtime:InvariantFailure", ... "SessionDiagnostics requires one Definition."); end - if ~isa(stream, "labkit.app.internal.SessionEventStream") || ... + if ~isa(stream, "labkit.app.internal.diagnostics.SessionEventStream") || ... ~isscalar(stream) error("labkit:app:runtime:InvariantFailure", ... "SessionDiagnostics requires one SessionEventStream."); end if ~isa(projection, ... - "labkit.app.internal.SessionJournalProjection") || ... + "labkit.app.internal.diagnostics.SessionJournalProjection") || ... ~isscalar(projection) error("labkit:app:runtime:InvariantFailure", ... "SessionDiagnostics requires one journal projection."); end - if ~isa(journal, "labkit.app.internal.SessionJournal") || ... + if ~isa(journal, "labkit.app.internal.diagnostics.SessionJournal") || ... ~isscalar(journal) error("labkit:app:runtime:InvariantFailure", ... "SessionDiagnostics requires one SessionJournal."); @@ -101,10 +101,17 @@ function setTraceEnabled(obj, enabled) end function destination = exportBundle( ... - obj, destination, excludeOperationId) + obj, destination, excludeOperationId, ... + privateState, stateMode) if nargin < 3 excludeOperationId = ""; end + if nargin < 4 + privateState = []; + end + if nargin < 5 + stateMode = "exact"; + end obj.Journal.flush(); streamSnapshot = obj.Stream.captureSnapshot(); manifest = obj.Journal.manifest(); @@ -112,7 +119,7 @@ function setTraceEnabled(obj, enabled) degradation = manifest.degradation; try archived = ... - labkit.app.internal.SessionJournalArchive.snapshot( ... + labkit.app.internal.diagnostics.SessionJournalArchive.snapshot( ... obj.Journal.rootFolder(), ... obj.Journal.sessionId()); events = mergeEvents( ... @@ -140,12 +147,15 @@ function setTraceEnabled(obj, enabled) "manifest", manifest, "events", events, ... "degradation", degradation, "capture", capture); destination = ... - labkit.app.internal.SessionDiagnosticBundle.write( ... - snapshot, destination); + labkit.app.internal.diagnostics.SessionDiagnosticBundle.write( ... + snapshot, destination, privateState, stateMode); end function destination = exportTextFallback( ... - obj, preferredDestination, failure) + obj, preferredDestination, failure, stateMode) + if nargin < 4 + stateMode = "exact"; + end % Keep this path independent of the journal and ZIP staging so a % failure in either subsystem cannot consume the last evidence. try @@ -178,8 +188,8 @@ function setTraceEnabled(obj, enabled) "capture", capture, ... "failureIdentifier", failureIdentifier); destination = ... - labkit.app.internal.SessionDiagnosticBundle.writeFallback( ... - snapshot, preferredDestination); + labkit.app.internal.diagnostics.SessionDiagnosticBundle.writeFallback( ... + snapshot, preferredDestination, stateMode); end function close(obj) diff --git a/+labkit/+app/+internal/SessionEventStream.m b/+labkit/+app/+internal/+diagnostics/SessionEventStream.m similarity index 92% rename from +labkit/+app/+internal/SessionEventStream.m rename to +labkit/+app/+internal/+diagnostics/SessionEventStream.m index fe087c60c..77441777a 100644 --- a/+labkit/+app/+internal/SessionEventStream.m +++ b/+labkit/+app/+internal/+diagnostics/SessionEventStream.m @@ -1,5 +1,5 @@ classdef (Hidden, Sealed) SessionEventStream < handle - %SESSIONEVENTSTREAM Private privacy-safe in-memory session event stream. + %SESSIONEVENTSTREAM Private full-detail in-memory session event stream. % Expected callers are the private App Runtime and focused framework tests. % Records are validated before entering the bounded ring; persistence and % viewer projections intentionally belong to later migration checkpoints. @@ -36,7 +36,7 @@ "SessionEventStream requires one Definition."); end sessionId = optionValue(varargin, "SessionId", ... - labkit.app.internal.SessionIdentity.create()); + labkit.app.internal.diagnostics.SessionIdentity.create()); projectionHook = optionValue(varargin, "ProjectionHook", []); projectionHealthHook = optionValue(varargin, "ProjectionHealthHook", []); traceEnabled = optionValue(varargin, "TraceEnabled", false); @@ -53,7 +53,7 @@ "Session event TraceEnabled must be scalar logical."); end obj.Application = application; - obj.SessionId = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + obj.SessionId = labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier( ... sessionId, "sessionId"); obj.StartedAt = datetime("now", TimeZone="UTC", ... Format="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); @@ -68,14 +68,15 @@ function operation = begin(obj, category, eventName, message, varargin) obj.ensureOpen(); - category = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + category = labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier( ... category, "category"); - eventName = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + eventName = labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier( ... eventName, "eventName"); - message = labkit.app.internal.SessionEventValidator.privacySafeText( ... - message, "message"); - attributes = labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... - optionValue(varargin, "Attributes", struct())); + values = labkit.app.internal.diagnostics.SessionEventValidator.logInputs( ... + "debug", eventName, message, category, "developer", ... + optionValue(varargin, "Attributes", struct()), []); + message = values.message; + attributes = values.attributes; obj.OperationSequence = obj.OperationSequence + 1; parent = obj.currentOperation(); operation = struct( ... @@ -104,7 +105,7 @@ function finish(obj, operation, operationResult, stateDisposition, exception) obj.ensureOpen(); operation = validOperation(operation); obj.requireActiveTopOperation(operation); - terminal = labkit.app.internal.SessionEventValidator.terminalFields( ... + terminal = labkit.app.internal.diagnostics.SessionEventValidator.terminalFields( ... operationResult, stateDisposition); if ~isempty(exception) && terminal.operationResult ~= "failed" error("labkit:app:contract:InvalidValue", ... @@ -128,7 +129,7 @@ function finish(obj, operation, operationResult, stateDisposition, exception) function log(obj, severity, eventName, message, varargin) obj.ensureOpen(); - values = labkit.app.internal.SessionEventValidator.logInputs( ... + values = labkit.app.internal.diagnostics.SessionEventValidator.logInputs( ... severity, eventName, message, ... optionValue(varargin, "Category", "runtime.lifecycle"), ... optionValue(varargin, "Audience", "developer"), ... @@ -171,7 +172,7 @@ function log(obj, severity, eventName, message, varargin) record.rootActionId = operation.RootActionId; terminal = optionValue(varargin, "Terminal", []); if ~isempty(terminal) - terminal = labkit.app.internal.SessionEventValidator.terminalFields( ... + terminal = labkit.app.internal.diagnostics.SessionEventValidator.terminalFields( ... terminal.operationResult, terminal.stateDisposition); record.operationResult = terminal.operationResult; record.stateDisposition = terminal.stateDisposition; @@ -181,6 +182,10 @@ function log(obj, severity, eventName, message, varargin) end record.exception = exception; obj.retain(record); + if any(severity == ["error", "critical"]) && ... + ~obj.TraceEnabled + obj.setTraceEnabled(true); + end end function records = records(obj) @@ -366,7 +371,7 @@ function retainProjectionHealth(obj, eventName, attributes, contextRecord) if nargin < 4 contextRecord = []; end - attributes = labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... + attributes = labkit.app.internal.diagnostics.SessionEventValidator.privacySafeAttributes( ... attributes); if isempty(contextRecord) operation = obj.currentOperation(); @@ -482,7 +487,7 @@ function notifyConsumers(obj, record) "Projection health notification is invalid."); end eventName = string(notification.eventName); -attributes = labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... +attributes = labkit.app.internal.diagnostics.SessionEventValidator.privacySafeAttributes( ... struct("reason", notification.reason)); count = notification.count; if eventName == "journal.degraded" @@ -526,6 +531,11 @@ function notifyConsumers(obj, record) "Session event Exception must be a scalar MException."); end exception.identifier = string(value.identifier); -exception.message = "Exception captured."; -exception.stack = string({value.stack.name}).'; +exception.message = string(value.message); +stack = value.stack; +exception.stack = strings(numel(stack), 1); +for index = 1:numel(stack) + exception.stack(index) = string(stack(index).name) + " (" + ... + string(stack(index).file) + ":" + string(stack(index).line) + ")"; +end end diff --git a/+labkit/+app/+internal/SessionEventValidator.m b/+labkit/+app/+internal/+diagnostics/SessionEventValidator.m similarity index 85% rename from +labkit/+app/+internal/SessionEventValidator.m rename to +labkit/+app/+internal/+diagnostics/SessionEventValidator.m index cdfe36772..0dcb4deab 100644 --- a/+labkit/+app/+internal/SessionEventValidator.m +++ b/+labkit/+app/+internal/+diagnostics/SessionEventValidator.m @@ -1,21 +1,19 @@ classdef (Hidden, Sealed) SessionEventValidator - %SESSIONEVENTVALIDATOR Validate privacy-safe private session event inputs. + %SESSIONEVENTVALIDATOR Validate and project private session event inputs. methods (Static) function values = logInputs(severity, eventName, message, ... category, audience, attributes, exception) values = struct( ... - "severity", labkit.app.internal.SessionEventValidator.severity(severity), ... - "eventName", labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + "severity", labkit.app.internal.diagnostics.SessionEventValidator.severity(severity), ... + "eventName", labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier( ... eventName, "eventName"), ... - "message", labkit.app.internal.SessionEventValidator.privacySafeText( ... - message, "message"), ... - "category", labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + "message", diagnosticText(message, "message"), ... + "category", labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier( ... category, "category"), ... - "audience", labkit.app.internal.SessionEventValidator.audience(audience), ... - "attributes", labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... - attributes), ... - "exception", labkit.app.internal.SessionEventValidator.exception(exception)); + "audience", labkit.app.internal.diagnostics.SessionEventValidator.audience(audience), ... + "attributes", diagnosticAttributes(attributes), ... + "exception", labkit.app.internal.diagnostics.SessionEventValidator.exception(exception)); end function value = semanticIdentifier(value, name) @@ -32,7 +30,7 @@ end function value = severity(value) - value = lower(labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + value = lower(labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier( ... value, "severity")); if ~any(value == ["trace", "debug", "info", "warning", "error", "critical"]) error("labkit:app:contract:InvalidValue", ... @@ -41,7 +39,7 @@ end function value = audience(value) - value = lower(labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + value = lower(labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier( ... value, "audience")); if ~any(value == ["user", "developer"]) error("labkit:app:contract:InvalidValue", ... @@ -117,10 +115,10 @@ function terminal = terminalFields(operationResult, stateDisposition) operationResult = lower( ... - labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier( ... operationResult, "operationResult")); stateDisposition = lower( ... - labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier( ... stateDisposition, "stateDisposition")); if operationResult == "completed" && ... any(stateDisposition == ["committed", "notapplicable"]) @@ -157,7 +155,7 @@ return; end try - terminal = labkit.app.internal.SessionEventValidator.terminalFields( ... + terminal = labkit.app.internal.diagnostics.SessionEventValidator.terminalFields( ... operationResult, stateDisposition); tf = operationResult == terminal.operationResult && ... stateDisposition == terminal.stateDisposition; @@ -168,6 +166,40 @@ end end +function value = diagnosticText(value, name) +if ~(ischar(value) || (isstring(value) && isscalar(value))) || ... + ismissing(string(value)) + error("labkit:app:contract:InvalidValue", ... + "Session event %s must be scalar text.", name); +end +value = string(value); +if strlength(value) > 65536 + error("labkit:app:contract:InvalidValue", ... + "Session event %s exceeds the live diagnostic-text limit.", name); +end +end + +function attributes = diagnosticAttributes(attributes) +if ~isstruct(attributes) || ~isscalar(attributes) + error("labkit:app:contract:InvalidValue", ... + "Session event attributes must be one scalar struct."); +end +if numel(fieldnames(attributes)) > 64 + error("labkit:app:contract:InvalidValue", ... + "Session event attributes exceed the live diagnostic field limit."); +end +try + encoded = jsonencode(attributes); +catch + error("labkit:app:contract:InvalidValue", ... + "Session event attributes must be JSON serializable."); +end +if utf8ByteCount(encoded) > 262144 + error("labkit:app:contract:InvalidValue", ... + "Session event attributes exceed the diagnostic JSON byte limit."); +end +end + function value = ternary(condition, trueValue, falseValue) if condition value = trueValue; diff --git a/+labkit/+app/+internal/SessionIdentity.m b/+labkit/+app/+internal/+diagnostics/SessionIdentity.m similarity index 77% rename from +labkit/+app/+internal/SessionIdentity.m rename to +labkit/+app/+internal/+diagnostics/SessionIdentity.m index 918c603eb..79c244812 100644 --- a/+labkit/+app/+internal/SessionIdentity.m +++ b/+labkit/+app/+internal/+diagnostics/SessionIdentity.m @@ -5,7 +5,7 @@ function sessionId = create() temporaryPath = string(tempname); [~, leaf] = fileparts(temporaryPath); - sessionId = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + sessionId = labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier( ... "session-" + string(leaf), "sessionId"); end end diff --git a/+labkit/+app/+internal/SessionJournal.m b/+labkit/+app/+internal/+diagnostics/SessionJournal.m similarity index 96% rename from +labkit/+app/+internal/SessionJournal.m rename to +labkit/+app/+internal/+diagnostics/SessionJournal.m index 0ea1167fe..b36dfcb18 100644 --- a/+labkit/+app/+internal/SessionJournal.m +++ b/+labkit/+app/+internal/+diagnostics/SessionJournal.m @@ -1,5 +1,5 @@ classdef (Hidden, Sealed) SessionJournal < handle - %SESSIONJOURNAL Buffered writer for already-validated canonical events. + %SESSIONJOURNAL Buffered writer for full-detail canonical events. % This private projection owns one live session only. Archive inspection, % recovery, retention, and export belong to SessionJournalArchive. @@ -121,7 +121,7 @@ function append(obj, record) function written = flush(obj, varargin) deferFailureManifest = false; if ~isempty(varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "SessionJournal.flush", "DeferFailureManifest", varargin{:}); deferFailureManifest = optionValue(options, ... "DeferFailureManifest", false); @@ -324,7 +324,7 @@ function writeActiveMarker(obj, force) return; end probe = leaseProbe(obj.LeaseProbe); - marker = labkit.app.internal.SessionLease.create( ... + marker = labkit.app.internal.diagnostics.SessionLease.create( ... obj.SessionId, obj.Application.AppId, string(obj.StartedAt), ... nowUtc, obj.LeaseNonce, probe); obj.invokeFault("activeMarker"); @@ -342,7 +342,7 @@ function removeActiveMarker(obj) written = false; force = false; if ~isempty(varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "SessionJournal.writeManifest", "Force", varargin{:}); force = optionValue(options, "Force", false); end @@ -430,7 +430,7 @@ function rememberDegradationReason(obj, reason) % segments cap ordinary context; 64 records/64 KiB bound buffered writes. options = struct( ... "RootFolder", string(fullfile(prefdir, "LabKit", "logs")), ... - "SessionId", labkit.app.internal.SessionIdentity.create(), ... + "SessionId", labkit.app.internal.diagnostics.SessionIdentity.create(), ... "SegmentByteLimit", 10 * 1024 * 1024, ... "SegmentLimit", 5, ... "SessionByteLimit", 50 * 1024 * 1024, ... @@ -451,7 +451,7 @@ function rememberDegradationReason(obj, reason) options.(name) = varargin{index + 1}; end options.RootFolder = string(options.RootFolder); -options.SessionId = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... +options.SessionId = labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier( ... options.SessionId, "SessionId"); for name = ["SegmentByteLimit", "SegmentLimit", "SessionByteLimit", ... "BufferRecordLimit", "BufferByteLimit"] @@ -472,9 +472,9 @@ function rememberDegradationReason(obj, reason) isTextNonce = (isstring(options.LeaseNonce) && isscalar(options.LeaseNonce)) || ... (ischar(options.LeaseNonce) && isrow(options.LeaseNonce)); if isTextNonce && strlength(string(options.LeaseNonce)) == 0 - options.LeaseNonce = labkit.app.internal.SessionLease.createNonce(); + options.LeaseNonce = labkit.app.internal.diagnostics.SessionLease.createNonce(); end -options.LeaseNonce = labkit.app.internal.SessionLease.validateNonce(options.LeaseNonce); +options.LeaseNonce = labkit.app.internal.diagnostics.SessionLease.validateNonce(options.LeaseNonce); if ~(isnumeric(options.HeartbeatIntervalSeconds) && isscalar(options.HeartbeatIntervalSeconds) && ... isfinite(options.HeartbeatIntervalSeconds) && options.HeartbeatIntervalSeconds > 0) error("labkit:app:contract:InvalidValue", ... @@ -492,7 +492,7 @@ function rememberDegradationReason(obj, reason) function value = leaseProbe(probe) if isempty(probe) - value = labkit.app.internal.SessionLease.localProbe(); + value = labkit.app.internal.diagnostics.SessionLease.localProbe(); else value = probe(); end @@ -518,7 +518,7 @@ function rememberDegradationReason(obj, reason) "exception"]; tf = isstruct(record) && isscalar(record) && ... isequal(string(fieldnames(record)), fields.') && ... - labkit.app.internal.SessionEventValidator.canonicalTerminalPair( ... + labkit.app.internal.diagnostics.SessionEventValidator.canonicalTerminalPair( ... record.operationResult, record.stateDisposition); end diff --git a/+labkit/+app/+internal/SessionJournalArchive.m b/+labkit/+app/+internal/+diagnostics/SessionJournalArchive.m similarity index 96% rename from +labkit/+app/+internal/SessionJournalArchive.m rename to +labkit/+app/+internal/+diagnostics/SessionJournalArchive.m index 8afc9b0f9..6a9669bec 100644 --- a/+labkit/+app/+internal/SessionJournalArchive.m +++ b/+labkit/+app/+internal/+diagnostics/SessionJournalArchive.m @@ -1,7 +1,7 @@ classdef (Hidden, Sealed) SessionJournalArchive %SESSIONJOURNALARCHIVE Inspect, recover, retain, and export closed journals. % This private archive boundary never receives live events or decides their - % privacy semantics; it consumes only the canonical journal representation. + % privacy semantics; journals retain the complete canonical representation. methods (Static) function inspection = inspect(rootFolder, varargin) @@ -30,7 +30,7 @@ end function exportFolder = exportSnapshot(rootFolder, sessionId, exportFolder) - snapshot = labkit.app.internal.SessionJournalArchive.snapshot( ... + snapshot = labkit.app.internal.diagnostics.SessionJournalArchive.snapshot( ... rootFolder, sessionId); exportFolder = string(exportFolder); if exist(char(exportFolder), "dir") ~= 7 @@ -238,12 +238,12 @@ if ~isempty(options.LeaseProbe) probe = options.LeaseProbe(targetPid, targetHost); else - probe = labkit.app.internal.SessionLease.localProbe(targetPid); + probe = labkit.app.internal.diagnostics.SessionLease.localProbe(targetPid); end catch probe = []; end -state = labkit.app.internal.SessionLease.classify( ... +state = labkit.app.internal.diagnostics.SessionLease.classify( ... marker, manifest, nowUtc, probe, options.LeaseFreshSeconds); end @@ -462,10 +462,8 @@ function removeSession(folder) end function value = redactionMetadata() -value = struct("semanticEventPrivacy", "validated-before-retention", ... - "exportProjection", "canonical-safe-events-only", ... - "excludedData", ["paths", "filenames", "input-content", ... - "scientific-data", "workspace-values"]); +value = struct("semanticEventPrivacy", "complete-retained-details", ... + "exportProjection", "none", "excludedData", strings(0, 1)); end function writeEvents(filepath, events) @@ -518,12 +516,12 @@ function truncate(filepath, content) "exception"]; tf = isstruct(record) && isscalar(record) && ... isequal(string(fieldnames(record)), fields.') && ... - labkit.app.internal.SessionEventValidator.canonicalTerminalPair( ... + labkit.app.internal.diagnostics.SessionEventValidator.canonicalTerminalPair( ... record.operationResult, record.stateDisposition); end function value = semanticSessionId(value, label) -value = labkit.app.internal.SessionEventValidator.semanticIdentifier(value, label); +value = labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier(value, label); end function value = utcNow() diff --git a/+labkit/+app/+internal/SessionJournalProjection.m b/+labkit/+app/+internal/+diagnostics/SessionJournalProjection.m similarity index 98% rename from +labkit/+app/+internal/SessionJournalProjection.m rename to +labkit/+app/+internal/+diagnostics/SessionJournalProjection.m index f6010e96e..7b0706a36 100644 --- a/+labkit/+app/+internal/SessionJournalProjection.m +++ b/+labkit/+app/+internal/+diagnostics/SessionJournalProjection.m @@ -17,7 +17,7 @@ methods function obj = SessionJournalProjection(journal, projectionFaultInjector) - if ~isa(journal, "labkit.app.internal.SessionJournal") || ~isscalar(journal) + if ~isa(journal, "labkit.app.internal.diagnostics.SessionJournal") || ~isscalar(journal) error("labkit:app:runtime:InvariantFailure", ... "SessionJournalProjection requires one SessionJournal."); end diff --git a/+labkit/+app/+internal/SessionLease.m b/+labkit/+app/+internal/+diagnostics/SessionLease.m similarity index 94% rename from +labkit/+app/+internal/SessionLease.m rename to +labkit/+app/+internal/+diagnostics/SessionLease.m index eb3b3cd60..3220376b6 100644 --- a/+labkit/+app/+internal/SessionLease.m +++ b/+labkit/+app/+internal/+diagnostics/SessionLease.m @@ -7,9 +7,9 @@ error("labkit:app:contract:InvalidValue", ... "SessionLease requires one explicit LeaseNonce."); end - nonce = labkit.app.internal.SessionLease.validateNonce(nonce); + nonce = labkit.app.internal.diagnostics.SessionLease.validateNonce(nonce); if nargin < 6 || isempty(probe) - probe = labkit.app.internal.SessionLease.localProbe(); + probe = labkit.app.internal.diagnostics.SessionLease.localProbe(); end pid = -1; if isfield(probe, "pid") && isnumeric(probe.pid) && isscalar(probe.pid) && ... @@ -25,7 +25,7 @@ function nonce = createNonce() [~, leaf] = fileparts(tempname); - nonce = labkit.app.internal.SessionLease.validateNonce("lease-" + string(leaf)); + nonce = labkit.app.internal.diagnostics.SessionLease.validateNonce("lease-" + string(leaf)); end function nonce = validateNonce(nonce) @@ -34,7 +34,7 @@ error("labkit:app:contract:InvalidValue", ... "SessionLease LeaseNonce must be one nonempty semantic identifier."); end - nonce = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + nonce = labkit.app.internal.diagnostics.SessionEventValidator.semanticIdentifier( ... nonce, "LeaseNonce"); end @@ -48,7 +48,7 @@ return; end if nargin < 4 || isempty(probe) - probe = labkit.app.internal.SessionLease.localProbe(); + probe = labkit.app.internal.diagnostics.SessionLease.localProbe(); end fields = ["sessionId", "appId", "state", "host", "pid", "nonce", ... "startedAtUtc", "heartbeatAtUtc", "leaseVersion"]; diff --git a/+labkit/+app/+internal/SessionLogProjection.m b/+labkit/+app/+internal/+diagnostics/SessionLogProjection.m similarity index 91% rename from +labkit/+app/+internal/SessionLogProjection.m rename to +labkit/+app/+internal/+diagnostics/SessionLogProjection.m index 40d5dc40d..9d43d8c61 100644 --- a/+labkit/+app/+internal/SessionLogProjection.m +++ b/+labkit/+app/+internal/+diagnostics/SessionLogProjection.m @@ -16,8 +16,7 @@ ExpiredSegmentCount (1, 1) double = 0 DegradationReason (1, 1) string = "" ClearedThroughSequence (1, 1) double = 0 - LevelFilter (1, 1) string = "default" - AudienceFilter (1, 1) string = "default" + LevelFilter (1, 1) string = "trace" CategoryFilter (1, 1) string = "" RootActionFilter (1, 1) string = "" SearchText (1, 1) string = "" @@ -63,18 +62,13 @@ function append(obj, record) end function setFilters(obj, varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "SessionLogProjection.setFilters", ... - ["Level", "Audience", "Category", "RootAction", "Search"], ... + ["Level", "Category", "RootAction", "Search"], ... varargin{:}); if isfield(options, "Level") obj.LevelFilter = oneOf(options.Level, ... - ["default", "trace", "debug", "info", ... - "warning", "error", "critical"], "Level"); - end - if isfield(options, "Audience") - obj.AudienceFilter = oneOf(options.Audience, ... - ["default", "all", "user", "developer"], "Audience"); + ["trace", "debug", "user"], "Level"); end if isfield(options, "Category") obj.CategoryFilter = optionalText( ... @@ -109,6 +103,7 @@ function clearView(obj) "categories", choices(string({obj.Events.category})), ... "rootActions", rootActionIds, ... "rootActionLabels", rootActionLabels, ... + "traceEnabled", obj.TraceEnabled, ... "notices", obj.notices(), ... "clearedThroughSequence", obj.ClearedThroughSequence); end @@ -135,22 +130,13 @@ function clearView(obj) keep = sequence > obj.ClearedThroughSequence; levels = lower(string({selected.severity})); audiences = lower(string({selected.audience})); - if obj.LevelFilter == "default" - rank = severityRank(levels); - keep = keep & ((audiences == "user" & rank >= 3) | ... - rank >= 4); + if obj.LevelFilter == "user" + keep = keep & audiences == "user" & ... + severityRank(levels) >= severityRank("info"); else keep = keep & severityRank(levels) >= ... severityRank(obj.LevelFilter); end - if obj.AudienceFilter == "user" - keep = keep & audiences == "user"; - elseif obj.AudienceFilter == "developer" - keep = keep & audiences == "developer"; - elseif obj.AudienceFilter == "default" - rank = severityRank(levels); - keep = keep & (audiences == "user" | rank >= 4); - end if strlength(obj.CategoryFilter) > 0 keep = keep & ... string({selected.category}) == obj.CategoryFilter; @@ -174,8 +160,8 @@ function clearView(obj) value = strings(0, 1); if ~obj.TraceEnabled value(end + 1, 1) = ... - "TRACE detail is off; DEBUG lifecycle and all warnings and errors " + ... - "are still captured. Earlier TRACE detail is unavailable."; + "TRACE capture starts automatically after the first ERROR; " + ... + "DEBUG and higher detail is complete so far."; end if obj.InMemoryTruncated value(end + 1, 1) = ... diff --git a/+labkit/+app/+internal/SessionLogViewer.m b/+labkit/+app/+internal/+diagnostics/SessionLogViewer.m similarity index 85% rename from +labkit/+app/+internal/SessionLogViewer.m rename to +labkit/+app/+internal/+diagnostics/SessionLogViewer.m index ae2bffc5e..c3e4c3ff6 100644 --- a/+labkit/+app/+internal/SessionLogViewer.m +++ b/+labkit/+app/+internal/+diagnostics/SessionLogViewer.m @@ -13,27 +13,25 @@ NoticeLabel SearchField LevelFilter - AudienceFilter CategoryFilter RootFilter - FollowButton + TraceButton CopyButton EventTable DetailArea VisibleSequences (1, :) double = zeros(1, 0) - FollowLatest (1, 1) logical = true Closed (1, 1) logical = false end methods function obj = SessionLogViewer(runtime) - if ~isa(runtime, "labkit.app.internal.RuntimeKernel") || ... + if ~isa(runtime, "labkit.app.internal.runtime.RuntimeKernel") || ... ~isscalar(runtime) error("labkit:app:runtime:InvariantFailure", ... "SessionLogViewer requires one RuntimeKernel."); end obj.Runtime = runtime; - obj.Projection = labkit.app.internal.SessionLogProjection( ... + obj.Projection = labkit.app.internal.diagnostics.SessionLogProjection( ... runtime.diagnosticSnapshot()); obj.createFigure(); obj.SubscriptionToken = ... @@ -46,7 +44,7 @@ function show(obj) return; end mode = ... - labkit.app.internal.NativeAdapterValues.startupGuiMode(); + labkit.app.internal.native.NativeAdapterValues.startupGuiMode(); if mode == "hidden" return; end @@ -99,7 +97,7 @@ function delete(obj) function createFigure(obj) obj.Figure = uifigure( ... Visible="off", ... - Name="LabKit Session Log", ... + Name=char(obj.Runtime.sessionLogTitle()), ... Position=viewerPosition(), ... AutoResizeChildren="off", ... CloseRequestFcn=@(~, ~) obj.close(), ... @@ -116,9 +114,9 @@ function createFigure(obj) Tag="labkitSessionLogSummary"); obj.SummaryLabel.Layout.Row = 1; - filters = uigridlayout(root, [2 8], ... + filters = uigridlayout(root, [2 4], ... RowHeight={30, 30}, ... - ColumnWidth={55, "1x", 45, 110, 40, 155, 70, 90}, ... + ColumnWidth={55, "1x", 50, 170}, ... Padding=[0 0 0 0], RowSpacing=4, ColumnSpacing=5); filters.Layout.Row = 2; label = uilabel(filters, Text="Search"); @@ -133,32 +131,15 @@ function createFigure(obj) label.Layout.Row = 1; label.Layout.Column = 3; obj.LevelFilter = uidropdown(filters, ... - Items=["Default", "TRACE+", "DEBUG+", "INFO+", ... - "WARNING+", "ERROR+", "CRITICAL"], ... - ItemsData=["default", "trace", "debug", "info", ... - "warning", "error", "critical"], ... + Items=["Full TRACE", "DEBUG", "User"], ... + ItemsData=["trace", "debug", "user"], ... + Tooltip=["Full TRACE shows every retained detail; DEBUG " + ... + "hides trace stages; User shows user-facing INFO and above."], ... ValueChangedFcn=@(~, ~) obj.applyFilters(), ... Tag="labkitSessionLogLevel"); - obj.LevelFilter.Value = "default"; + obj.LevelFilter.Value = "trace"; obj.LevelFilter.Layout.Row = 1; obj.LevelFilter.Layout.Column = 4; - label = uilabel(filters, Text="View", ... - Tooltip="Choose user workflow events, developer internals, or both.", ... - Tag="labkitSessionLogAudienceLabel"); - label.Layout.Row = 1; - label.Layout.Column = 5; - obj.AudienceFilter = uidropdown(filters, ... - Items=["Useful (default)", "Everything", ... - "User workflow", "Developer details"], ... - ItemsData=["default", "all", "user", "developer"], ... - Tooltip=[ ... - "Useful shows user events plus developer warnings and errors. " ... - "Developer details requires DEBUG+ to show normal callback boundaries."], ... - ValueChangedFcn=@(~, ~) obj.applyFilters(), ... - Tag="labkitSessionLogAudience"); - obj.AudienceFilter.Value = "default"; - obj.AudienceFilter.Layout.Row = 1; - obj.AudienceFilter.Layout.Column = 6; label = uilabel(filters, Text="Area"); label.Layout.Row = 2; label.Layout.Column = 1; @@ -180,21 +161,19 @@ function createFigure(obj) Tag="labkitSessionLogRoot"); obj.RootFilter.Value = ""; obj.RootFilter.Layout.Row = 2; - obj.RootFilter.Layout.Column = [4 8]; - obj.FollowButton = uibutton(filters, ... - Text="Pause follow", ... - ButtonPushedFcn=@(~, ~) obj.toggleFollow(), ... - Tag="labkitSessionLogFollow"); - obj.FollowButton.Layout.Row = 1; - obj.FollowButton.Layout.Column = [7 8]; + obj.RootFilter.Layout.Column = 4; - noticeGrid = uigridlayout(root, [1 5], ... - ColumnWidth={"1x", 80, 90, 110, 150}, ... + noticeGrid = uigridlayout(root, [1 6], ... + ColumnWidth={"1x", 100, 80, 90, 110, 175}, ... Padding=[0 0 0 0], ColumnSpacing=6); noticeGrid.Layout.Row = 3; obj.NoticeLabel = uilabel(noticeGrid, ... Text="", FontColor=[0.45 0.25 0], ... Tag="labkitSessionLogNotices"); + obj.TraceButton = uibutton(noticeGrid, ... + Text="Enable TRACE", ... + ButtonPushedFcn=@(~, ~) obj.toggleTraceCapture(), ... + Tag="labkitSessionLogTraceCapture"); uibutton(noticeGrid, Text="Refresh", ... ButtonPushedFcn=@(~, ~) obj.refresh(), ... Tag="labkitSessionLogRefresh"); @@ -206,7 +185,7 @@ function createFigure(obj) ButtonPushedFcn=@(~, ~) obj.copyDetails(), ... Tag="labkitSessionLogCopy"); uibutton(noticeGrid, ... - Text="Export diagnostic ZIP", ... + Text="Export Diagnostic Bundle", ... ButtonPushedFcn=@(~, ~) ... obj.Runtime.exportDiagnosticBundleInteractive(), ... Tag="labkitSessionLogExport"); @@ -219,11 +198,14 @@ function createFigure(obj) CellSelectionCallback=@(~, event) ... obj.selectRow(event), ... Tag="labkitSessionLogTable"); + if isprop(obj.EventTable, "SelectionType") + obj.EventTable.SelectionType = "row"; + end obj.EventTable.Layout.Row = 4; obj.DetailArea = uitextarea(root, ... Editable="off", ... - Value="Select an event to inspect safe structured details.", ... + Value="Select an event to inspect complete structured details.", ... FontName="Consolas", ... Tag="labkitSessionLogDetail"); obj.DetailArea.Layout.Row = 5; @@ -243,7 +225,6 @@ function acceptRecord(obj, record) function applyFilters(obj) obj.Projection.setFilters( ... Level=string(obj.LevelFilter.Value), ... - Audience=string(obj.AudienceFilter.Value), ... Category=string(obj.CategoryFilter.Value), ... RootAction=string(obj.RootFilter.Value), ... Search=string(obj.SearchField.Value)); @@ -253,19 +234,15 @@ function applyFilters(obj) function clearView(obj) obj.Projection.clearView(); obj.DetailArea.Value = ... - "Select an event to inspect safe structured details."; + "Select an event to inspect complete structured details."; obj.CopyButton.Enable = "off"; obj.refreshView(); end - function toggleFollow(obj) - obj.FollowLatest = ~obj.FollowLatest; - if obj.FollowLatest - obj.FollowButton.Text = "Pause follow"; - obj.followLatest(); - else - obj.FollowButton.Text = "Follow latest"; - end + function toggleTraceCapture(obj) + snapshot = obj.Runtime.diagnosticSnapshot(); + obj.Runtime.setTraceCapture(~snapshot.traceEnabled); + obj.refresh(); end function refreshView(obj, incremental) @@ -276,6 +253,11 @@ function refreshView(obj, incremental) return; end projection = obj.Projection.view(); + if projection.traceEnabled + obj.TraceButton.Text = "Disable TRACE"; + else + obj.TraceButton.Text = "Enable TRACE"; + end obj.updateChoices( ... obj.CategoryFilter, projection.categories); obj.updateChoices(obj.RootFilter, projection.rootActions, ... @@ -309,7 +291,7 @@ function refreshView(obj, incremental) projection.notices, " | ")); obj.NoticeLabel.FontColor = [0.55 0.28 0]; end - if obj.FollowLatest && (appended || ~incremental) + if appended || ~incremental obj.followLatest(); end end @@ -452,6 +434,7 @@ function applySeverityStyle(obj, level, row) "Severity: " + string(record.severity) + ... " Audience: " + string(record.audience) "Category: " + string(record.category) + "Message: " + string(record.message) "Operation: " + string(record.operationId) "Parent: " + string(record.parentOperationId) "Root action: " + string(record.rootActionId) @@ -459,6 +442,7 @@ function applySeverityStyle(obj, level, row) " State: " + string(record.stateDisposition) "Duration (s): " + numericText(record.durationSeconds) "Exception: " + string(exception.identifier) + "Exception message: " + string(exception.message) "Attributes:" attributes "Stack:" diff --git a/+labkit/+app/+internal/InteractionSpec.m b/+labkit/+app/+internal/+interaction/InteractionSpec.m similarity index 95% rename from +labkit/+app/+internal/InteractionSpec.m rename to +labkit/+app/+internal/+interaction/InteractionSpec.m index e084fe999..9e93ba64f 100644 --- a/+labkit/+app/+internal/InteractionSpec.m +++ b/+labkit/+app/+internal/+interaction/InteractionSpec.m @@ -37,7 +37,7 @@ end obj.Targets = strings(1, 0); obj.Capabilities = obj.Kind; - obj.Signals = {labkit.app.internal.SignalBinding( ... + obj.Signals = {labkit.app.internal.contract.SignalBinding( ... obj.Id, "interactionChanged", onChanged)}; obj.Options = optionValue(options, "Style", struct()); if ~isstruct(obj.Options) || ~isscalar(obj.Options) @@ -52,12 +52,12 @@ background = optionValue(options, "OnBackgroundPressed", []); if ~isempty(background) - obj.Signals{end + 1} = labkit.app.internal.SignalBinding( ... + obj.Signals{end + 1} = labkit.app.internal.contract.SignalBinding( ... obj.Id, "backgroundPressed", background); end scrolled = optionValue(options, "OnScrolled", []); if ~isempty(scrolled) - obj.Signals{end + 1} = labkit.app.internal.SignalBinding( ... + obj.Signals{end + 1} = labkit.app.internal.contract.SignalBinding( ... obj.Id, "scrolled", scrolled); end end diff --git a/+labkit/+app/+internal/+interaction/addOrInsertAnchor.m b/+labkit/+app/+internal/+interaction/addOrInsertAnchor.m new file mode 100644 index 000000000..d7a25e24e --- /dev/null +++ b/+labkit/+app/+internal/+interaction/addOrInsertAnchor.m @@ -0,0 +1,124 @@ +% Internal anchor-path insertion policy. Expected caller: the native anchor +% editor. Inputs are ordered image-pixel anchors, one candidate point, image +% geometry, curve style, closure, and point limit. Output is the updated +% ordered anchor array. Side effects: none. +function points = addOrInsertAnchor( ... + points, newPoint, imageSize, curveStyle, closed, maxPoints) +%ADDORINSERTANCHOR Add a point at its nearest visible path location. + +% Open paths use the nearest location on the complete visible curve. A point +% whose nearest location is the first or last path endpoint extends that end; +% every other point is inserted after the owning visible segment. This avoids +% view-size thresholds that could make the same click append or insert after +% zooming. Closed paths always insert after the nearest visible segment. + +% Expected caller: +% The private native anchor editor used by anchorPath interactions. + +% Inputs: +% points - Existing N-by-2 ordered anchor coordinates. +% newPoint - Candidate 1-by-2 image-pixel coordinate. +% imageSize - Image size vector beginning with height and width. +% curveStyle - "Curve" or "Straight lines". +% closed - Logical scalar selecting an open or closed path. +% maxPoints - Positive integer or Inf. At capacity, the nearest anchor is +% replaced instead of growing the collection. + +% Outputs: +% points - Updated N-by-2 ordered anchor coordinates. + +% Side effects: +% None. + + n = size(points, 1); + if n < 2 + points(end + 1, :) = newPoint; + return; + end + + if isfinite(maxPoints) && n >= maxPoints + idx = nearestPointIndex(points, newPoint); + points(idx, :) = newPoint; + return; + end + + [segmentIdx, endpoint] = nearestVisibleLocation( ... + points, newPoint, imageSize, curveStyle, closed); + if isempty(segmentIdx) + points(end + 1, :) = newPoint; + return; + end + + if closed || endpoint == "interior" + points = insertAnchorAfterSegment(points, newPoint, segmentIdx); + return; + end + + if endpoint == "start" + points = [newPoint; points]; + else + points = [points; newPoint]; + end +end + +function idx = nearestPointIndex(points, point) + [~, idx] = min(hypot(points(:, 1) - point(1), ... + points(:, 2) - point(2))); +end + +function points = insertAnchorAfterSegment(points, newPoint, segmentIdx) + points = [points(1:segmentIdx, :); newPoint; ... + points((segmentIdx + 1):end, :)]; +end + +function [segmentIdx, endpoint] = nearestVisibleLocation( ... + points, point, imageSize, curveStyle, closed) + segmentIdx = []; + endpoint = "interior"; + [curve, owners] = labkit.app.interaction.interpolateAnchorPath( ... + points, imageSize, "Style", string(curveStyle), "Closed", closed); + segmentCount = size(curve, 1) - 1; + if segmentCount < 1 + return; + end + + bestDistance = inf; + bestCurveSegment = 0; + bestFraction = 0; + for index = 1:segmentCount + [distance, fraction] = pointSegmentDistance( ... + point, curve(index, :), curve(index + 1, :)); + if distance < bestDistance + bestDistance = distance; + bestCurveSegment = index; + bestFraction = fraction; + end + end + segmentIdx = owners(bestCurveSegment); + if closed + return; + end + + endpointTolerance = 1e-9; + if bestCurveSegment == 1 && bestFraction <= endpointTolerance + endpoint = "start"; + elseif bestCurveSegment == segmentCount && ... + bestFraction >= 1 - endpointTolerance + endpoint = "end"; + end +end + +function [distance, fraction] = pointSegmentDistance(point, a, b) + ab = b - a; + denom = dot(ab, ab); + if denom <= eps + fraction = 0; + distance = hypot(point(1) - a(1), point(2) - a(2)); + return; + end + fraction = dot(point - a, ab) / denom; + fraction = min(max(fraction, 0), 1); + projection = a + fraction .* ab; + distance = hypot(point(1) - projection(1), ... + point(2) - projection(2)); +end diff --git a/+labkit/+app/+internal/+launcher/appCatalogTable.m b/+labkit/+app/+internal/+launcher/appCatalogTable.m new file mode 100644 index 000000000..963f29a54 --- /dev/null +++ b/+labkit/+app/+internal/+launcher/appCatalogTable.m @@ -0,0 +1,25 @@ +function catalog = appCatalogTable(apps) +%APPCATALOGTABLE Convert normalized launcher entries to the list-mode table. +count = numel(apps); +commandColumn = strings(count, 1); displayNameColumn = strings(count, 1); +familyColumn = strings(count, 1); visibilityColumn = strings(count, 1); +folderColumn = strings(count, 1); relativePathColumn = strings(count, 1); +descriptionColumn = strings(count, 1); versionColumn = strings(count, 1); +updatedColumn = strings(count, 1); +for index = 1:count + commandColumn(index) = apps(index).command; + displayNameColumn(index) = apps(index).name; + familyColumn(index) = apps(index).family; + visibilityColumn(index) = apps(index).visibility; + folderColumn(index) = apps(index).folder; + relativePathColumn(index) = apps(index).relativePath; + descriptionColumn(index) = apps(index).description; + versionColumn(index) = apps(index).version; + updatedColumn(index) = apps(index).updated; +end +catalog = table(commandColumn, displayNameColumn, familyColumn, ... + visibilityColumn, folderColumn, relativePathColumn, descriptionColumn, ... + versionColumn, updatedColumn, ... + 'VariableNames', {'Command', 'DisplayName', 'Family', 'Visibility', ... + 'Folder', 'RelativePath', 'Description', 'Version', 'Updated'}); +end diff --git a/+labkit/+app/+internal/+launcher/createLauncher.m b/+labkit/+app/+internal/+launcher/createLauncher.m new file mode 100644 index 000000000..1e90c6d0c --- /dev/null +++ b/+labkit/+app/+internal/+launcher/createLauncher.m @@ -0,0 +1,714 @@ +function fig = createLauncher(root) +%CREATELAUNCHER Build and wire the stateful native launcher window. +% ROOT is the checkout or installed package root. The returned uifigure owns +% all callback state; discovery and documentation remain separate owners. +panelFontSize = 15; +tableFontSize = 12; +version = labkit.app.internal.launcher.launcherVersion(); +position = defaultLauncherPosition(); +figArgs = { ... + "Name", version.displayName + " v" + version.version + ... + " (" + version.updated + ")", ... + "Tag", "labkitLauncher", ... + "Position", position, ... + "AutoResizeChildren", "off", ... + "Color", [0.97 0.98 0.99]}; +if launcherGuiTestMode() == "hidden" + figArgs = [figArgs, {"Visible", "off"}]; +end +close(findall(groot, "Type", "figure", "Tag", "labkitLauncher")); +fig = uifigure(figArgs{:}); +rootPanel = uipanel(fig, ... + "BorderType", "none", ... + "BackgroundColor", fig.Color, ... + "Units", "pixels", ... + "Position", [1 1 position(3:4)]); +main = uigridlayout(rootPanel, [1 3]); +leftWidth = launcherControlWidth(position(3)); +main.ColumnWidth = {leftWidth, 5, "1x"}; +main.RowHeight = {"1x"}; +main.Padding = [6 6 6 6]; +main.ColumnSpacing = 0; + +left = uipanel(main, "Title", "Launcher", "FontSize", panelFontSize); +left.Layout.Column = 1; +divider = uipanel(main, "BorderType", "none", ... + "BackgroundColor", [0.78 0.80 0.82]); +divider.Layout.Column = 2; +right = uipanel(main, "Title", "Applications", "FontSize", panelFontSize); +right.Layout.Column = 3; + +controls = uigridlayout(left, [5 1]); +controls.RowHeight = {108, 72, 108, 72, "1x"}; +controls.Padding = [6 6 6 6]; +controls.RowSpacing = 6; + +runPanel = uipanel(controls, "Title", "Run Apps"); +runPanel.Layout.Row = 1; +runGrid = uigridlayout(runPanel, [2 2]); +runGrid.RowHeight = {"1x", "1x"}; +runGrid.ColumnWidth = {"1x", "1x"}; +runGrid.Padding = [5 5 5 5]; +runGrid.RowSpacing = 5; +runGrid.ColumnSpacing = 6; +openButton = uibutton(runGrid, "Text", "Open Selected App"); +openButton.Layout.Row = 1; +openButton.Layout.Column = [1 2]; +refreshButton = uibutton(runGrid, "Text", "Refresh App List"); +refreshButton.Layout.Row = 2; +refreshButton.Layout.Column = 1; +appDocsButton = uibutton(runGrid, "Text", "Documentation and History"); +appDocsButton.Layout.Row = 2; +appDocsButton.Layout.Column = 2; +appDocsButton.Tooltip = ... + "Open the online documentation page for the selected app."; + +versionPanel = uipanel(controls, "Title", "Versions and Install"); +versionPanel.Layout.Row = 2; +versionGrid = uigridlayout(versionPanel, [1 3]); +versionGrid.ColumnWidth = {"1x", "1x", "1x"}; +versionGrid.Padding = [5 5 5 5]; +versionGrid.ColumnSpacing = 6; +latestButton = uibutton(versionGrid, "Text", "Latest"); +releaseButton = uibutton(versionGrid, "Text", "Release"); +versionsButton = uibutton(versionGrid, "Text", "Versions"); +latestButton.Tooltip = "Download and apply the latest main branch ZIP."; +releaseButton.Tooltip = "Download and apply the latest stable release."; +versionsButton.Tooltip = ... + "Choose a recent release, tag, or main-branch commit."; + +maintenancePanel = uipanel(controls, ... + "Title", "Development and Maintenance"); +maintenancePanel.Layout.Row = 3; +maintenanceGrid = uigridlayout(maintenancePanel, [2 2]); +maintenanceGrid.ColumnWidth = {"1x", "1x"}; +maintenanceGrid.RowHeight = {"1x", "1x"}; +maintenanceGrid.Padding = [5 5 5 5]; +maintenanceGrid.RowSpacing = 5; +maintenanceGrid.ColumnSpacing = 6; +docsToolButton = uibutton(maintenanceGrid, ... + "Text", "Doc Generation"); +codeButton = uibutton(maintenanceGrid, "Text", "Run Code Analyzer"); +profileButton = uibutton(maintenanceGrid, "Text", "Profile Selected App"); +cleanButton = uibutton(maintenanceGrid, "Text", "Clean Artifacts"); +docsToolButton.Tooltip = ... + "Rebuild the ignored local site from current documentation sources."; +codeButton.Tooltip = ... + "Run MATLAB Code Analyzer and write the repository report."; +profileButton.Tooltip = ... + "Profile the selected app and save its report without opening a browser."; +cleanButton.Tooltip = ... + "Remove generated artifacts through the maintenance tool."; + +packagePanel = uipanel(controls, "Title", "Package and Publish"); +packagePanel.Layout.Row = 4; +packageGrid = uigridlayout(packagePanel, [1 2]); +packageGrid.ColumnWidth = {"1x", "1x"}; +packageGrid.Padding = [5 5 5 5]; +packageGrid.ColumnSpacing = 6; +packageButton = uibutton(packageGrid, "Text", "Package Checked"); +pcodeButton = uibutton(packageGrid, "Text", "Checked P-code"); +packageButton.Tooltip = ... + "Create one standalone source package containing every checked app."; +pcodeButton.Tooltip = ... + "Create the same multi-app package with MATLAB code encoded as P-code."; + +status = uitextarea(controls, "Editable", "off", "Value", "Ready."); +status.Layout.Row = 5; +tableGrid = uigridlayout(right, [1 1]); +tableGrid.Padding = [4 4 4 4]; +appTable = uitable(tableGrid, ... + "ColumnName", { ... + "Package", "Family", "App", "Version", "Access", "Updated"}, ... + "ColumnEditable", [true false false false false false], ... + "RowName", {}, ... + "FontSize", tableFontSize); +if isprop(appTable, "ColumnFormat") + appTable.ColumnFormat = { ... + 'logical', 'char', 'char', 'char', 'char', 'char'}; +end +appTable.ColumnWidth = launcherTableWidths(position(3), leftWidth); +configureTable(appTable, @selectRow, @doubleClickRow); +appTable.CellEditCallback = @changePackageSelection; + +setappdata(groot, "labkitFigureStudioLauncher", ... + @(ax) launchFigureStudioFromAxes(root, ax)); +view = struct( ... + "figure", fig, ... + "controls", struct( ... + "selectedDetails", struct("textArea", status), ... + "statusLine", struct("textArea", status), ... + "appTable", struct("table", appTable))); +setappdata(fig, "labkitLauncherView", view); +state = struct( ... + "apps", labkit.app.internal.launcher.emptyApps(), ... + "selected", 1, ... + "checkedCommands", strings(0, 1), ... + "status", "Loading app list...", ... + "busy", false, ... + "tools", launcherToolAvailability(root)); + +openButton.ButtonPushedFcn = @(~, ~) launchSelected(); +refreshButton.ButtonPushedFcn = @(~, ~) refreshApps(); +appDocsButton.ButtonPushedFcn = @(~, ~) openDocumentation("online"); +latestButton.ButtonPushedFcn = @(~, ~) manageVersion("main"); +releaseButton.ButtonPushedFcn = @(~, ~) manageVersion("stable"); +versionsButton.ButtonPushedFcn = @(~, ~) manageVersion("browse"); +cleanButton.ButtonPushedFcn = @(~, ~) runMaintenance("clean"); +docsToolButton.ButtonPushedFcn = @(~, ~) runMaintenance("docs"); +codeButton.ButtonPushedFcn = @(~, ~) runMaintenance("codecheck"); +profileButton.ButtonPushedFcn = @(~, ~) runMaintenance("profile"); +packageButton.ButtonPushedFcn = @(~, ~) packageChecked("source"); +pcodeButton.ButtonPushedFcn = @(~, ~) packageChecked("pcode"); +refreshApps(); +fig.SizeChangedFcn = @(~, ~) resizeLauncher(); +resizeLauncher(); + + function refreshApps() + if state.busy + return; + end + selectedCommand = currentSelectedCommand(); + beginAction("Refreshing app list..."); + try + state.apps = labkit.app.internal.launcher.discoverApps(root); + state.tools = launcherToolAvailability(root); + state.checkedCommands = retainedCommands( ... + state.apps, state.checkedCommands); + state.selected = appRowByCommand(state.apps, selectedCommand); + appTable.Data = launcherRows(state.apps, state.checkedCommands); + setStatus(appAvailabilityStatus(state.apps)); + catch cause + setStatus("Refresh app list failed: " + failureText(cause)); + end + endAction(); + end + + function resizeLauncher() + if ~isvalid(fig) || ~isvalid(appTable) + return; + end + figureWidth = fig.Position(3); + rootPanel.Position = [1 1 fig.Position(3:4)]; + resizedControlWidth = launcherControlWidth(figureWidth); + main.ColumnWidth = {resizedControlWidth, 5, "1x"}; + appTable.ColumnWidth = launcherTableWidths( ... + figureWidth, resizedControlWidth); + end + + function selectRow(~, event) + row = eventRow(event); + if ~isnan(row) + state.selected = row; + updateInfo(); + end + end + + function doubleClickRow(~, event) + row = eventRow(event); + if ~isnan(row) + state.selected = row; + end + launchSelected(); + end + + function changePackageSelection(~, event) + if isempty(event.Indices) || isempty(state.apps) + return; + end + row = event.Indices(1, 1); + if row < 1 || row > numel(state.apps) + return; + end + command = string(state.apps(row).command); + state.checkedCommands(state.checkedCommands == command) = []; + if logical(event.NewData) + state.checkedCommands(end + 1, 1) = command; + end + updateInfo(); + end + + function launchSelected() + if state.busy || isempty(state.apps) + return; + end + app = selectedApp(); + openButton.Text = "Starting App..."; + beginAction("Starting " + app.name + "..."); + try + reportLaunchStage(app, 1, "preparing app path"); + addPathIfMissing(app.folder, "-end"); + reportLaunchStage(app, 2, ... + "initializing app window via " + app.command); + invokeDiscoveredApp(app); + setStatus("Finishing startup for " + app.name + "..."); + drawnow; + setStatus("Opened " + app.command + "."); + catch cause + if isStructuralStartupFailure(cause) + setStatus([ ... + "Could not start " + app.command + ": " + ... + failureText(cause) + "The installation may be incomplete. Run " + ... + "labkit_launcher(""repair"") to reinstall." + ]); + else + setStatus("App " + app.command + " reported: " + ... + failureText(cause)); + end + end + openButton.Text = "Open Selected App"; + endAction(); + end + + function reportLaunchStage(app, step, message) + setStatus("Starting " + app.name + " (" + string(step) + ... + "/2): " + message + "..."); + drawnow; + end + + function openDocumentation(source) + if state.busy || isempty(state.apps) + return; + end + app = selectedApp(); + beginAction("Opening " + source + " documentation for " + ... + app.command + "..."); + try + page = labkit.app.internal.launcher.documentationPage( ... + root, app.command, source); + if launcherGuiTestMode() ~= "hidden" + browserStatus = web(char(page), "-browser"); + if browserStatus ~= 0 + error( ... + "labkit:app:internal:launcher:BrowserUnavailable", ... + "The system browser could not open the documentation."); + end + end + setStatus("Opened " + source + " documentation for " + ... + app.command + "."); + catch cause + setStatus("Documentation unavailable: " + failureText(cause)); + end + endAction(); + end + + function manageVersion(mode) + if state.busy + return; + end + beginAction("Preparing LabKit version tools..."); + try + if mode == "browse" + callTool(root, fullfile("tools", "deployment"), ... + "manageLabKitVersions", root, mode, ... + "ProgressFcn", @reportProgress); + setStatus("Opened LabKit Version Manager."); + else + result = callTool(root, fullfile("tools", "deployment"), ... + "manageLabKitVersions", root, mode, ... + "ProgressFcn", @reportProgress); + setStatus(result.message); + end + catch cause + setStatus("Version action failed: " + failureText(cause)); + end + endAction(); + end + + function runMaintenance(kind) + if state.busy + return; + end + beginAction("Running " + kind + "..."); + try + switch kind + case "clean" + result = callTool(root, fullfile("tools", "maintenance"), ... + "cleanLabKitArtifacts", root, ... + "ProgressFcn", @reportProgress); + setStatus("Clean Artifacts complete: " + ... + string(result.removedCount) + " target(s) removed."); + case "docs" + callTool(root, fullfile("tools", "docs"), ... + "renderLabKitDocs", fullfile(root, "docs"), ... + fullfile(root, "site")); + setStatus("Local documentation generated from current sources."); + case "codecheck" + callTool(root, fullfile("tools", "codecheck"), ... + "runCodecheckReport", root, ... + "ProgressFcn", @reportProgress); + setStatus("Code Analyzer report completed."); + case "profile" + app = selectedApp(); + callTool(root, fullfile("tools", "profiling"), ... + "profileLabKitTarget", app.command, [], ... + "OpenReport", false, "WaitForGuiClose", false); + setStatus("Performance profile completed for " + ... + app.command + "."); + end + catch cause + setStatus("Tool failed: " + failureText(cause)); + end + endAction(); + end + + function packageChecked(codeFormat) + if state.busy + return; + end + apps = checkedApps(state.apps, state.checkedCommands); + if isempty(apps) + setStatus("Check one or more apps in the Package column first."); + return; + end + commands = string({apps.command}); + beginAction("Packaging " + packageSummary(commands) + "..."); + try + result = callTool(root, fullfile("tools", "deployment"), ... + "packageLabKitApp", commands, [], ... + "Root", root, "CodeFormat", codeFormat, ... + "ProgressFcn", @reportProgress); + setStatus("Packaged " + packageSummary(commands) + ... + " at " + string(result.zipFile) + "."); + catch cause + setStatus("Package failed: " + failureText(cause)); + end + endAction(); + end + + function reportProgress(message, ~) + setStatus(message); + drawnow limitrate; + end + + function app = selectedApp() + if isempty(state.apps) + error("labkit:app:internal:launcher:NoAppSelected", ... + "Select an app before using this action."); + end + row = min(max(state.selected, 1), numel(state.apps)); + app = state.apps(row); + end + + function command = currentSelectedCommand() + command = ""; + if ~isempty(state.apps) + command = string(selectedApp().command); + end + end + + function beginAction(message) + state.busy = true; + fig.Pointer = "watch"; + setControlsEnabled(false); + setStatus(message); + drawnow; + end + + function endAction() + state.busy = false; + if isvalid(fig) + fig.Pointer = "arrow"; + end + setControlsEnabled(true); + updateInfo(); + end + + function setControlsEnabled(enabled) + value = matlab.lang.OnOffSwitchState(enabled); + refreshButton.Enable = value; + latestButton.Enable = matlab.lang.OnOffSwitchState( ... + enabled && state.tools.version); + releaseButton.Enable = latestButton.Enable; + versionsButton.Enable = latestButton.Enable; + cleanButton.Enable = matlab.lang.OnOffSwitchState( ... + enabled && state.tools.clean); + docsToolButton.Enable = matlab.lang.OnOffSwitchState( ... + enabled && state.tools.docs); + codeButton.Enable = matlab.lang.OnOffSwitchState( ... + enabled && state.tools.codecheck); + hasApps = ~isempty(state.apps); + openButton.Enable = matlab.lang.OnOffSwitchState(enabled && hasApps); + appDocsButton.Enable = openButton.Enable; + profileButton.Enable = matlab.lang.OnOffSwitchState( ... + enabled && hasApps && state.tools.profile); + packageButton.Enable = matlab.lang.OnOffSwitchState( ... + enabled && hasApps && state.tools.package); + pcodeButton.Enable = packageButton.Enable; + appTable.Enable = char(value); + end + + function setStatus(message) + state.status = string(message); + updateInfo(); + end + + function updateInfo() + details = selectedAppDetails(state.apps, state.selected); + details(end + 1, 1) = "Checked for package: " + ... + numel(state.checkedCommands) + " app(s)"; + status.Value = [ ... + "Status: " + state.status + "" + details + ]; + end +end + +function position = defaultLauncherPosition() +screen = double(get(groot, "ScreenSize")); +screenWidth = screen(3); +screenHeight = screen(4); +width = min(screenWidth, max(800, min(1280, screenWidth - 80))); +height = min(screenHeight, max(560, min(720, screenHeight - 120))); +x = screen(1) + max(0, (screenWidth - width) / 2); +y = screen(2) + max(0, (screenHeight - height) / 2); +position = round([x y width height]); +end + +function width = launcherControlWidth(figureWidth) +width = min(390, max(350, round(double(figureWidth) * 0.29))); +end + +function widths = launcherTableWidths(figureWidth, controlWidth) +tableWidth = max(640, double(figureWidth) - double(controlWidth) - 36); +minimum = [62 120 180 70 72 90]; +preferred = [72 150 240 78 80 100]; +if tableWidth <= sum(minimum) + values = minimum; +elseif tableWidth < sum(preferred) + fraction = (tableWidth - sum(minimum)) / ... + (sum(preferred) - sum(minimum)); + values = minimum + fraction .* (preferred - minimum); +else + extra = tableWidth - sum(preferred); + values = preferred + extra .* [0 0.25 0.60 0 0 0.15]; +end +widths = num2cell(round(values)); +end + +function configureTable(tableHandle, selectionCallback, doubleClickCallback) +if isprop(tableHandle, "SelectionChangedFcn") + tableHandle.SelectionChangedFcn = selectionCallback; +else + tableHandle.CellSelectionCallback = selectionCallback; +end +if isprop(tableHandle, "SelectionType") + tableHandle.SelectionType = "row"; +end +if isprop(tableHandle, "DoubleClickedFcn") + tableHandle.DoubleClickedFcn = doubleClickCallback; +elseif isprop(tableHandle, "CellDoubleClickedFcn") + tableHandle.CellDoubleClickedFcn = doubleClickCallback; +end +end + +function row = eventRow(event) +row = NaN; +if isprop(event, "Indices") && ~isempty(event.Indices) + row = event.Indices(1, 1); +elseif isprop(event, "Selection") && ~isempty(event.Selection) + row = event.Selection(1, 1); +elseif isstruct(event) && isfield(event, "Indices") && ~isempty(event.Indices) + row = event.Indices(1, 1); +elseif isstruct(event) && isfield(event, "Selection") && ~isempty(event.Selection) + row = event.Selection(1, 1); +end +end + +function row = appRowByCommand(apps, command) +row = 1; +if isempty(apps) || strlength(string(command)) == 0 + return; +end +match = find(string({apps.command}) == string(command), 1); +if ~isempty(match) + row = match; +end +end + +function rows = launcherRows(apps, checkedCommands) +rows = cell(numel(apps), 6); +checked = ismember(string({apps.command}), string(checkedCommands)); +for index = 1:numel(apps) + rows(index, :) = { ... + checked(index), ... + char(apps(index).family), ... + char(apps(index).name), ... + char(apps(index).version), ... + char(apps(index).visibility), ... + char(apps(index).updated)}; +end +end + +function commands = retainedCommands(apps, commands) +available = string({apps.command}); +commands = string(commands(:)); +commands = commands(ismember(commands, available)); +end + +function apps = checkedApps(apps, commands) +if isempty(apps) + return; +end +apps = apps(ismember(string({apps.command}), string(commands))); +end + +function summary = packageSummary(commands) +if isscalar(commands) + summary = string(commands); +else + summary = string(numel(commands)) + " checked apps"; +end +end + +function details = selectedAppDetails(apps, selected) +if isempty(apps) + details = [ + "No app entry points found." + "Run labkit_launcher(""repair"") if the installation is incomplete." + ]; + return; +end +row = min(max(selected, 1), numel(apps)); +app = apps(row); +details = [ + string(app.name) + "Family: " + string(app.family) + "Visibility: " + string(app.visibility) + "Version: " + string(app.version) + "Updated: " + string(app.updated) + "Command: " + string(app.command) + "Path: " + string(app.folder) + ]; +end + +function message = appAvailabilityStatus(apps) +if isempty(apps) + message = "No app entry points found. Run labkit_launcher(""repair"") " + ... + "if the installation is incomplete."; +else + message = string(numel(apps)) + " app entry point(s) available."; +end +end + +function tools = launcherToolAvailability(root) +tools = struct( ... + "version", toolExists(root, "deployment", "manageLabKitVersions"), ... + "clean", toolExists(root, "maintenance", "cleanLabKitArtifacts"), ... + "docs", toolExists(root, "docs", "renderLabKitDocs"), ... + "codecheck", toolExists(root, "codecheck", "runCodecheckReport"), ... + "profile", toolExists(root, "profiling", "profileLabKitTarget"), ... + "package", toolExists(root, "deployment", "packageLabKitApp")); +end + +function tf = toolExists(root, area, name) +base = fullfile(root, "tools", area, name); +tf = exist(base + ".m", "file") == 2 || exist(base + ".p", "file") == 2; +end + +function addPathIfMissing(folder, varargin) +if exist(folder, "dir") == 7 && ~pathContains(folder) + addpath(folder, varargin{:}); +end +end + +function text = failureText(cause) +text = string(cause.message); +if strlength(string(cause.identifier)) > 0 + text = string(cause.identifier) + ": " + text; +end +end + +function varargout = callTool(root, relativeFolder, name, varargin) +folder = fullfile(root, relativeFolder); +if exist(fullfile(folder, name + ".m"), "file") ~= 2 && ... + exist(fullfile(folder, name + ".p"), "file") ~= 2 + error("labkit:app:internal:launcher:ToolUnavailable", "Tool is unavailable: %s", name); +end +added = ~pathContains(folder); +if added + addpath(folder, "-begin"); + cleanup = onCleanup(@() rmpath(folder)); +end +switch string(name) + case "manageLabKitVersions" + callable = @manageLabKitVersions; + case "cleanLabKitArtifacts" + callable = @cleanLabKitArtifacts; + case "renderLabKitDocs" + callable = @renderLabKitDocs; + case "runCodecheckReport" + callable = @runCodecheckReport; + case "profileLabKitTarget" + callable = @profileLabKitTarget; + case "packageLabKitApp" + callable = @packageLabKitApp; + otherwise + error("labkit:app:internal:launcher:UnknownTool", ... + "Launcher tool is not allowlisted: %s", name); +end +if nargout > 0 + [varargout{1:nargout}] = callable(varargin{:}); +else + callable(varargin{:}); +end +clear cleanup +end + +function invokeDiscoveredApp(app) +command = string(app.command); +resolved = string(which(char(command))); +expected = fullfile(string(app.folder), command + [".m", ".p"]); +available = arrayfun(@(candidate) exist(candidate, "file") == 2, expected); +expected = expected(available); +if strlength(resolved) == 0 || isempty(expected) || ... + ~any(normalizePathEntry(resolved) == normalizePathEntry(expected)) + error("labkit:app:internal:launcher:AppEntryMismatch", ... + "Discovered App entry does not resolve from its owning folder: %s", command); +end +% Dynamic extension boundary: the command is derived from and revalidated +% against one discovered labkit_*_app.m or .p file before invocation. +feval(char(command)); +end + +function tf = isStructuralStartupFailure(cause) +id = string(cause.identifier); +tf = startsWith(id, "MATLAB:UndefinedFunction") || ... + startsWith(id, "MATLAB:parse") || startsWith(id, "MATLAB:dispatcher"); +end + +function tf = pathContains(folder) +entries = string(strsplit(path, pathsep)); +target = normalizePathEntry(folder); +tf = any(normalizePathEntry(entries) == target); +end + +function value = normalizePathEntry(value) +values = string(value); +for index = 1:numel(values) + pathValue = java.nio.file.Paths.get(char(values(index)), javaArray("java.lang.String", 0)); + values(index) = string(pathValue.toAbsolutePath().normalize().toString()); +end +value = values; +if ispc, value = lower(value); end +end + +function launchFigureStudioFromAxes(root, ax) +folder = fullfile(root, "apps", "labkit_core", "figure_studio"); +if exist(folder, "dir") ~= 7 + error("labkit:app:internal:launcher:FigureStudioUnavailable", "Figure Studio is unavailable."); +end +if ~pathContains(folder) + addpath(folder, "-end"); +end +labkit_FigureStudio_app("axes", ax); +end + +function mode = launcherGuiTestMode() +mode = "visible"; +if isappdata(groot, "labkitLauncherGuiTestMode") + mode = string(getappdata(groot, "labkitLauncherGuiTestMode")); +end +end diff --git a/+labkit/+app/+internal/+launcher/discoverApps.m b/+labkit/+app/+internal/+launcher/discoverApps.m new file mode 100644 index 000000000..f3762b17a --- /dev/null +++ b/+labkit/+app/+internal/+launcher/discoverApps.m @@ -0,0 +1,223 @@ +function apps = discoverApps(root) +%DISCOVERAPPS Discover normalized public and private launcher entries. +% ROOT supplies the public Apps tree and optional local private roots. The +% returned struct array is sorted and contains no executable handles. +apps = labkit.app.internal.launcher.emptyApps(); +roots = [string(fullfile(root, "apps")); privateAppRoots(root)]; +entrySets = cell(numel(roots), 1); +entryCount = 0; +for rootIndex = 1:numel(roots) + entrySets{rootIndex} = appEntryFiles(roots(rootIndex)); + entryCount = entryCount + numel(entrySets{rootIndex}); +end +records = cell(entryCount, 1); +recordCount = 0; +for rootIndex = 1:numel(roots) + appRoot = roots(rootIndex); + if exist(appRoot, "dir") ~= 7 + continue; + end + entries = entrySets{rootIndex}; + for entryIndex = 1:numel(entries) + entry = entries(entryIndex); + if isHiddenImplementationPath(relativePath(appRoot, entry.folder)) + continue; + end + [~, command] = fileparts(entry.name); + filepath = fullfile(entry.folder, entry.name); + metadata = appVersionInfo(entry.folder); + family = familyName(appRoot, entry.folder); + if strlength(metadata.family) > 0 + family = metadata.family; + end + name = displayName(command); + if strlength(metadata.displayName) > 0 + name = metadata.displayName; + end + app = struct("command", scalarText(command, "command"), ... + "folder", scalarText(entry.folder, "folder"), ... + "relativePath", scalarText(relativePath(root, filepath), "relativePath"), ... + "family", scalarText(family, "family"), ... + "name", scalarText(name, "name"), ... + "description", scalarText(appDescription(filepath, command), "description"), ... + "visibility", scalarText(visibilityFor(root, entry.folder), "visibility"), ... + "version", scalarText(metadata.version, "version"), ... + "updated", scalarText(metadata.updated, "updated")); + recordCount = recordCount + 1; + records{recordCount} = app; + end +end +if recordCount > 0 + apps = [records{1:recordCount}]; +end +if ~isempty(apps) + keys = [reshape(string({apps.visibility}) == "private", [], 1), ... + reshape(string({apps.family}), [], 1), reshape(string({apps.name}), [], 1)]; + [~, order] = sortrows(keys); + apps = apps(order); +end +end + +function entries = appEntryFiles(appRoot) +entries = [dir(fullfile(char(appRoot), "**", "labkit_*_app.m")); ... + dir(fullfile(char(appRoot), "**", "labkit_*_app.p"))]; +entries = entries(~[entries.isdir]); +if isempty(entries), return; end +count = numel(entries); +paths = strings(count, 1); commands = strings(count, 1); isSource = false(count, 1); +for index = 1:count + paths(index) = string(fullfile(entries(index).folder, entries(index).name)); + [~, commands(index), extension] = fileparts(paths(index)); + isSource(index) = string(extension) == ".m"; +end +[~, order] = sortrows([commands, string(~isSource), paths]); +entries = entries(order); commands = string(commands(order)); +[~, keep] = unique(commands, "stable"); +entries = entries(keep); +end + +function roots = privateAppRoots(root) +parts = split(string(getenv("LABKIT_PRIVATE_APP_ROOTS")), pathsep); +localCandidates = strings(numel(parts) + 1, 1); +candidateCount = 0; +localRoot = fullfile(root, "private_apps", "apps"); +if exist(localRoot, "dir") == 7 + candidateCount = candidateCount + 1; localCandidates(candidateCount) = localRoot; +end +environmentRoots = string(getenv("LABKIT_PRIVATE_APP_ROOTS")); +if strlength(environmentRoots) == 0 + roots = unique(localCandidates(1:candidateCount), "stable"); + return; +end +for part = split(environmentRoots, pathsep).' + candidate = string(part); + if ~endsWith(replace(candidate, "\\", "/"), "/apps") + candidate = fullfile(candidate, "apps"); + end + if exist(candidate, "dir") == 7 + candidateCount = candidateCount + 1; + localCandidates(candidateCount) = candidate; + end +end +roots = unique(localCandidates(1:candidateCount), "stable"); +end + +function value = visibilityFor(root, folder) +if isDescendantPath(folder, fullfile(root, "apps")) + value = "public"; +else + value = "private"; +end +end + +function value = familyName(appRoot, folder) +parts = split(relativePath(appRoot, folder), "/"); +if isempty(parts) || strlength(parts(1)) == 0 + value = "Other"; +else + value = displayToken(parts(1)); +end +end + +function tf = isHiddenImplementationPath(relative) +parts = split(string(relative), "/"); +tf = any(parts == "private" | startsWith(parts, "+")); +end + +function relative = relativePath(root, folder) +root = string(root); folder = string(folder); +prefix = root + filesep; +if startsWith(folder, prefix, "IgnoreCase", ispc) + relative = extractAfter(folder, strlength(prefix)); +else + relative = folder; +end +relative = replace(relative, string(filesep), "/"); +end + +function tf = isDescendantPath(folder, ancestor) +folder = lower(replace(string(folder), string(filesep), "/")); +ancestor = lower(replace(string(ancestor), string(filesep), "/")); +tf = folder == ancestor || startsWith(folder, ancestor + "/"); +end + +function value = displayName(command) +value = erase(string(command), "labkit_"); +value = erase(value, "_app"); +value = displayToken(value); +end + +function value = displayToken(value) +words = split(replace(string(value), "_", " ")); +for index = 1:numel(words) + if lower(words(index)) == "labkit" + words(index) = "LabKit"; + else + words(index) = upper(extractBefore(words(index), 2)) + extractAfter(words(index), 1); + end +end +value = strjoin(cellstr(words), " "); +end + +function description = appDescription(filepath, command) +description = ""; +try + text = fileread(filepath); +catch + return; +end +lines = splitlines(string(text)); +prefix = "%" + upper(string(command)); +for index = 1:min(numel(lines), 20) + line = strtrim(lines(index)); + if startsWith(line, prefix) + description = strtrim(erase(extractAfter( ... + line, strlength(prefix)), "-")); + return; + elseif startsWith(line, "%") + cleaned = strtrim(extractAfter(line, 1)); + if strlength(cleaned) > 0 && ~startsWith(cleaned, "Usage") + description = cleaned; + return; + end + end +end +end + +function info = appVersionInfo(folder) +info = struct("version", "", "updated", "", ... + "displayName", "", "family", ""); +definitions = dir(fullfile(folder, "+*", "definition.m")); +if isempty(definitions) + return; +end +try + text = fileread(fullfile(definitions(1).folder, definitions(1).name)); + info.version = literalField(text, "AppVersion"); + info.updated = literalField(text, "Updated"); + info.displayName = literalField(text, "DisplayName"); + info.family = literalField(text, "Family"); +catch +end +end + +function value = literalField(text, field) +value = ""; +patterns = {[char(field) '\s*=\s*"([^"]+)"'], ... + ['"' char(field) '"\s*,\s*"([^"]+)"']}; +for index = 1:numel(patterns) + tokens = regexp(text, patterns{index}, "tokens", "once"); + if ~isempty(tokens) + value = string(tokens{1}); + return; + end +end +end + +function value = scalarText(value, field) +value = string(value); +if ~isscalar(value) + error("labkit:app:internal:launcher:InvalidMetadataShape", ... + "App metadata field %s must be scalar.", field); +end +end diff --git a/+labkit/+app/+internal/+launcher/dispatch.m b/+labkit/+app/+internal/+launcher/dispatch.m index 341d9b6b0..326940ed8 100644 --- a/+labkit/+app/+internal/+launcher/dispatch.m +++ b/+labkit/+app/+internal/+launcher/dispatch.m @@ -1,1061 +1,28 @@ function varargout = dispatch(root, varargin) -%DISPATCH Own the installed LabKit launcher composition and entry routing. -% Private capability. The root launcher owns repair only. - - [mode, modeArgs] = parseMode(varargin); - switch mode - case "list" - varargout = {appCatalogTable(discoverApps(root))}; - case "documentation" - varargout = {documentationPage( ... - root, modeArgs.command, modeArgs.source)}; - case "version" - varargout = {launcherVersion()}; - otherwise - if nargout > 1 - error("labkit:app:internal:launcher:TooManyOutputs", ... - "Launcher dispatch returns at most one figure."); - end - fig = createLauncher(root); - if nargout == 1 - varargout = {fig}; - end - end -end - -function [mode, modeArgs] = parseMode(args) -mode = "gui"; -modeArgs = struct("command", "", "source", "online"); -if isempty(args) - return; -end -if ismember(numel(args), [2 3]) && isTextScalar(args{1}) && ... - strcmpi(string(args{1}), "documentation") - if ~isTextScalar(args{2}) || ... - strlength(strtrim(string(args{2}))) == 0 - error("labkit:app:internal:launcher:InvalidInput", ... - "Documentation mode requires one nonempty app command."); - end - modeArgs.command = string(args{2}); - if numel(args) == 3 - if ~isTextScalar(args{3}) || ... - ~ismember(lower(string(args{3})), ["online", "local"]) - error("labkit:app:internal:launcher:InvalidInput", ... - "Documentation source must be online or local."); - end - modeArgs.source = lower(string(args{3})); - end - mode = "documentation"; - return; -end -if numel(args) ~= 1 || ~isTextScalar(args{1}) - error("labkit:app:internal:launcher:InvalidInput", ... - "Use no input, list, version, or documentation plus an app command and optional source."); -end -mode = lower(string(args{1})); -if ~ismember(mode, ["list", "version"]) - error("labkit:app:internal:launcher:InvalidInput", "Unsupported launcher mode: %s", mode); -end -end - -function fig = createLauncher(root) -panelFontSize = 15; -tableFontSize = 12; -version = launcherVersion(); -position = defaultLauncherPosition(); -figArgs = { ... - "Name", version.displayName + " v" + version.version + ... - " (" + version.updated + ")", ... - "Tag", "labkitLauncher", ... - "Position", position, ... - "AutoResizeChildren", "off", ... - "Color", [0.97 0.98 0.99]}; -if launcherGuiTestMode() == "hidden" - figArgs = [figArgs, {"Visible", "off"}]; -end -close(findall(groot, "Type", "figure", "Tag", "labkitLauncher")); -fig = uifigure(figArgs{:}); -rootPanel = uipanel(fig, ... - "BorderType", "none", ... - "BackgroundColor", fig.Color, ... - "Units", "pixels", ... - "Position", [1 1 position(3:4)]); -main = uigridlayout(rootPanel, [1 3]); -leftWidth = launcherControlWidth(position(3)); -main.ColumnWidth = {leftWidth, 5, "1x"}; -main.RowHeight = {"1x"}; -main.Padding = [6 6 6 6]; -main.ColumnSpacing = 0; - -left = uipanel(main, "Title", "Launcher", "FontSize", panelFontSize); -left.Layout.Column = 1; -divider = uipanel(main, "BorderType", "none", ... - "BackgroundColor", [0.78 0.80 0.82]); -divider.Layout.Column = 2; -right = uipanel(main, "Title", "Applications", "FontSize", panelFontSize); -right.Layout.Column = 3; - -controls = uigridlayout(left, [5 1]); -controls.RowHeight = {108, 72, 108, 72, "1x"}; -controls.Padding = [6 6 6 6]; -controls.RowSpacing = 6; - -runPanel = uipanel(controls, "Title", "Run Apps"); -runPanel.Layout.Row = 1; -runGrid = uigridlayout(runPanel, [2 2]); -runGrid.RowHeight = {"1x", "1x"}; -runGrid.ColumnWidth = {"1x", "1x"}; -runGrid.Padding = [5 5 5 5]; -runGrid.RowSpacing = 5; -runGrid.ColumnSpacing = 6; -openButton = uibutton(runGrid, "Text", "Open Selected App"); -openButton.Layout.Row = 1; -openButton.Layout.Column = [1 2]; -refreshButton = uibutton(runGrid, "Text", "Refresh App List"); -refreshButton.Layout.Row = 2; -refreshButton.Layout.Column = 1; -appDocsButton = uibutton(runGrid, "Text", "Documentation and History"); -appDocsButton.Layout.Row = 2; -appDocsButton.Layout.Column = 2; -appDocsButton.Tooltip = ... - "Open the online documentation page for the selected app."; - -versionPanel = uipanel(controls, "Title", "Versions and Install"); -versionPanel.Layout.Row = 2; -versionGrid = uigridlayout(versionPanel, [1 3]); -versionGrid.ColumnWidth = {"1x", "1x", "1x"}; -versionGrid.Padding = [5 5 5 5]; -versionGrid.ColumnSpacing = 6; -latestButton = uibutton(versionGrid, "Text", "Latest"); -releaseButton = uibutton(versionGrid, "Text", "Release"); -versionsButton = uibutton(versionGrid, "Text", "Versions"); -latestButton.Tooltip = "Download and apply the latest main branch ZIP."; -releaseButton.Tooltip = "Download and apply the latest stable release."; -versionsButton.Tooltip = ... - "Choose a recent release, tag, or main-branch commit."; - -maintenancePanel = uipanel(controls, ... - "Title", "Development and Maintenance"); -maintenancePanel.Layout.Row = 3; -maintenanceGrid = uigridlayout(maintenancePanel, [2 2]); -maintenanceGrid.ColumnWidth = {"1x", "1x"}; -maintenanceGrid.RowHeight = {"1x", "1x"}; -maintenanceGrid.Padding = [5 5 5 5]; -maintenanceGrid.RowSpacing = 5; -maintenanceGrid.ColumnSpacing = 6; -docsToolButton = uibutton(maintenanceGrid, ... - "Text", "Generate Local Documentation"); -codeButton = uibutton(maintenanceGrid, "Text", "Run Code Analyzer"); -profileButton = uibutton(maintenanceGrid, "Text", "Profile Selected App"); -cleanButton = uibutton(maintenanceGrid, "Text", "Clean Artifacts"); -docsToolButton.Tooltip = ... - "Rebuild the ignored local site from current documentation sources."; -codeButton.Tooltip = ... - "Run MATLAB Code Analyzer and write the repository report."; -profileButton.Tooltip = ... - "Profile the selected app and save its report without opening a browser."; -cleanButton.Tooltip = ... - "Remove generated artifacts through the maintenance tool."; - -packagePanel = uipanel(controls, "Title", "Package and Publish"); -packagePanel.Layout.Row = 4; -packageGrid = uigridlayout(packagePanel, [1 2]); -packageGrid.ColumnWidth = {"1x", "1x"}; -packageGrid.Padding = [5 5 5 5]; -packageGrid.ColumnSpacing = 6; -packageButton = uibutton(packageGrid, "Text", "Package Checked"); -pcodeButton = uibutton(packageGrid, "Text", "Checked P-code"); -packageButton.Tooltip = ... - "Create one standalone source package containing every checked app."; -pcodeButton.Tooltip = ... - "Create the same multi-app package with MATLAB code encoded as P-code."; - -status = uitextarea(controls, "Editable", "off", "Value", "Ready."); -status.Layout.Row = 5; -tableGrid = uigridlayout(right, [1 1]); -tableGrid.Padding = [4 4 4 4]; -appTable = uitable(tableGrid, ... - "ColumnName", { ... - "Package", "App", "Family", "Version", "Access", "Updated"}, ... - "ColumnEditable", [true false false false false false], ... - "RowName", {}, ... - "FontSize", tableFontSize); -if isprop(appTable, "ColumnFormat") - appTable.ColumnFormat = { ... - 'logical', 'char', 'char', 'char', 'char', 'char'}; -end -appTable.ColumnWidth = launcherTableWidths(position(3), leftWidth); -configureTable(appTable, @selectRow, @doubleClickRow); -appTable.CellEditCallback = @changePackageSelection; - -setappdata(groot, "labkitFigureStudioLauncher", ... - @(ax) launchFigureStudioFromAxes(root, ax)); -view = struct( ... - "figure", fig, ... - "controls", struct( ... - "selectedDetails", struct("textArea", status), ... - "statusLine", struct("textArea", status), ... - "appTable", struct("table", appTable))); -setappdata(fig, "labkitLauncherView", view); -state = struct( ... - "apps", emptyApps(), ... - "selected", 1, ... - "checkedCommands", strings(0, 1), ... - "status", "Loading app list...", ... - "busy", false, ... - "tools", launcherToolAvailability(root)); - -openButton.ButtonPushedFcn = @(~, ~) launchSelected(); -refreshButton.ButtonPushedFcn = @(~, ~) refreshApps(); -appDocsButton.ButtonPushedFcn = @(~, ~) openDocumentation("online"); -latestButton.ButtonPushedFcn = @(~, ~) manageVersion("main"); -releaseButton.ButtonPushedFcn = @(~, ~) manageVersion("stable"); -versionsButton.ButtonPushedFcn = @(~, ~) manageVersion("browse"); -cleanButton.ButtonPushedFcn = @(~, ~) runMaintenance("clean"); -docsToolButton.ButtonPushedFcn = @(~, ~) runMaintenance("docs"); -codeButton.ButtonPushedFcn = @(~, ~) runMaintenance("codecheck"); -profileButton.ButtonPushedFcn = @(~, ~) runMaintenance("profile"); -packageButton.ButtonPushedFcn = @(~, ~) packageChecked("source"); -pcodeButton.ButtonPushedFcn = @(~, ~) packageChecked("pcode"); -refreshApps(); -fig.SizeChangedFcn = @(~, ~) resizeLauncher(); -resizeLauncher(); - - function refreshApps() - if state.busy - return; - end - selectedCommand = currentSelectedCommand(); - beginAction("Refreshing app list..."); - try - state.apps = discoverApps(root); - state.tools = launcherToolAvailability(root); - state.checkedCommands = retainedCommands( ... - state.apps, state.checkedCommands); - state.selected = appRowByCommand(state.apps, selectedCommand); - appTable.Data = launcherRows(state.apps, state.checkedCommands); - setStatus(appAvailabilityStatus(state.apps)); - catch cause - setStatus("Refresh app list failed: " + failureText(cause)); - end - endAction(); - end - - function resizeLauncher() - if ~isvalid(fig) || ~isvalid(appTable) - return; - end - figureWidth = fig.Position(3); - rootPanel.Position = [1 1 fig.Position(3:4)]; - resizedControlWidth = launcherControlWidth(figureWidth); - main.ColumnWidth = {resizedControlWidth, 5, "1x"}; - appTable.ColumnWidth = launcherTableWidths( ... - figureWidth, resizedControlWidth); - end - - function selectRow(~, event) - row = eventRow(event); - if ~isnan(row) - state.selected = row; - updateInfo(); - end - end - - function doubleClickRow(~, event) - row = eventRow(event); - if ~isnan(row) - state.selected = row; - end - launchSelected(); - end - - function changePackageSelection(~, event) - if isempty(event.Indices) || isempty(state.apps) - return; - end - row = event.Indices(1, 1); - if row < 1 || row > numel(state.apps) - return; - end - command = string(state.apps(row).command); - state.checkedCommands(state.checkedCommands == command) = []; - if logical(event.NewData) - state.checkedCommands(end + 1, 1) = command; - end - updateInfo(); - end - - function launchSelected() - if state.busy || isempty(state.apps) - return; - end - app = selectedApp(); - openButton.Text = "Starting App..."; - beginAction("Starting " + app.name + "..."); - try - reportLaunchStage(app, 1, "preparing app path"); - addPathIfMissing(app.folder, "-end"); - reportLaunchStage(app, 2, ... - "initializing app window via " + app.command); - invokeDiscoveredApp(app); - setStatus("Finishing startup for " + app.name + "..."); - drawnow; - setStatus("Opened " + app.command + "."); - catch cause - if isStructuralStartupFailure(cause) - setStatus([ ... - "Could not start " + app.command + ": " + ... - failureText(cause) - "The installation may be incomplete. Run " + ... - "labkit_launcher(""repair"") to reinstall." - ]); - else - setStatus("App " + app.command + " reported: " + ... - failureText(cause)); - end - end - openButton.Text = "Open Selected App"; - endAction(); - end - - function reportLaunchStage(app, step, message) - setStatus("Starting " + app.name + " (" + string(step) + ... - "/2): " + message + "..."); - drawnow; - end - - function openDocumentation(source) - if state.busy || isempty(state.apps) - return; - end - app = selectedApp(); - beginAction("Opening " + source + " documentation for " + ... - app.command + "..."); - try - page = documentationPage(root, app.command, source); - if launcherGuiTestMode() ~= "hidden" - browserStatus = web(char(page), "-browser"); - if browserStatus ~= 0 - error( ... - "labkit:app:internal:launcher:BrowserUnavailable", ... - "The system browser could not open the documentation."); - end - end - setStatus("Opened " + source + " documentation for " + ... - app.command + "."); - catch cause - setStatus("Documentation unavailable: " + failureText(cause)); - end - endAction(); - end - - function manageVersion(mode) - if state.busy - return; - end - beginAction("Preparing LabKit version tools..."); - try - if mode == "browse" - callTool(root, fullfile("tools", "deployment"), ... - "manageLabKitVersions", root, mode, ... - "ProgressFcn", @reportProgress); - setStatus("Opened LabKit Version Manager."); - else - result = callTool(root, fullfile("tools", "deployment"), ... - "manageLabKitVersions", root, mode, ... - "ProgressFcn", @reportProgress); - setStatus(result.message); - end - catch cause - setStatus("Version action failed: " + failureText(cause)); - end - endAction(); - end - - function runMaintenance(kind) - if state.busy - return; - end - beginAction("Running " + kind + "..."); - try - switch kind - case "clean" - result = callTool(root, fullfile("tools", "maintenance"), ... - "cleanLabKitArtifacts", root, ... - "ProgressFcn", @reportProgress); - setStatus("Clean Artifacts complete: " + ... - string(result.removedCount) + " target(s) removed."); - case "docs" - callTool(root, fullfile("tools", "docs"), ... - "renderLabKitDocs", fullfile(root, "docs"), ... - fullfile(root, "site")); - setStatus("Local documentation generated from current sources."); - case "codecheck" - callTool(root, fullfile("tools", "codecheck"), ... - "runCodecheckReport", root, ... - "ProgressFcn", @reportProgress); - setStatus("Code Analyzer report completed."); - case "profile" - app = selectedApp(); - callTool(root, fullfile("tools", "profiling"), ... - "profileLabKitTarget", app.command, [], ... - "OpenReport", false, "WaitForGuiClose", false); - setStatus("Performance profile completed for " + ... - app.command + "."); - end - catch cause - setStatus("Tool failed: " + failureText(cause)); - end - endAction(); - end - - function packageChecked(codeFormat) - if state.busy - return; - end - apps = checkedApps(state.apps, state.checkedCommands); - if isempty(apps) - setStatus("Check one or more apps in the Package column first."); - return; - end - commands = string({apps.command}); - beginAction("Packaging " + packageSummary(commands) + "..."); - try - result = callTool(root, fullfile("tools", "deployment"), ... - "packageLabKitApp", commands, [], ... - "Root", root, "CodeFormat", codeFormat, ... - "ProgressFcn", @reportProgress); - setStatus("Packaged " + packageSummary(commands) + ... - " at " + string(result.zipFile) + "."); - catch cause - setStatus("Package failed: " + failureText(cause)); - end - endAction(); - end - - function reportProgress(message, ~) - setStatus(message); - drawnow limitrate; - end - - function app = selectedApp() - if isempty(state.apps) - error("labkit:app:internal:launcher:NoAppSelected", ... - "Select an app before using this action."); - end - row = min(max(state.selected, 1), numel(state.apps)); - app = state.apps(row); - end - - function command = currentSelectedCommand() - command = ""; - if ~isempty(state.apps) - command = string(selectedApp().command); - end - end - - function beginAction(message) - state.busy = true; - fig.Pointer = "watch"; - setControlsEnabled(false); - setStatus(message); - drawnow; - end - - function endAction() - state.busy = false; - if isvalid(fig) - fig.Pointer = "arrow"; - end - setControlsEnabled(true); - updateInfo(); - end - - function setControlsEnabled(enabled) - value = matlab.lang.OnOffSwitchState(enabled); - refreshButton.Enable = value; - latestButton.Enable = matlab.lang.OnOffSwitchState( ... - enabled && state.tools.version); - releaseButton.Enable = latestButton.Enable; - versionsButton.Enable = latestButton.Enable; - cleanButton.Enable = matlab.lang.OnOffSwitchState( ... - enabled && state.tools.clean); - docsToolButton.Enable = matlab.lang.OnOffSwitchState( ... - enabled && state.tools.docs); - codeButton.Enable = matlab.lang.OnOffSwitchState( ... - enabled && state.tools.codecheck); - hasApps = ~isempty(state.apps); - openButton.Enable = matlab.lang.OnOffSwitchState(enabled && hasApps); - appDocsButton.Enable = openButton.Enable; - profileButton.Enable = matlab.lang.OnOffSwitchState( ... - enabled && hasApps && state.tools.profile); - packageButton.Enable = matlab.lang.OnOffSwitchState( ... - enabled && hasApps && state.tools.package); - pcodeButton.Enable = packageButton.Enable; - appTable.Enable = char(value); - end - - function setStatus(message) - state.status = string(message); - updateInfo(); - end - - function updateInfo() - details = selectedAppDetails(state.apps, state.selected); - details(end + 1, 1) = "Checked for package: " + ... - numel(state.checkedCommands) + " app(s)"; - status.Value = [ ... - "Status: " + state.status - "" - details - ]; - end -end - -function info = launcherVersion() -info = struct( ... - "name", "labkit_launcher", ... - "displayName", "LabKit App Launcher", ... - "version", "1.8.2", ... - "updated", "2026-07-30"); -end - -function position = defaultLauncherPosition() -screen = double(get(groot, "ScreenSize")); -screenWidth = screen(3); -screenHeight = screen(4); -width = min(screenWidth, max(800, min(1280, screenWidth - 80))); -height = min(screenHeight, max(560, min(720, screenHeight - 120))); -x = screen(1) + max(0, (screenWidth - width) / 2); -y = screen(2) + max(0, (screenHeight - height) / 2); -position = round([x y width height]); -end - -function width = launcherControlWidth(figureWidth) -width = min(390, max(350, round(double(figureWidth) * 0.29))); -end - -function widths = launcherTableWidths(figureWidth, controlWidth) -tableWidth = max(640, double(figureWidth) - double(controlWidth) - 36); -minimum = [62 180 120 70 72 90]; -preferred = [72 240 150 78 80 100]; -if tableWidth <= sum(minimum) - values = minimum; -elseif tableWidth < sum(preferred) - fraction = (tableWidth - sum(minimum)) / ... - (sum(preferred) - sum(minimum)); - values = minimum + fraction .* (preferred - minimum); -else - extra = tableWidth - sum(preferred); - values = preferred + extra .* [0 0.60 0.25 0 0 0.15]; -end -widths = num2cell(round(values)); -end - -function configureTable(tableHandle, selectionCallback, doubleClickCallback) -if isprop(tableHandle, "SelectionChangedFcn") - tableHandle.SelectionChangedFcn = selectionCallback; -else - tableHandle.CellSelectionCallback = selectionCallback; -end -if isprop(tableHandle, "SelectionType") - tableHandle.SelectionType = "row"; -end -if isprop(tableHandle, "DoubleClickedFcn") - tableHandle.DoubleClickedFcn = doubleClickCallback; -elseif isprop(tableHandle, "CellDoubleClickedFcn") - tableHandle.CellDoubleClickedFcn = doubleClickCallback; -end -end - -function row = eventRow(event) -row = NaN; -if isprop(event, "Indices") && ~isempty(event.Indices) - row = event.Indices(1, 1); -elseif isprop(event, "Selection") && ~isempty(event.Selection) - row = event.Selection(1, 1); -elseif isstruct(event) && isfield(event, "Indices") && ~isempty(event.Indices) - row = event.Indices(1, 1); -elseif isstruct(event) && isfield(event, "Selection") && ~isempty(event.Selection) - row = event.Selection(1, 1); -end -end - -function row = appRowByCommand(apps, command) -row = 1; -if isempty(apps) || strlength(string(command)) == 0 - return; -end -match = find(string({apps.command}) == string(command), 1); -if ~isempty(match) - row = match; -end -end - -function rows = launcherRows(apps, checkedCommands) -rows = cell(numel(apps), 6); -checked = ismember(string({apps.command}), string(checkedCommands)); -for index = 1:numel(apps) - rows(index, :) = { ... - checked(index), ... - char(apps(index).name), ... - char(apps(index).family), ... - char(apps(index).version), ... - char(apps(index).visibility), ... - char(apps(index).updated)}; -end -end - -function commands = retainedCommands(apps, commands) -available = string({apps.command}); -commands = string(commands(:)); -commands = commands(ismember(commands, available)); -end - -function apps = checkedApps(apps, commands) -if isempty(apps) - return; -end -apps = apps(ismember(string({apps.command}), string(commands))); -end - -function summary = packageSummary(commands) -if isscalar(commands) - summary = string(commands); -else - summary = string(numel(commands)) + " checked apps"; -end -end - -function details = selectedAppDetails(apps, selected) -if isempty(apps) - details = [ - "No app entry points found." - "Run labkit_launcher(""repair"") if the installation is incomplete." - ]; - return; -end -row = min(max(selected, 1), numel(apps)); -app = apps(row); -details = [ - string(app.name) - "Family: " + string(app.family) - "Visibility: " + string(app.visibility) - "Version: " + string(app.version) - "Updated: " + string(app.updated) - "Command: " + string(app.command) - "Path: " + string(app.folder) - ]; -end - -function message = appAvailabilityStatus(apps) -if isempty(apps) - message = "No app entry points found. Run labkit_launcher(""repair"") " + ... - "if the installation is incomplete."; -else - message = string(numel(apps)) + " app entry point(s) available."; -end -end - -function tools = launcherToolAvailability(root) -tools = struct( ... - "version", toolExists(root, "deployment", "manageLabKitVersions"), ... - "clean", toolExists(root, "maintenance", "cleanLabKitArtifacts"), ... - "docs", toolExists(root, "docs", "renderLabKitDocs"), ... - "codecheck", toolExists(root, "codecheck", "runCodecheckReport"), ... - "profile", toolExists(root, "profiling", "profileLabKitTarget"), ... - "package", toolExists(root, "deployment", "packageLabKitApp")); -end - -function tf = toolExists(root, area, name) -base = fullfile(root, "tools", area, name); -tf = exist(base + ".m", "file") == 2 || exist(base + ".p", "file") == 2; -end - -function addPathIfMissing(folder, varargin) -if exist(folder, "dir") == 7 && ~pathContains(folder) - addpath(folder, varargin{:}); -end -end - -function text = failureText(cause) -text = string(cause.message); -if strlength(string(cause.identifier)) > 0 - text = string(cause.identifier) + ": " + text; -end -end - -function varargout = callTool(root, relativeFolder, name, varargin) -folder = fullfile(root, relativeFolder); -if exist(fullfile(folder, name + ".m"), "file") ~= 2 && ... - exist(fullfile(folder, name + ".p"), "file") ~= 2 - error("labkit:app:internal:launcher:ToolUnavailable", "Tool is unavailable: %s", name); -end -added = ~pathContains(folder); -if added - addpath(folder, "-begin"); - cleanup = onCleanup(@() rmpath(folder)); -end -switch string(name) - case "manageLabKitVersions" - callable = @manageLabKitVersions; - case "cleanLabKitArtifacts" - callable = @cleanLabKitArtifacts; - case "renderLabKitDocs" - callable = @renderLabKitDocs; - case "runCodecheckReport" - callable = @runCodecheckReport; - case "profileLabKitTarget" - callable = @profileLabKitTarget; - case "packageLabKitApp" - callable = @packageLabKitApp; +%DISPATCH Route the installed launcher entry modes to focused owners. +% ROOT is the checkout or installed package root. Optional arguments select +% list, version, documentation, or the default GUI. No state is retained. + +[mode, modeArgs] = ... + labkit.app.internal.launcher.parseRequest(varargin); +switch mode + case "list" + apps = labkit.app.internal.launcher.discoverApps(root); + varargout = { ... + labkit.app.internal.launcher.appCatalogTable(apps)}; + case "documentation" + varargout = {labkit.app.internal.launcher.documentationPage( ... + root, modeArgs.command, modeArgs.source)}; + case "version" + varargout = {labkit.app.internal.launcher.launcherVersion()}; otherwise - error("labkit:app:internal:launcher:UnknownTool", ... - "Launcher tool is not allowlisted: %s", name); -end -if nargout > 0 - [varargout{1:nargout}] = callable(varargin{:}); -else - callable(varargin{:}); -end -clear cleanup -end - -function invokeDiscoveredApp(app) -command = string(app.command); -resolved = string(which(char(command))); -expected = fullfile(string(app.folder), command + [".m", ".p"]); -available = arrayfun(@(candidate) exist(candidate, "file") == 2, expected); -expected = expected(available); -if strlength(resolved) == 0 || isempty(expected) || ... - ~any(normalizePathEntry(resolved) == normalizePathEntry(expected)) - error("labkit:app:internal:launcher:AppEntryMismatch", ... - "Discovered App entry does not resolve from its owning folder: %s", command); -end -% Dynamic extension boundary: the command is derived from and revalidated -% against one discovered labkit_*_app.m or .p file before invocation. -feval(char(command)); -end - -function apps = discoverApps(root) -apps = emptyApps(); -roots = [string(fullfile(root, "apps")); privateAppRoots(root)]; -entrySets = cell(numel(roots), 1); -entryCount = 0; -for rootIndex = 1:numel(roots) - entrySets{rootIndex} = appEntryFiles(roots(rootIndex)); - entryCount = entryCount + numel(entrySets{rootIndex}); -end -records = cell(entryCount, 1); -recordCount = 0; -for rootIndex = 1:numel(roots) - appRoot = roots(rootIndex); - if exist(appRoot, "dir") ~= 7 - continue; - end - entries = entrySets{rootIndex}; - for entryIndex = 1:numel(entries) - entry = entries(entryIndex); - if isHiddenImplementationPath(relativePath(appRoot, entry.folder)) - continue; - end - [~, command] = fileparts(entry.name); - filepath = fullfile(entry.folder, entry.name); - metadata = appVersionInfo(entry.folder); - family = familyName(appRoot, entry.folder); - if strlength(metadata.family) > 0 - family = metadata.family; - end - name = displayName(command); - if strlength(metadata.displayName) > 0 - name = metadata.displayName; + if nargout > 1 + error("labkit:app:internal:launcher:TooManyOutputs", ... + "Launcher dispatch returns at most one figure."); end - app = struct("command", scalarText(command, "command"), ... - "folder", scalarText(entry.folder, "folder"), ... - "relativePath", scalarText(relativePath(root, filepath), "relativePath"), ... - "family", scalarText(family, "family"), ... - "name", scalarText(name, "name"), ... - "description", scalarText(appDescription(filepath, command), "description"), ... - "visibility", scalarText(visibilityFor(root, entry.folder), "visibility"), ... - "version", scalarText(metadata.version, "version"), ... - "updated", scalarText(metadata.updated, "updated")); - recordCount = recordCount + 1; - records{recordCount} = app; - end -end -if recordCount > 0 - apps = [records{1:recordCount}]; -end -if ~isempty(apps) - keys = [reshape(string({apps.visibility}) == "private", [], 1), ... - reshape(string({apps.family}), [], 1), reshape(string({apps.name}), [], 1)]; - [~, order] = sortrows(keys); - apps = apps(order); -end -end - -function apps = emptyApps() -apps = struct("command", {}, "folder", {}, "relativePath", {}, ... - "family", {}, "name", {}, "description", {}, "visibility", {}, ... - "version", {}, "updated", {}); -end - -function entries = appEntryFiles(appRoot) -entries = [dir(fullfile(char(appRoot), "**", "labkit_*_app.m")); ... - dir(fullfile(char(appRoot), "**", "labkit_*_app.p"))]; -entries = entries(~[entries.isdir]); -if isempty(entries), return; end -count = numel(entries); -paths = strings(count, 1); commands = strings(count, 1); isSource = false(count, 1); -for index = 1:count - paths(index) = string(fullfile(entries(index).folder, entries(index).name)); - [~, commands(index), extension] = fileparts(paths(index)); - isSource(index) = string(extension) == ".m"; -end -[~, order] = sortrows([commands, string(~isSource), paths]); -entries = entries(order); commands = string(commands(order)); -[~, keep] = unique(commands, "stable"); -entries = entries(keep); -end - -function roots = privateAppRoots(root) -parts = split(string(getenv("LABKIT_PRIVATE_APP_ROOTS")), pathsep); -localCandidates = strings(numel(parts) + 1, 1); -candidateCount = 0; -localRoot = fullfile(root, "private_apps", "apps"); -if exist(localRoot, "dir") == 7 - candidateCount = candidateCount + 1; localCandidates(candidateCount) = localRoot; -end -environmentRoots = string(getenv("LABKIT_PRIVATE_APP_ROOTS")); -if strlength(environmentRoots) == 0 - roots = unique(localCandidates(1:candidateCount), "stable"); - return; -end -for part = split(environmentRoots, pathsep).' - candidate = string(part); - if ~endsWith(replace(candidate, "\\", "/"), "/apps") - candidate = fullfile(candidate, "apps"); - end - if exist(candidate, "dir") == 7 - candidateCount = candidateCount + 1; - localCandidates(candidateCount) = candidate; - end -end -roots = unique(localCandidates(1:candidateCount), "stable"); -end - -function value = visibilityFor(root, folder) -if isDescendantPath(folder, fullfile(root, "apps")) - value = "public"; -else - value = "private"; -end -end - -function value = familyName(appRoot, folder) -parts = split(relativePath(appRoot, folder), "/"); -if isempty(parts) || strlength(parts(1)) == 0 - value = "Other"; -else - value = displayToken(parts(1)); -end -end - -function tf = isHiddenImplementationPath(relative) -parts = split(string(relative), "/"); -tf = any(parts == "private" | startsWith(parts, "+")); -end - -function relative = relativePath(root, folder) -root = string(root); folder = string(folder); -prefix = root + filesep; -if startsWith(folder, prefix, "IgnoreCase", ispc) - relative = extractAfter(folder, strlength(prefix)); -else - relative = folder; -end -relative = replace(relative, string(filesep), "/"); -end - -function tf = isDescendantPath(folder, ancestor) -folder = lower(replace(string(folder), string(filesep), "/")); -ancestor = lower(replace(string(ancestor), string(filesep), "/")); -tf = folder == ancestor || startsWith(folder, ancestor + "/"); -end - -function value = displayName(command) -value = erase(string(command), "labkit_"); -value = erase(value, "_app"); -value = displayToken(value); -end - -function value = displayToken(value) -words = split(replace(string(value), "_", " ")); -for index = 1:numel(words) - if lower(words(index)) == "labkit" - words(index) = "LabKit"; - else - words(index) = upper(extractBefore(words(index), 2)) + extractAfter(words(index), 1); - end -end -value = strjoin(cellstr(words), " "); -end - -function description = appDescription(filepath, command) -description = ""; -try - text = fileread(filepath); -catch - return; -end -lines = splitlines(string(text)); -prefix = "%" + upper(string(command)); -for index = 1:min(numel(lines), 20) - line = strtrim(lines(index)); - if startsWith(line, prefix) - description = strtrim(erase(extractAfter( ... - line, strlength(prefix)), "-")); - return; - elseif startsWith(line, "%") - cleaned = strtrim(extractAfter(line, 1)); - if strlength(cleaned) > 0 && ~startsWith(cleaned, "Usage") - description = cleaned; - return; + fig = labkit.app.internal.launcher.createLauncher(root); + if nargout == 1 + varargout = {fig}; end - end -end -end - -function catalog = appCatalogTable(apps) -count = numel(apps); -commandColumn = strings(count, 1); displayNameColumn = strings(count, 1); -familyColumn = strings(count, 1); visibilityColumn = strings(count, 1); -folderColumn = strings(count, 1); relativePathColumn = strings(count, 1); -descriptionColumn = strings(count, 1); versionColumn = strings(count, 1); -updatedColumn = strings(count, 1); -for index = 1:count - commandColumn(index) = apps(index).command; - displayNameColumn(index) = apps(index).name; - familyColumn(index) = apps(index).family; - visibilityColumn(index) = apps(index).visibility; - folderColumn(index) = apps(index).folder; - relativePathColumn(index) = apps(index).relativePath; - descriptionColumn(index) = apps(index).description; - versionColumn(index) = apps(index).version; - updatedColumn(index) = apps(index).updated; -end -catalog = table(commandColumn, displayNameColumn, familyColumn, ... - visibilityColumn, folderColumn, relativePathColumn, descriptionColumn, ... - versionColumn, updatedColumn, ... - 'VariableNames', {'Command', 'DisplayName', 'Family', 'Visibility', ... - 'Folder', 'RelativePath', 'Description', 'Version', 'Updated'}); -end - -function page = documentationPage(root, command, source) -apps = discoverApps(root); -match = find(string({apps.command}) == string(command), 1); -if isempty(match) || apps(match).visibility ~= "public" - error("labkit:app:internal:launcher:DocumentationUnavailable", ... - "No public documentation page is available for %s.", command); -end -[~, appId] = fileparts(apps(match).folder); -appId = replace(string(appId), "_", "-"); -manuals = dir(fullfile(root, "docs", "apps", "*", appId, "README.md")); -if numel(manuals) ~= 1 - error("labkit:app:internal:launcher:DocumentationUnavailable", ... - "No documentation source is available for %s.", command); -end -[~, family] = fileparts(fileparts(manuals(1).folder)); -if source == "online" - page = "https://pluze.github.io/LabKit-MATLAB-Workbench/apps/" + ... - family + "/" + appId + ".html"; - return; -end -page = string(fullfile(root, "site", "apps", family, appId + ".html")); -if exist(page, "file") ~= 2 - error("labkit:app:internal:launcher:LocalDocumentationMissing", ... - "Local documentation has not been generated for %s.", command); -end -end - -function tf = isStructuralStartupFailure(cause) -id = string(cause.identifier); -tf = startsWith(id, "MATLAB:UndefinedFunction") || ... - startsWith(id, "MATLAB:parse") || startsWith(id, "MATLAB:dispatcher"); -end - -function info = appVersionInfo(folder) -info = struct("version", "", "updated", "", ... - "displayName", "", "family", ""); -definitions = dir(fullfile(folder, "+*", "definition.m")); -if isempty(definitions) - return; -end -try - text = fileread(fullfile(definitions(1).folder, definitions(1).name)); - info.version = literalField(text, "AppVersion"); - info.updated = literalField(text, "Updated"); - info.displayName = literalField(text, "DisplayName"); - info.family = literalField(text, "Family"); -catch -end -end - -function value = literalField(text, field) -value = ""; -patterns = {[char(field) '\s*=\s*"([^"]+)"'], ... - ['"' char(field) '"\s*,\s*"([^"]+)"']}; -for index = 1:numel(patterns) - tokens = regexp(text, patterns{index}, "tokens", "once"); - if ~isempty(tokens) - value = string(tokens{1}); - return; - end -end -end - -function value = scalarText(value, field) -value = string(value); -if ~isscalar(value) - error("labkit:app:internal:launcher:InvalidMetadataShape", ... - "App metadata field %s must be scalar.", field); -end -end - -function tf = pathContains(folder) -entries = string(strsplit(path, pathsep)); -target = normalizePathEntry(folder); -tf = any(normalizePathEntry(entries) == target); -end - -function value = normalizePathEntry(value) -values = string(value); -for index = 1:numel(values) - pathValue = java.nio.file.Paths.get(char(values(index)), javaArray("java.lang.String", 0)); - values(index) = string(pathValue.toAbsolutePath().normalize().toString()); -end -value = values; -if ispc, value = lower(value); end -end - -function launchFigureStudioFromAxes(root, ax) -folder = fullfile(root, "apps", "labkit_core", "figure_studio"); -if exist(folder, "dir") ~= 7 - error("labkit:app:internal:launcher:FigureStudioUnavailable", "Figure Studio is unavailable."); -end -if ~pathContains(folder) - addpath(folder, "-end"); -end -labkit_FigureStudio_app("axes", ax); -end - -function tf = isTextScalar(value) -tf = ischar(value) || (isstring(value) && isscalar(value)); -end - -function mode = launcherGuiTestMode() -mode = "visible"; -if isappdata(groot, "labkitLauncherGuiTestMode") - mode = string(getappdata(groot, "labkitLauncherGuiTestMode")); end end diff --git a/+labkit/+app/+internal/+launcher/documentationPage.m b/+labkit/+app/+internal/+launcher/documentationPage.m new file mode 100644 index 000000000..907b75b73 --- /dev/null +++ b/+labkit/+app/+internal/+launcher/documentationPage.m @@ -0,0 +1,27 @@ +function page = documentationPage(root, command, source) +%DOCUMENTATIONPAGE Resolve one public App's online or generated local page. +apps = labkit.app.internal.launcher.discoverApps(root); +match = find(string({apps.command}) == string(command), 1); +if isempty(match) || apps(match).visibility ~= "public" + error("labkit:app:internal:launcher:DocumentationUnavailable", ... + "No public documentation page is available for %s.", command); +end +[~, appId] = fileparts(apps(match).folder); +appId = replace(string(appId), "_", "-"); +manuals = dir(fullfile(root, "docs", "apps", "*", appId, "README.md")); +if numel(manuals) ~= 1 + error("labkit:app:internal:launcher:DocumentationUnavailable", ... + "No documentation source is available for %s.", command); +end +[~, family] = fileparts(fileparts(manuals(1).folder)); +if source == "online" + page = "https://pluze.github.io/LabKit-MATLAB-Workbench/apps/" + ... + family + "/" + appId + ".html"; + return; +end +page = string(fullfile(root, "site", "apps", family, appId + ".html")); +if exist(page, "file") ~= 2 + error("labkit:app:internal:launcher:LocalDocumentationMissing", ... + "Local documentation has not been generated for %s.", command); +end +end diff --git a/+labkit/+app/+internal/+launcher/emptyApps.m b/+labkit/+app/+internal/+launcher/emptyApps.m new file mode 100644 index 000000000..ed911856d --- /dev/null +++ b/+labkit/+app/+internal/+launcher/emptyApps.m @@ -0,0 +1,6 @@ +function apps = emptyApps() +%EMPTYAPPS Return the canonical empty launcher-entry collection. +apps = struct("command", {}, "folder", {}, "relativePath", {}, ... + "family", {}, "name", {}, "description", {}, "visibility", {}, ... + "version", {}, "updated", {}); +end diff --git a/+labkit/+app/+internal/+launcher/launcherVersion.m b/+labkit/+app/+internal/+launcher/launcherVersion.m new file mode 100644 index 000000000..eb89b7765 --- /dev/null +++ b/+labkit/+app/+internal/+launcher/launcherVersion.m @@ -0,0 +1,8 @@ +function info = launcherVersion() +%LAUNCHERVERSION Return the installed launcher component metadata. +info = struct( ... + "name", "labkit_launcher", ... + "displayName", "LabKit App Launcher", ... + "version", "1.8.3", ... + "updated", "2026-08-03"); +end diff --git a/+labkit/+app/+internal/+launcher/parseRequest.m b/+labkit/+app/+internal/+launcher/parseRequest.m new file mode 100644 index 000000000..a38a84edf --- /dev/null +++ b/+labkit/+app/+internal/+launcher/parseRequest.m @@ -0,0 +1,40 @@ +function [mode, modeArgs] = parseRequest(args) +%PARSEREQUEST Validate and normalize one launcher entry request. +mode = "gui"; +modeArgs = struct("command", "", "source", "online"); +if isempty(args) + return; +end + +if ismember(numel(args), [2 3]) && isTextScalar(args{1}) && ... + strcmpi(string(args{1}), "documentation") + if ~isTextScalar(args{2}) || ... + strlength(strtrim(string(args{2}))) == 0 + error("labkit:app:internal:launcher:InvalidInput", ... + "Documentation mode requires one nonempty app command."); + end + modeArgs.command = string(args{2}); + if numel(args) == 3 + if ~isTextScalar(args{3}) || ... + ~ismember(lower(string(args{3})), ["online", "local"]) + error("labkit:app:internal:launcher:InvalidInput", ... + "Documentation source must be online or local."); + end + modeArgs.source = lower(string(args{3})); + end + mode = "documentation"; + return; +end +if numel(args) ~= 1 || ~isTextScalar(args{1}) + error("labkit:app:internal:launcher:InvalidInput", ... + "Use no input, list, version, or documentation plus an app command and optional source."); +end +mode = lower(string(args{1})); +if ~ismember(mode, ["list", "version"]) + error("labkit:app:internal:launcher:InvalidInput", "Unsupported launcher mode: %s", mode); +end +end + +function tf = isTextScalar(value) +tf = ischar(value) || (isstring(value) && isscalar(value)); +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/MatlabPlatformAdapter.m similarity index 75% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/MatlabPlatformAdapter.m index 68ac3a613..360ff995f 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/MatlabPlatformAdapter.m @@ -18,8 +18,7 @@ Runtime InteractionController InteractionDeclarations (1, :) cell = {} - BaseWindowTitle (1, 1) string = "LabKit application" - Busy (1, 1) logical = false + BusyLifecycle PriorPointer (1, 1) string = "arrow" ClosePrompt DialogFolders @@ -28,18 +27,16 @@ StartupPanel StartupLabel LogViewer - TraceCaptureMenu end methods (Access = { ... - ?labkit.app.internal.RuntimeKernel, ... - ?labkit.app.internal.RuntimeContractBoundary}) + ?labkit.app.internal.runtime.RuntimeKernel, ... + ?labkit.app.internal.runtime.RuntimeContractBoundary}) function obj = MatlabPlatformAdapter(plan, title) - obj.Plan = labkit.app.internal.NativeAdapterValues.validatePlan(plan); + obj.Plan = labkit.app.internal.native.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", ... @@ -48,10 +45,15 @@ "ValueType", "any"); obj.DialogFolders = containers.Map("KeyType", "char", ... "ValueType", "char"); - policy = labkit.app.internal.NativeAdapterValues.layoutPolicy(); + policy = labkit.app.internal.native.NativeAdapterValues.layoutPolicy(); obj.Figure = uifigure(Visible="off", ... - Name=char(obj.BaseWindowTitle), ... + Name=char(string(title)), ... Position=policy.InitialFigurePosition); + obj.BusyLifecycle = ... + labkit.app.internal.native.BusyLifecycle( ... + obj.Figure, title, ... + @(view, restoreValues) ... + obj.restoreBusyView(view, restoreValues)); obj.Starting = true; obj.StartupStarted = tic; obj.PriorPointer = string(obj.Figure.Pointer); @@ -63,7 +65,7 @@ end function attachRuntime(obj, runtime) - if ~isa(runtime, "labkit.app.internal.RuntimeKernel") + if ~isa(runtime, "labkit.app.internal.runtime.RuntimeKernel") error("labkit:app:runtime:InvariantFailure", ... "MATLAB platform adapter requires its RuntimeKernel."); end @@ -75,10 +77,11 @@ function attachRuntime(obj, runtime) obj.InteractionDeclarations = obj.collectInteractionDeclarations(); targets = obj.interactionTargetAxes(); if ~isempty(targets) - obj.InteractionController = labkit.app.internal.NativeAdapterValues.interactionController( ... + obj.InteractionController = labkit.app.internal.native.NativeAdapterValues.interactionController( ... obj.Figure, targets, ... @(id, signal, value) ... - runtime.applyInteraction(id, signal, value)); + obj.runUserInput(@() ... + runtime.applyInteraction(id, signal, value))); end end @@ -115,6 +118,7 @@ function close(obj) obj.InteractionController.delete(); obj.InteractionController = []; end + obj.BusyLifecycle.close(); if ~isempty(obj.Figure) && isvalid(obj.Figure) delete(obj.Figure); end @@ -127,7 +131,7 @@ function close(obj) function show(obj, title) if ~isempty(obj.Figure) && isvalid(obj.Figure) obj.setWindowTitle(title); - mode = labkit.app.internal.NativeAdapterValues.startupGuiMode(); + mode = labkit.app.internal.native.NativeAdapterValues.startupGuiMode(); if mode == "hidden" return end @@ -139,47 +143,25 @@ function show(obj, title) 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 + obj.BusyLifecycle.setWindowTitle(title); end function beginBusy(obj, message) - if obj.Starting || obj.Busy || ... - isempty(obj.Figure) || ~isvalid(obj.Figure) + if obj.Starting 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 nocallbacks + obj.BusyLifecycle.begin(message); end - function endBusy(obj) + function updateBusy(obj, message) + obj.BusyLifecycle.update(message); + end + + function endBusy(obj, view) 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 nocallbacks + obj.BusyLifecycle.finish(view); end function startupUpdate(obj, message) @@ -190,7 +172,7 @@ function startupUpdate(obj, message) obj.StartupLabel.Text = char(string(message)); end if toc(obj.StartupStarted) >= 0.25 && ... - ~any(labkit.app.internal.NativeAdapterValues.startupGuiMode() == ["hidden", "minimized"]) + ~any(labkit.app.internal.native.NativeAdapterValues.startupGuiMode() == ["hidden", "minimized"]) obj.StartupPanel.Visible = "on"; obj.Figure.Visible = "on"; drawnow limitrate nocallbacks @@ -214,7 +196,7 @@ function finishStartup(obj) if isappdata(obj.Figure, "labkitAppBusy") rmappdata(obj.Figure, "labkitAppBusy"); end - if labkit.app.internal.NativeAdapterValues.startupGuiMode() == "minimized" && ... + if labkit.app.internal.native.NativeAdapterValues.startupGuiMode() == "minimized" && ... isprop(obj.Figure, "WindowState") obj.Figure.Visible = "on"; obj.Figure.WindowState = "minimized"; @@ -223,11 +205,11 @@ function finishStartup(obj) function failStartup(obj, cause) obj.Starting = false; - obj.Busy = false; + obj.BusyLifecycle.close(); if isempty(obj.Figure) || ~isvalid(obj.Figure) return end - message = "Startup failed: " + labkit.app.internal.NativeAdapterValues.deepestCauseMessage(cause); + message = "Startup failed: " + labkit.app.internal.native.NativeAdapterValues.deepestCauseMessage(cause); if ~isempty(obj.StartupLabel) && isvalid(obj.StartupLabel) obj.StartupLabel.Text = char(message); end @@ -246,8 +228,20 @@ function failStartup(obj, cause) drawnow limitrate nocallbacks end - function alert(obj, message, title) - uialert(obj.Figure, char(string(message)), char(string(title))); + function alert(obj, message, title, icon) + if nargin < 4 + icon = "error"; + end + if string(obj.Figure.Visible) == "off" && ... + labkit.app.internal.native.NativeAdapterValues.startupGuiMode() == ... + "hidden" + setappdata(obj.Figure, "labkitAppLastAlert", struct( ... + "message", string(message), "title", string(title), ... + "icon", string(icon))); + return; + end + uialert(obj.Figure, char(string(message)), char(string(title)), ... + Icon=char(string(icon))); end function result = chooseOption(obj, prompt, choices, title, ... @@ -261,49 +255,49 @@ function alert(obj, message, title) end function result = chooseInputFile(~, filters, startPath) - filters = labkit.app.internal.NativeAdapterValues.dialogFilters(filters); + filters = labkit.app.internal.native.NativeAdapterValues.dialogFilters(filters); [name, folder] = uigetfile(filters, "Choose input file", ... - labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + labkit.app.internal.native.NativeAdapterValues.dialogStartFolder( ... "input", startPath)); if ~isequal(name, 0) - labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + labkit.app.internal.native.NativeAdapterValues.rememberDialogFolder( ... "input", folder); end - result = labkit.app.internal.NativeAdapterValues.dialogPath(name, folder); + result = labkit.app.internal.native.NativeAdapterValues.dialogPath(name, folder); end function result = chooseInputFolder(~, startPath) folder = uigetdir( ... - labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + labkit.app.internal.native.NativeAdapterValues.dialogStartFolder( ... "input", startPath), "Choose input folder"); if ~isequal(folder, 0) - labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + labkit.app.internal.native.NativeAdapterValues.rememberDialogFolder( ... "input", folder); end - result = labkit.app.internal.NativeAdapterValues.folderDialogPath(folder); + result = labkit.app.internal.native.NativeAdapterValues.folderDialogPath(folder); end function result = chooseOutputFile(~, filters, startPath) - filters = labkit.app.internal.NativeAdapterValues.dialogFilters(filters); + filters = labkit.app.internal.native.NativeAdapterValues.dialogFilters(filters); [name, folder] = uiputfile(filters, "Choose output file", ... - labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + labkit.app.internal.native.NativeAdapterValues.dialogStartFolder( ... "output", startPath)); if ~isequal(name, 0) - labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + labkit.app.internal.native.NativeAdapterValues.rememberDialogFolder( ... "output", folder); end - result = labkit.app.internal.NativeAdapterValues.dialogPath(name, folder); + result = labkit.app.internal.native.NativeAdapterValues.dialogPath(name, folder); end function result = chooseOutputFolder(~, startPath) folder = uigetdir( ... - labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + labkit.app.internal.native.NativeAdapterValues.dialogStartFolder( ... "output", startPath), "Choose output folder"); if ~isequal(folder, 0) - labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + labkit.app.internal.native.NativeAdapterValues.rememberDialogFolder( ... "output", folder); end - result = labkit.app.internal.NativeAdapterValues.folderDialogPath(folder); + result = labkit.app.internal.native.NativeAdapterValues.folderDialogPath(folder); end end @@ -417,45 +411,40 @@ function toggleLogFollowLatest(~, textArea, button) installUtilityMenus(obj) - function runUtility(obj, callback) + function runUtility(obj, callback, title) + if nargin < 3 + title = "LabKit Utility"; + end + if obj.Starting || ~obj.BusyLifecycle.acceptInput() + return + end try callback(); catch cause - obj.alert(cause.message, "LabKit Utility"); + obj.alert(cause.message, title); end end + function runUserInput(obj, callback) + if obj.Starting || ~obj.BusyLifecycle.acceptInput() + return + end + callback(); + end + + restoreBusyView(obj, view, restoreValues) + function openSessionLog(obj) if isempty(obj.LogViewer) || ~isvalid(obj.LogViewer) || ... ~obj.LogViewer.isOpen() obj.LogViewer = ... - labkit.app.internal.SessionLogViewer(obj.Runtime); + labkit.app.internal.diagnostics.SessionLogViewer(obj.Runtime); else obj.LogViewer.refresh(); end obj.LogViewer.show(); end - function toggleTraceCapture(obj) - enabled = true; - if ~isempty(obj.TraceCaptureMenu) && ... - isvalid(obj.TraceCaptureMenu) - enabled = string(obj.TraceCaptureMenu.Checked) ~= "on"; - end - obj.Runtime.setTraceCapture(enabled); - if ~isempty(obj.TraceCaptureMenu) && ... - isvalid(obj.TraceCaptureMenu) - if enabled - obj.TraceCaptureMenu.Checked = "on"; - else - obj.TraceCaptureMenu.Checked = "off"; - end - end - if ~isempty(obj.LogViewer) && isvalid(obj.LogViewer) - obj.LogViewer.refresh(); - end - end - function handles = allAxes(obj) values = obj.Axes.values; if isempty(values) @@ -502,19 +491,31 @@ function saveAllPlots(obj) for k = 1:numel(handles) output = filepath; if numel(handles) > 1 - output = labkit.app.internal.NativeAdapterValues.plotFilepath(filepath, handles(k), k); + output = labkit.app.internal.native.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); + filename = obj.Runtime.automaticArtifactFilename( ... + "screenshot", ".png"); + try + destination = obj.Runtime.automaticArtifactDestination( ... + "screenshots", "screenshot", ".png"); + exportapp(obj.Figure, destination); + catch + choice = obj.chooseOutputFile( ... + {"*.png", "PNG image (*.png)"; ... + "*.pdf", "PDF file (*.pdf)"}, filename); + if choice.Cancelled + return; + end + destination = string(choice.Value); + exportapp(obj.Figure, destination); end + obj.alert("Screenshot written to:" + newline + destination, ... + "Screenshot Saved", "info"); end function copyScreenshot(obj) @@ -531,16 +532,23 @@ function copyScreenshot(obj) 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); + filename = obj.Runtime.automaticArtifactFilename( ... + "state", ".mat"); + try + destination = obj.Runtime.automaticArtifactDestination( ... + "states", "state", ".mat"); + obj.Runtime.saveProject(obj.Runtime.State, destination); + catch + choice = obj.chooseOutputFile( ... + {"*.mat", "LabKit project (*.mat)"}, filename); + if choice.Cancelled + return; + end + destination = string(choice.Value); + obj.Runtime.saveProject(obj.Runtime.State, destination); end + obj.alert("App state written to:" + newline + destination, ... + "State Saved", "info"); end function loadState(obj) @@ -571,7 +579,7 @@ function requestClose(obj) return end message = "Close this LabKit app?"; - if obj.Busy + if obj.BusyLifecycle.Active message = "LabKit is still working. Close anyway?"; elseif obj.hasProjectDocument() metadata = obj.Runtime.documentMetadata(); @@ -582,7 +590,7 @@ function requestClose(obj) obj.ClosePrompt = uipanel(obj.Figure, ... Title="Close LabKit app?", ... Tag="labkitAppClosePrompt", ... - Position=labkit.app.internal.NativeAdapterValues.closePromptPosition(obj.Figure)); + Position=labkit.app.internal.native.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); @@ -616,7 +624,7 @@ function pannerChanged(obj, target, value) value = min(component.Limits(2), ... max(component.Limits(1), double(value))); component.Value = value; - linked = labkit.app.internal.NativeAdapterValues.linkedPannerSlider(component); + linked = labkit.app.internal.native.NativeAdapterValues.linkedPannerSlider(component); if ~isempty(linked) linked.Value = value; end @@ -625,7 +633,7 @@ function pannerChanged(obj, target, value) function rangeChanged(obj, target) component = obj.component(target); - rangeEnd = labkit.app.internal.NativeAdapterValues.linkedRangeEnd(component); + rangeEnd = labkit.app.internal.native.NativeAdapterValues.linkedRangeEnd(component); value = [component.Value, rangeEnd.Value]; obj.Runtime.applyControlValue(target, value); end @@ -634,13 +642,13 @@ function rangeChanged(obj, target) 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)); + rowId = labkit.app.internal.native.NativeAdapterValues.tableLabel(component.RowName, indices(1)); + columnId = labkit.app.internal.native.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); + NewValue=labkit.app.internal.native.NativeAdapterValues.editedValue(event), Data=component.Data); obj.Runtime.applyTableEdit(target, edit); end @@ -649,14 +657,14 @@ function dispatchTableEdit(obj, target, component, event) 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), ... + [names, folder] = uigetfile(labkit.app.internal.native.NativeAdapterValues.dialogFilters(config.Filters), ... char(config.ChooseLabel), ... - startPath, MultiSelect=labkit.app.internal.NativeAdapterValues.multiSelectValue(config.SelectionMode)); + startPath, MultiSelect=labkit.app.internal.native.NativeAdapterValues.multiSelectValue(config.SelectionMode)); if isequal(names, 0) return; end obj.DialogFolders(char(target)) = char(folder); - labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + labkit.app.internal.native.NativeAdapterValues.rememberDialogFolder( ... "input", folder); paths = string(folder) + filesep + string(names); if config.SelectionMode == "single" @@ -677,9 +685,9 @@ function chooseFolderFiles(obj, target, recursive) return end obj.DialogFolders(char(target)) = char(folder); - labkit.app.internal.NativeAdapterValues.rememberDialogFolder( ... + labkit.app.internal.native.NativeAdapterValues.rememberDialogFolder( ... "input", folder); - paths = labkit.app.internal.NativeAdapterValues.filesInFolder(folder, config.Filters, recursive); + paths = labkit.app.internal.native.NativeAdapterValues.filesInFolder(folder, config.Filters, recursive); if recursive && ... numel(paths) > config.FolderWarningThreshold message = sprintf([ ... @@ -705,7 +713,7 @@ function chooseFolderFiles(obj, target, recursive) function removeSelectedFiles(obj, target, list) obj.Runtime.removeFileSelection( ... - target, labkit.app.internal.NativeAdapterValues.selectedIndices(list)); + target, labkit.app.internal.native.NativeAdapterValues.selectedIndices(list)); end function folder = dialogStartFolder(obj, target, configured) @@ -717,7 +725,7 @@ function removeSelectedFiles(obj, target, list) end folder = char(string(configured)); if isempty(folder) || ~isfolder(folder) - folder = labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + folder = labkit.app.internal.native.NativeAdapterValues.dialogStartFolder( ... "input", ""); end end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/actionGridSize.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/actionGridSize.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/actionGridSize.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/actionGridSize.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/apply.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/apply.m similarity index 69% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/apply.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/apply.m index 25f45b450..4e104c8e5 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/apply.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/apply.m @@ -5,14 +5,14 @@ function apply(obj, operation) case "value" obj.applyValue(component, operation.Value); case "choices" - labkit.app.internal.NativeAdapterValues.applyChoices(component, operation.Value); + labkit.app.internal.native.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)); + labkit.app.internal.native.NativeAdapterValues.setIfProperty(labkit.app.internal.native.NativeAdapterValues.layoutHandle(component), ... + "Visible", labkit.app.internal.native.NativeAdapterValues.onOff(operation.Value)); case "text" obj.applyText(component, operation.Value); case "filePaths" @@ -28,7 +28,7 @@ function apply(obj, operation) case "renderPlot" obj.renderPlot(operation); case "workspacePage" - labkit.app.internal.NativeAdapterValues.setIfProperty(component, "Enable", labkit.app.internal.NativeAdapterValues.onOff(operation.Value.Enabled)); + labkit.app.internal.native.NativeAdapterValues.setIfProperty(component, "Enable", labkit.app.internal.native.NativeAdapterValues.onOff(operation.Value.Enabled)); component.UserData = struct("Status", operation.Value.Status); end end diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyEnabled.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyEnabled.m new file mode 100644 index 000000000..f09202e29 --- /dev/null +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyEnabled.m @@ -0,0 +1,21 @@ +function applyEnabled(~, component, enabled) +% Class-folder implementation of MatlabPlatformAdapter.applyEnabled. + value = labkit.app.internal.native.NativeAdapterValues.onOff(enabled); + labkit.app.internal.native.NativeAdapterValues.setIfProperty(component, "Enable", value); + label = labkit.app.internal.native.NativeAdapterValues.linkedLabel(component); + if ~isempty(label) + labkit.app.internal.native.NativeAdapterValues.setIfProperty(label, "Enable", value); + end + mode = labkit.app.internal.native.NativeAdapterValues.linkedPlotMode(component); + if ~isempty(mode) + mode.Enable = value; + end + linked = labkit.app.internal.native.NativeAdapterValues.linkedPannerSlider(component); + if ~isempty(linked) + linked.Enable = value; + end + rangeEnd = labkit.app.internal.native.NativeAdapterValues.linkedRangeEnd(component); + if ~isempty(rangeEnd) + rangeEnd.Enable = value; + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFileItemStatuses.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyFileItemStatuses.m similarity index 74% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/applyFileItemStatuses.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyFileItemStatuses.m index 6b8064e2e..dcbd0c188 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFileItemStatuses.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyFileItemStatuses.m @@ -8,9 +8,9 @@ function applyFileItemStatuses(~, component, statuses) end data.ItemStatuses = statuses; component.UserData = data; - labels = labkit.app.internal.NativeAdapterValues.formatFileLabels(data.Paths, statuses); + labels = labkit.app.internal.native.NativeAdapterValues.formatFileLabels(data.Paths, statuses); if isempty(labels) && ~data.Compact labels = data.EmptyText; end - labkit.app.internal.NativeAdapterValues.setIfProperty(component, "Items", labels); + labkit.app.internal.native.NativeAdapterValues.setIfProperty(component, "Items", labels); end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFilePaths.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyFilePaths.m similarity index 84% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/applyFilePaths.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyFilePaths.m index 5a48bb1d4..eb65c9be7 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyFilePaths.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyFilePaths.m @@ -6,11 +6,11 @@ function applyFilePaths(~, component, paths) data.ItemStatuses = strings(1, 0); end component.UserData = data; - labels = labkit.app.internal.NativeAdapterValues.formatFileLabels(paths, data.ItemStatuses); + labels = labkit.app.internal.native.NativeAdapterValues.formatFileLabels(paths, data.ItemStatuses); if isempty(labels) && ~data.Compact labels = data.EmptyText; end - labkit.app.internal.NativeAdapterValues.setIfProperty(component, "Items", labels); + labkit.app.internal.native.NativeAdapterValues.setIfProperty(component, "Items", labels); if ~isstruct(component.UserData) || ... ~isfield(component.UserData, "Status") || ... isempty(component.UserData.Status) || ... diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyLimits.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyLimits.m similarity index 62% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/applyLimits.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyLimits.m index a82bc761e..e1684f05b 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyLimits.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyLimits.m @@ -1,14 +1,14 @@ function applyLimits(~, component, limits) % Class-folder implementation of MatlabPlatformAdapter.applyLimits. - rangeEnd = labkit.app.internal.NativeAdapterValues.linkedRangeEnd(component); + rangeEnd = labkit.app.internal.native.NativeAdapterValues.linkedRangeEnd(component); if ~isempty(rangeEnd) component.Limits = limits; rangeEnd.Limits = limits; return end - linked = labkit.app.internal.NativeAdapterValues.linkedPannerSlider(component); + linked = labkit.app.internal.native.NativeAdapterValues.linkedPannerSlider(component); if isempty(linked) - labkit.app.internal.NativeAdapterValues.setIfProperty(component, "Limits", limits); + labkit.app.internal.native.NativeAdapterValues.setIfProperty(component, "Limits", limits); return end value = min(limits(2), ... diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyListSelection.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyListSelection.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/applyListSelection.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyListSelection.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyTableCellSelection.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyTableCellSelection.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/applyTableCellSelection.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyTableCellSelection.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyTableData.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyTableData.m similarity index 78% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/applyTableData.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyTableData.m index f440814ed..2d25b2d4f 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyTableData.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyTableData.m @@ -1,6 +1,6 @@ function applyTableData(~, component, model) % Class-folder implementation of MatlabPlatformAdapter.applyTableData. - component.Data = labkit.app.internal.NativeAdapterValues.nativeTableData(model.Data); + component.Data = labkit.app.internal.native.NativeAdapterValues.nativeTableData(model.Data); if ~isempty(model.Columns) component.ColumnName = cellstr(model.Columns); end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyText.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyText.m similarity index 86% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/applyText.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyText.m index 90c082e20..f8e924524 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyText.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyText.m @@ -10,7 +10,7 @@ function applyText(~, component, value) else component.UserData.Status.Value = cellstr(string(value)); end - labkit.app.internal.NativeAdapterValues.fitText(component.UserData.Status); + labkit.app.internal.native.NativeAdapterValues.fitText(component.UserData.Status); return elseif isprop(component, "Text") component.Text = value; @@ -19,7 +19,7 @@ function applyText(~, component, value) elseif isprop(component, "Title") component.Title = value; end - labkit.app.internal.NativeAdapterValues.fitText(component); + labkit.app.internal.native.NativeAdapterValues.fitText(component); if isappdata(component, "labkitAppLogFollowLatest") && ... getappdata(component, "labkitAppLogFollowLatest") try diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyValue.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyValue.m similarity index 54% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/applyValue.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyValue.m index 8f80659d1..6d245eb68 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyValue.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyValue.m @@ -1,18 +1,18 @@ function applyValue(~, component, value) % Class-folder implementation of MatlabPlatformAdapter.applyValue. - mode = labkit.app.internal.NativeAdapterValues.linkedPlotMode(component); + mode = labkit.app.internal.native.NativeAdapterValues.linkedPlotMode(component); if ~isempty(mode) mode.Value = value; return end - rangeEnd = labkit.app.internal.NativeAdapterValues.linkedRangeEnd(component); + rangeEnd = labkit.app.internal.native.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); + labkit.app.internal.native.NativeAdapterValues.setIfProperty(component, "Value", value); + linked = labkit.app.internal.native.NativeAdapterValues.linkedPannerSlider(component); if ~isempty(linked) linked.Value = min(linked.Limits(2), ... max(linked.Limits(1), double(value))); diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyView.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyView.m similarity index 58% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/applyView.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyView.m index e19f8c74e..bb27739ca 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyView.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyView.m @@ -1,10 +1,10 @@ function applyView(obj, view) % Class-folder implementation of MatlabPlatformAdapter.applyView. - operations = labkit.app.internal.NativeAdapterValues.orderedOperations(view.operationsForCompiler()); + operations = labkit.app.internal.native.NativeAdapterValues.orderedOperations(view.operationsForCompiler()); interactionOperations = operations(cellfun(@(operation) ... - labkit.app.internal.NativeAdapterValues.isInteractionKind(operation.Kind), operations)); + labkit.app.internal.native.NativeAdapterValues.isInteractionKind(operation.Kind), operations)); operations = operations(~cellfun(@(operation) ... - labkit.app.internal.NativeAdapterValues.isInteractionKind(operation.Kind), operations)); + labkit.app.internal.native.NativeAdapterValues.isInteractionKind(operation.Kind), operations)); for k = 1:numel(operations) obj.apply(operations{k}); end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/buildTree.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/buildTree.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/buildTree.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/buildTree.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/childRowHeights.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/childRowHeights.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/childRowHeights.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/childRowHeights.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/collectInteractionDeclarations.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/collectInteractionDeclarations.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/collectInteractionDeclarations.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/collectInteractionDeclarations.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/contentParent.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/contentParent.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/contentParent.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/contentParent.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createAxes.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createAxes.m similarity index 75% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/createAxes.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/createAxes.m index f3a7c2498..eb557843a 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/createAxes.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createAxes.m @@ -21,11 +21,11 @@ function createAxes(obj, node, parent) case "pair" layout = uigridlayout(root, [1 axisCount]); layout.RowHeight = {'1x'}; - layout.ColumnWidth = labkit.app.internal.NativeAdapterValues.repeatedOrConfigured( ... + layout.ColumnWidth = labkit.app.internal.native.NativeAdapterValues.repeatedOrConfigured( ... config.ColumnWidths, axisCount); case "stack" layout = uigridlayout(root, [axisCount 1]); - layout.RowHeight = labkit.app.internal.NativeAdapterValues.repeatedOrConfigured( ... + layout.RowHeight = labkit.app.internal.native.NativeAdapterValues.repeatedOrConfigured( ... config.RowHeights, axisCount); layout.ColumnWidth = {'1x'}; otherwise @@ -40,7 +40,7 @@ function createAxes(obj, node, parent) layout.ColumnSpacing = 2; for k = 1:numel(node.AxisIds) axisId = node.AxisIds(k); - key = labkit.app.internal.NativeAdapterValues.axisKey(node.Id, axisId); + key = labkit.app.internal.native.NativeAdapterValues.axisKey(node.Id, axisId); ax = uiaxes(layout, Tag=char(key)); if config.Layout == "stack" ax.Layout.Row = k; @@ -52,9 +52,9 @@ function createAxes(obj, node, parent) 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)); + title(ax, labkit.app.internal.native.NativeAdapterValues.axisText(config.AxisTitles, node.AxisIds, k)); + xlabel(ax, labkit.app.internal.native.NativeAdapterValues.axisText(config.XLabels, strings(1, axisCount), k)); + ylabel(ax, labkit.app.internal.native.NativeAdapterValues.axisText(config.YLabels, strings(1, axisCount), k)); if config.ScrollZoomAxes(k) ~= "xy" setappdata(ax, "labkitPreviewScrollZoomAxes", ... config.ScrollZoomAxes(k)); diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createComponent.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createComponent.m similarity index 94% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/createComponent.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/createComponent.m index ffe01fe2a..64510d5f4 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/createComponent.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createComponent.m @@ -32,9 +32,9 @@ obj.installContentGrid(node, component); case "button" component = uibutton(parent, Text=config.Label, ... - Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled), ... + Enable=labkit.app.internal.native.NativeAdapterValues.onOff(config.Enabled), ... Tooltip=char(config.Tooltip)); - labkit.app.internal.NativeAdapterValues.fitText(component, ... + labkit.app.internal.native.NativeAdapterValues.fitText(component, ... CharsPerStep=18, MaxShrinkSteps=3); case "field" component = obj.createField(parent, config, node.Id); diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createDataTable.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createDataTable.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/createDataTable.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/createDataTable.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createField.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createField.m similarity index 57% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/createField.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/createField.m index d98570657..61a34657a 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/createField.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createField.m @@ -1,31 +1,31 @@ function component = createField(~, parent, config, id) % Class-folder implementation of MatlabPlatformAdapter.createField. - value = labkit.app.internal.NativeAdapterValues.neutralValue(config.Value, config.Kind, config.Choices); + value = labkit.app.internal.native.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)); + Value=logical(value), Enable=labkit.app.internal.native.NativeAdapterValues.onOff(config.Enabled)); return; end - parent = labkit.app.internal.NativeAdapterValues.labeledParent(parent, config.Label, id); + parent = labkit.app.internal.native.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)); + Value=value, Enable=labkit.app.internal.native.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)); + Value=value, Enable=labkit.app.internal.native.NativeAdapterValues.onOff(config.Enabled)); case "readonly" component = uitextarea(parent, Editable="off", ... Value=char(string(value)), ... - Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled)); + Enable=labkit.app.internal.native.NativeAdapterValues.onOff(config.Enabled)); otherwise component = uieditfield(parent, "text", ... - Value=string(value), Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled)); + Value=string(value), Enable=labkit.app.internal.native.NativeAdapterValues.onOff(config.Enabled)); end component.UserData = struct( ... "LayoutContainer", layoutContainer); diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createFilePanel.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createFilePanel.m similarity index 94% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/createFilePanel.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/createFilePanel.m index 198a28f8f..684086441 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/createFilePanel.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createFilePanel.m @@ -10,7 +10,7 @@ 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); + labkit.app.internal.native.NativeAdapterValues.fitText(choose, CharsPerStep=18, MaxShrinkSteps=3); status = uieditfield(grid, Editable="off", ... Value=char(config.EmptyText), ... Tag=char(node.Id + ".status")); @@ -60,7 +60,7 @@ 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), ... + labkit.app.internal.native.NativeAdapterValues.fitText(buttons(k), ... CharsPerStep=18, MaxShrinkSteps=3); end status = []; diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createPanner.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createPanner.m similarity index 73% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/createPanner.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/createPanner.m index 657b3e5ee..a403a44b9 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/createPanner.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createPanner.m @@ -1,8 +1,8 @@ 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); + [limits, value] = labkit.app.internal.native.NativeAdapterValues.sliderInitialValue(config); + outer = labkit.app.internal.native.NativeAdapterValues.labeledParent(parent, config.Label, node.Id); grid = uigridlayout(outer, [1 2], ... Padding=[0 0 0 0], ColumnSpacing=6, ... ColumnWidth={76, '1x'}, ... @@ -14,7 +14,7 @@ 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)); + Step=step, Enable=labkit.app.internal.native.NativeAdapterValues.onOff(config.Enabled)); spinner.Layout.Row = 1; spinner.Layout.Column = 1; if strlength(config.ValueDisplayFormat) > 0 @@ -22,7 +22,7 @@ char(config.ValueDisplayFormat); end slider = uislider(grid, Limits=limits, Value=value, ... - Enable=labkit.app.internal.NativeAdapterValues.onOff(config.Enabled), ... + Enable=labkit.app.internal.native.NativeAdapterValues.onOff(config.Enabled), ... Tag=char(node.Id + ".slider")); slider.Layout.Row = 1; slider.Layout.Column = 2; diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createRangeField.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createRangeField.m similarity index 64% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/createRangeField.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/createRangeField.m index 18eca0352..c6ceed6ca 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/createRangeField.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createRangeField.m @@ -1,8 +1,8 @@ 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); + [limits, value] = labkit.app.internal.native.NativeAdapterValues.rangeSliderInitialValue(config); + outer = labkit.app.internal.native.NativeAdapterValues.labeledParent(parent, config.Label, node.Id); grid = uigridlayout(outer, [1 2], ... Padding=[0 0 0 0], ColumnSpacing=6, ... ColumnWidth={'1x', '1x'}, ... @@ -10,11 +10,11 @@ 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)); + Value=value(1), Enable=labkit.app.internal.native.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), ... + Value=value(2), Enable=labkit.app.internal.native.NativeAdapterValues.onOff(config.Enabled), ... Tag=char(node.Id + ".end")); second.Layout.Row = 1; second.Layout.Column = 2; diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createTextPanel.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createTextPanel.m similarity index 92% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/createTextPanel.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/createTextPanel.m index a413f4a41..8e295e2cf 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/createTextPanel.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createTextPanel.m @@ -16,7 +16,7 @@ Padding=[7 7 7 7], RowHeight={30, '1x'}, ... RowSpacing=4); follow = uibutton(grid, Text="Pause auto-scroll"); - labkit.app.internal.NativeAdapterValues.fitText(follow, ... + labkit.app.internal.native.NativeAdapterValues.fitText(follow, ... CharsPerStep=18, MaxShrinkSteps=2); follow.Tag = char(node.Id + ".follow"); follow.Layout.Row = 1; @@ -38,7 +38,7 @@ grid = uigridlayout(panel, [1 1], Padding=[7 7 7 7]); textArea = uitextarea(grid, Editable="off"); if isfield(config, "Lines") && config.Lines <= 2 - policy = labkit.app.internal.NativeAdapterValues.layoutPolicy(); + policy = labkit.app.internal.native.NativeAdapterValues.layoutPolicy(); textArea.FontSize = policy.SummaryFontSize; setappdata(textArea, ... "labkitAppTextFitMinFontSize", policy.SummaryFontSize); diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installCallbacks.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installCallbacks.m similarity index 58% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/installCallbacks.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/installCallbacks.m index 5f909b6d5..689d56af4 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/installCallbacks.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installCallbacks.m @@ -10,45 +10,50 @@ function installCallbacks(obj) switch node.Kind case "button" component.ButtonPushedFcn = @(~, ~) ... - obj.Runtime.invokeAction(node.Id); + obj.runUserInput(@() obj.Runtime.invokeAction(node.Id)); case "field" if isprop(component, "ValueChangedFcn") component.ValueChangedFcn = @(src, ~) ... - obj.Runtime.applyControlValue(node.Id, src.Value); + obj.runUserInput(@() ... + obj.Runtime.applyControlValue(node.Id, src.Value)); end case "rangeField" component.ValueChangedFcn = @(~, ~) ... - obj.rangeChanged(node.Id); - rangeEnd = labkit.app.internal.NativeAdapterValues.linkedRangeEnd(component); + obj.runUserInput(@() obj.rangeChanged(node.Id)); + rangeEnd = labkit.app.internal.native.NativeAdapterValues.linkedRangeEnd(component); rangeEnd.ValueChangedFcn = @(~, ~) ... - obj.rangeChanged(node.Id); + obj.runUserInput(@() obj.rangeChanged(node.Id)); case "slider" component.ValueChangedFcn = @(src, ~) ... - obj.pannerChanged(node.Id, src.Value); - linked = labkit.app.internal.NativeAdapterValues.linkedPannerSlider(component); + obj.runUserInput(@() ... + obj.pannerChanged(node.Id, src.Value)); + linked = labkit.app.internal.native.NativeAdapterValues.linkedPannerSlider(component); linked.ValueChangedFcn = @(src, ~) ... - obj.pannerChanged(node.Id, src.Value); + obj.runUserInput(@() ... + 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)); + obj.runUserInput(@() obj.pannerChanged(node.Id, ... + labkit.app.internal.native.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); + mode = labkit.app.internal.native.NativeAdapterValues.linkedPlotMode(component); if ~isempty(mode) mode.ValueChangedFcn = @(src, ~) ... + obj.runUserInput(@() ... obj.Runtime.applyControlValue( ... - node.Id, string(src.Value)); + node.Id, string(src.Value))); end case "workspace" if ~isempty(node.PageIds) component.SelectionChangedFcn = @(src, ~) ... + obj.runUserInput(@() ... obj.Runtime.applyControlValue( ... - node.Id, string(src.SelectedTab.Tag)); + node.Id, string(src.SelectedTab.Tag))); end end end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installContentGrid.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installContentGrid.m similarity index 89% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/installContentGrid.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/installContentGrid.m index ff97177a7..26285af09 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/installContentGrid.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installContentGrid.m @@ -3,7 +3,7 @@ function installContentGrid(obj, node, component) if isempty(node.ChildIds) return; end - policy = labkit.app.internal.NativeAdapterValues.layoutPolicy(); + policy = labkit.app.internal.native.NativeAdapterValues.layoutPolicy(); padding = policy.ContentPadding; if node.Kind == "group" padding = [0 0 0 0]; @@ -55,14 +55,14 @@ function installContentGrid(obj, node, component) end grid.RowHeight = heights; if node.Kind == "tab" && isprop(grid, "Scrollable") - grid.Scrollable = labkit.app.internal.NativeAdapterValues.onOff(~singleGrowable); + grid.Scrollable = labkit.app.internal.native.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); + labkit.app.internal.native.NativeAdapterValues.installRowDivider(obj.Figure, grid, 2 * k - 1, 2 * k); end end end diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installFilePanelCallbacks.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installFilePanelCallbacks.m new file mode 100644 index 000000000..69f680099 --- /dev/null +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installFilePanelCallbacks.m @@ -0,0 +1,31 @@ +function installFilePanelCallbacks(obj, node, list) +% Class-folder implementation of MatlabPlatformAdapter.installFilePanelCallbacks. + handles = list.UserData; + list.ValueChangedFcn = @(src, ~) obj.runUtility(@() ... + obj.Runtime.applyFilePanelSelection(node.Id, ... + labkit.app.internal.native.NativeAdapterValues.selectedIndices(src)), ... + "Could not select file"); + handles.Choose.ButtonPushedFcn = @(~, ~) obj.runUtility( ... + @() obj.chooseFiles(node.Id), "Could not add files"); + if ~isempty(handles.Folder) + handles.Folder.ButtonPushedFcn = @(~, ~) obj.runUtility( ... + @() obj.chooseFolderFiles(node.Id, false), ... + "Could not add folder"); + end + if ~isempty(handles.RecursiveFolder) + handles.RecursiveFolder.ButtonPushedFcn = @(~, ~) obj.runUtility( ... + @() obj.chooseFolderFiles(node.Id, true), ... + "Could not add folder tree"); + end + if ~isempty(handles.Remove) + handles.Remove.ButtonPushedFcn = @(~, ~) obj.runUtility( ... + @() obj.removeSelectedFiles(node.Id, list), ... + "Could not remove files"); + end + if ~isempty(handles.Clear) + handles.Clear.ButtonPushedFcn = @(~, ~) obj.runUtility(@() ... + obj.Runtime.applyFileSelection( ... + node.Id, strings(1, 0), zeros(1, 0)), ... + "Could not clear files"); + end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installTableCallbacks.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installTableCallbacks.m similarity index 56% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/installTableCallbacks.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/installTableCallbacks.m index f0b983038..323e11c2d 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/installTableCallbacks.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installTableCallbacks.m @@ -4,17 +4,18 @@ function installTableCallbacks(obj, node, component) "UniformOutput", false)); if any(roles == "cellEdited") component.CellEditCallback = @(src, event) ... - obj.dispatchTableEdit(node.Id, src, event); + obj.runUserInput(@() ... + 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)); + obj.runUserInput(@() obj.Runtime.applyTableSelection( ... + node.Id, labkit.app.internal.native.NativeAdapterValues.tableSelectionCells(event))); else component.CellSelectionCallback = @(~, event) ... - obj.Runtime.applyTableSelection( ... - node.Id, labkit.app.internal.NativeAdapterValues.tableSelectionCells(event)); + obj.runUserInput(@() obj.Runtime.applyTableSelection( ... + node.Id, labkit.app.internal.native.NativeAdapterValues.tableSelectionCells(event))); end end end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installUtilityMenus.m similarity index 86% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/installUtilityMenus.m index e54774d1d..68c8f471b 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installUtilityMenus.m @@ -8,12 +8,7 @@ function installUtilityMenus(obj) Tag="labkitAppUtilitySessionLog", ... MenuSelectedFcn=@(~, ~) obj.runUtility( ... @() obj.openSessionLog())); - obj.TraceCaptureMenu = uimenu( ... - diagnosticsMenu, Text="Trace Capture", Checked="off", ... - Tag="labkitAppUtilityTraceCapture", ... - MenuSelectedFcn=@(~, ~) obj.runUtility( ... - @() obj.toggleTraceCapture())); - uimenu(diagnosticsMenu, Text="Export Diagnostic Bundle...", ... + uimenu(diagnosticsMenu, Text="Export Diagnostic Bundle", ... Tag="labkitAppUtilityExportDiagnostics", ... MenuSelectedFcn=@(~, ~) obj.runUtility( ... @() obj.Runtime.exportDiagnosticBundleInteractive())); @@ -48,7 +43,7 @@ function installUtilityMenus(obj) Tag="labkitAppUtilityCopyScreenshot", ... MenuSelectedFcn=@(~, ~) obj.runUtility( ... @() obj.copyScreenshot())); - uimenu(screenshotMenu, Text="Save to File...", ... + uimenu(screenshotMenu, Text="Save to Artifacts", ... Tag="labkitAppUtilityScreenshot", ... MenuSelectedFcn=@(~, ~) obj.runUtility( ... @() obj.saveScreenshot())); @@ -56,7 +51,7 @@ function installUtilityMenus(obj) if obj.hasProjectDocument() projectMenu = uimenu(toolsMenu, Text="Project State", ... Tag="labkitAppUtilityProjectStateMenu"); - uimenu(projectMenu, Text="Save State...", ... + uimenu(projectMenu, Text="Save State", ... Tag="labkitAppUtilitySaveState", ... MenuSelectedFcn=@(~, ~) obj.runUtility( ... @() obj.saveState())); diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installWorkbenchLayout.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installWorkbenchLayout.m similarity index 92% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/installWorkbenchLayout.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/installWorkbenchLayout.m index d0efa87b6..b82dac749 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/installWorkbenchLayout.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installWorkbenchLayout.m @@ -1,6 +1,6 @@ function installWorkbenchLayout(obj, node, component) % Class-folder implementation of MatlabPlatformAdapter.installWorkbenchLayout. - policy = labkit.app.internal.NativeAdapterValues.layoutPolicy(); + policy = labkit.app.internal.native.NativeAdapterValues.layoutPolicy(); nodes = obj.nodes(node.ChildIds); hasWorkspace = any(string({nodes.Kind}) == "workspace"); columns = 1 + hasWorkspace; @@ -41,7 +41,7 @@ function installWorkbenchLayout(obj, node, component) controlContainer.Layout.Column = 1; obj.WorkbenchControls = controlParent; if hasWorkspace - labkit.app.internal.NativeAdapterValues.installColumnDivider(obj.Figure, grid, 1, 2); + labkit.app.internal.native.NativeAdapterValues.installColumnDivider(obj.Figure, grid, 1, 2); workspaceNode = nodes(string({nodes.Kind}) == "workspace"); workspace = uipanel(grid, ... Title=char(workspaceNode.Configuration.Title), ... diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/interactionTargetAxes.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/interactionTargetAxes.m similarity index 89% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/interactionTargetAxes.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/interactionTargetAxes.m index dca50776b..483061fe1 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/interactionTargetAxes.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/interactionTargetAxes.m @@ -9,7 +9,7 @@ continue; end for axisId = node.AxisIds - key = labkit.app.internal.NativeAdapterValues.axisKey(node.Id, axisId); + key = labkit.app.internal.native.NativeAdapterValues.axisKey(node.Id, axisId); targetId = key; if isscalar(node.AxisIds) targetId = node.Id; diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/isGrowableTabChild.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/isGrowableTabChild.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/nodes.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/nodes.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/nodes.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/nodes.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/owningNode.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/owningNode.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/owningNode.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/owningNode.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/parentFor.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/parentFor.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/parentFor.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/parentFor.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/placeInParent.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/placeInParent.m similarity index 94% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/placeInParent.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/placeInParent.m index 59c952b51..65eeada07 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/placeInParent.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/placeInParent.m @@ -11,7 +11,7 @@ function placeInParent(obj, node, component) if isempty(index) return end - handle = labkit.app.internal.NativeAdapterValues.layoutHandle(component); + handle = labkit.app.internal.native.NativeAdapterValues.layoutHandle(component); if isempty(handle) || ~isprop(handle, "Layout") return end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/preferredRowHeight.m similarity index 87% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/preferredRowHeight.m index 1ea60e635..ebba76c5e 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/preferredRowHeight.m @@ -1,6 +1,6 @@ function height = preferredRowHeight(obj, node) % Class-folder implementation of MatlabPlatformAdapter.preferredRowHeight. - policy = labkit.app.internal.NativeAdapterValues.layoutPolicy(); + policy = labkit.app.internal.native.NativeAdapterValues.layoutPolicy(); switch node.Kind case {"tab", "workspace", "workspacePage", "plotArea"} height = "1x"; @@ -23,7 +23,7 @@ node.Configuration.Lines * policy.StatusLineHeight; end case "button" - height = labkit.app.internal.NativeAdapterValues.estimatedControlHeight( ... + height = labkit.app.internal.native.NativeAdapterValues.estimatedControlHeight( ... node.Configuration.Label, 22, 2, ... policy.ButtonHeight); case "slider" @@ -32,14 +32,14 @@ if node.Configuration.Kind == "readonly" text = [string(node.Configuration.Label), ... string(node.Configuration.Value)]; - height = labkit.app.internal.NativeAdapterValues.estimatedControlHeight( ... + height = labkit.app.internal.native.NativeAdapterValues.estimatedControlHeight( ... text, 34, 3, policy.FieldHeight); elseif node.Configuration.Kind == "logical" - height = labkit.app.internal.NativeAdapterValues.estimatedControlHeight( ... + height = labkit.app.internal.native.NativeAdapterValues.estimatedControlHeight( ... node.Configuration.Label, 42, 2, ... policy.FieldHeight); else - height = labkit.app.internal.NativeAdapterValues.estimatedControlHeight( ... + height = labkit.app.internal.native.NativeAdapterValues.estimatedControlHeight( ... node.Configuration.Label, 30, 2, ... policy.FieldHeight); end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/renderPlot.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/renderPlot.m similarity index 73% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/renderPlot.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/renderPlot.m index 6d19aaf4d..d43e73ff9 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/renderPlot.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/renderPlot.m @@ -5,10 +5,10 @@ function renderPlot(obj, operation) 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)))); + axes(k) = obj.Axes(char(labkit.app.internal.native.NativeAdapterValues.axisKey(node.Id, node.AxisIds(k)))); axesById.(char(node.AxisIds(k))) = axes(k); end - viewport = labkit.app.internal.NativeAdapterValues.captureViewport(axes); + viewport = labkit.app.internal.native.NativeAdapterValues.captureViewport(axes); value = operation.Value; revisionKey = "labkitAppPlotViewRevision"; preserveViewport = all(arrayfun(@(ax) ... @@ -20,6 +20,6 @@ function renderPlot(obj, operation) setappdata(axes(k), revisionKey, value.ViewRevision); end if preserveViewport - labkit.app.internal.NativeAdapterValues.restoreViewport(axes, viewport); + labkit.app.internal.native.NativeAdapterValues.restoreViewport(axes, viewport); end end diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/restoreBusyView.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/restoreBusyView.m new file mode 100644 index 000000000..8aed46cef --- /dev/null +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/restoreBusyView.m @@ -0,0 +1,24 @@ +function restoreBusyView(obj, view, restoreValues) +%RESTOREBUSYVIEW Reconcile native input state after a busy transaction. +% Caller: BusyLifecycle through MatlabPlatformAdapter. VIEW is the committed +% Snapshot; RESTOREVALUES also reapplies non-plot values after rejected input. + +operations = labkit.app.internal.native.NativeAdapterValues.orderedOperations( ... + view.operationsForCompiler()); +for index = 1:numel(operations) + operation = operations{index}; + if operation.Kind == "enabled" || ... + (restoreValues && ... + operation.Kind ~= "renderPlot" && ... + ~labkit.app.internal.native.NativeAdapterValues.isInteractionKind( ... + operation.Kind)) + obj.apply(operation); + elseif operation.Kind == "workspacePage" + component = obj.component(operation.Target); + labkit.app.internal.native.NativeAdapterValues.setIfProperty( ... + component, "Enable", ... + labkit.app.internal.native.NativeAdapterValues.onOff( ... + operation.Value.Enabled)); + end +end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/sectionDrawsOwnTitle.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/sectionDrawsOwnTitle.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/sectionDrawsOwnTitle.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/sectionDrawsOwnTitle.m diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/usesAdaptiveActionGrid.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/usesAdaptiveActionGrid.m similarity index 100% rename from +labkit/+app/+internal/@MatlabPlatformAdapter/usesAdaptiveActionGrid.m rename to +labkit/+app/+internal/+native/@MatlabPlatformAdapter/usesAdaptiveActionGrid.m diff --git a/+labkit/+app/+internal/+native/BusyLifecycle.m b/+labkit/+app/+internal/+native/BusyLifecycle.m new file mode 100644 index 000000000..6233367d1 --- /dev/null +++ b/+labkit/+app/+internal/+native/BusyLifecycle.m @@ -0,0 +1,181 @@ +classdef (Hidden, Sealed) BusyLifecycle < handle + % Own delayed native busy presentation and non-reentrant input gating. + % Caller: MatlabPlatformAdapter. The lifecycle retains only native window + % state and invokes restoreView(snapshot,restoreValues) when work ends. + + properties (SetAccess = private) + Active (1, 1) logical = false + end + + properties (Access = private) + Figure + RestoreView + BaseWindowTitle (1, 1) string + Visible (1, 1) logical = false + Message (1, 1) string = "" + RejectedInput (1, 1) logical = false + Timer = [] + EnableHandles (1, :) cell = {} + EnableValues (1, :) cell = {} + PriorPointer (1, 1) string = "arrow" + end + + methods + function obj = BusyLifecycle(figureHandle, title, restoreView) + if isempty(figureHandle) || ~isvalid(figureHandle) || ... + ~isa(restoreView, "function_handle") || ... + ~isscalar(restoreView) + error("labkit:app:runtime:InvariantFailure", ... + "Native busy lifecycle inputs are invalid."); + end + obj.Figure = figureHandle; + obj.BaseWindowTitle = string(title); + obj.RestoreView = restoreView; + end + + function setWindowTitle(obj, title) + obj.BaseWindowTitle = string(title); + if ~obj.Active && obj.hasFigure() + obj.Figure.Name = char(obj.BaseWindowTitle); + end + end + + function begin(obj, message) + if obj.Active || ~obj.hasFigure() + return + end + message = strip(string(message)); + if strlength(message) == 0 + message = "Working"; + end + obj.Active = true; + obj.Visible = false; + obj.Message = message; + obj.RejectedInput = false; + obj.PriorPointer = string(obj.Figure.Pointer); + setappdata(obj.Figure, "labkitAppBusy", true); + obj.Timer = timer( ... + ExecutionMode="singleShot", StartDelay=0.25, ... + BusyMode="drop", TimerFcn=@(~, ~) obj.show()); + start(obj.Timer); + end + + function update(obj, message) + if ~obj.Active + return + end + message = strip(string(message)); + if strlength(message) > 0 + obj.Message = message; + end + if obj.Visible && obj.hasFigure() + obj.Figure.Name = char(obj.busyWindowTitle()); + drawnow limitrate nocallbacks + end + end + + function accepted = acceptInput(obj) + accepted = ~obj.Active; + if ~accepted + obj.RejectedInput = true; + end + end + + function finish(obj, view) + if ~obj.Active + return + end + wasVisible = obj.Visible; + rejectedInput = obj.RejectedInput; + obj.Active = false; + obj.Visible = false; + obj.RejectedInput = false; + obj.cancelTimer(); + if ~obj.hasFigure() + return + end + if wasVisible || rejectedInput + obj.restoreControls(); + obj.RestoreView(view, rejectedInput); + end + if wasVisible + obj.Figure.Pointer = char(obj.PriorPointer); + obj.Figure.Name = char(obj.BaseWindowTitle); + end + if isappdata(obj.Figure, "labkitAppBusy") + rmappdata(obj.Figure, "labkitAppBusy"); + end + if wasVisible + drawnow limitrate nocallbacks + end + end + + function close(obj) + obj.Active = false; + obj.Visible = false; + obj.RejectedInput = false; + obj.cancelTimer(); + obj.EnableHandles = {}; + obj.EnableValues = {}; + end + + function delete(obj) + obj.close(); + end + end + + methods (Access = private) + function show(obj) + if ~obj.Active || obj.Visible || ~obj.hasFigure() + return + end + obj.Visible = true; + obj.Figure.Pointer = "watch"; + obj.Figure.Name = char(obj.busyWindowTitle()); + obj.disableControls(); + drawnow limitrate nocallbacks + end + + function title = busyWindowTitle(obj) + title = obj.BaseWindowTitle + ... + " [Working: " + obj.Message + "]"; + end + + function disableControls(obj) + handles = findall(obj.Figure, "-property", "Enable"); + obj.EnableHandles = cell(1, numel(handles)); + obj.EnableValues = cell(1, numel(handles)); + for index = 1:numel(handles) + handle = handles(index); + obj.EnableHandles{index} = handle; + obj.EnableValues{index} = handle.Enable; + handle.Enable = "off"; + end + end + + function restoreControls(obj) + for index = 1:numel(obj.EnableHandles) + handle = obj.EnableHandles{index}; + if ~isempty(handle) && isvalid(handle) + handle.Enable = obj.EnableValues{index}; + end + end + obj.EnableHandles = {}; + obj.EnableValues = {}; + end + + function cancelTimer(obj) + timerValue = obj.Timer; + obj.Timer = []; + if isempty(timerValue) || ~isvalid(timerValue) + return + end + stop(timerValue); + delete(timerValue); + end + + function tf = hasFigure(obj) + tf = ~isempty(obj.Figure) && isvalid(obj.Figure); + end + end +end diff --git a/+labkit/+app/+internal/HeadlessPlatformAdapter.m b/+labkit/+app/+internal/+native/HeadlessPlatformAdapter.m similarity index 91% rename from +labkit/+app/+internal/HeadlessPlatformAdapter.m rename to +labkit/+app/+internal/+native/HeadlessPlatformAdapter.m index 4daa5391c..46fc386ee 100644 --- a/+labkit/+app/+internal/HeadlessPlatformAdapter.m +++ b/+labkit/+app/+internal/+native/HeadlessPlatformAdapter.m @@ -8,7 +8,7 @@ FailNext (1, 1) logical = false end - methods (Access = ?labkit.app.internal.RuntimeKernel) + methods (Access = ?labkit.app.internal.runtime.RuntimeKernel) function reconcile(obj, ~, ~) if obj.FailNext obj.FailNext = false; diff --git a/+labkit/+app/+internal/NativeAdapterValues.m b/+labkit/+app/+internal/+native/NativeAdapterValues.m similarity index 99% rename from +labkit/+app/+internal/NativeAdapterValues.m rename to +labkit/+app/+internal/+native/NativeAdapterValues.m index 05eb58d1b..d34aad5d9 100644 --- a/+labkit/+app/+internal/NativeAdapterValues.m +++ b/+labkit/+app/+internal/+native/NativeAdapterValues.m @@ -438,7 +438,7 @@ function restoreViewport(axes, viewport) end function path = safeStartPath(value) - path = labkit.app.internal.NativeAdapterValues.dialogStartFolder( ... + path = labkit.app.internal.native.NativeAdapterValues.dialogStartFolder( ... "input", value); end diff --git a/+labkit/+app/+internal/private/FigureInteractionHub.m b/+labkit/+app/+internal/+native/private/FigureInteractionHub.m similarity index 100% rename from +labkit/+app/+internal/private/FigureInteractionHub.m rename to +labkit/+app/+internal/+native/private/FigureInteractionHub.m diff --git a/+labkit/+app/+internal/private/InteractionController.m b/+labkit/+app/+internal/+native/private/InteractionController.m similarity index 100% rename from +labkit/+app/+internal/private/InteractionController.m rename to +labkit/+app/+internal/+native/private/InteractionController.m diff --git a/+labkit/+app/+internal/private/applyTextFit.m b/+labkit/+app/+internal/+native/private/applyTextFit.m similarity index 100% rename from +labkit/+app/+internal/private/applyTextFit.m rename to +labkit/+app/+internal/+native/private/applyTextFit.m diff --git a/+labkit/+app/+internal/private/createAnchorEditor.m b/+labkit/+app/+internal/+native/private/createAnchorEditor.m similarity index 99% rename from +labkit/+app/+internal/private/createAnchorEditor.m rename to +labkit/+app/+internal/+native/private/createAnchorEditor.m index 15cac0dc0..932f752cd 100644 --- a/+labkit/+app/+internal/private/createAnchorEditor.m +++ b/+labkit/+app/+internal/+native/private/createAnchorEditor.m @@ -385,8 +385,9 @@ function ensureGraphics() end return; end - points = addOrInsertAnchor(points, point, state.ax, ... - state.imageSize, state.style, state.closed, state.maxPoints); + points = labkit.app.internal.interaction.addOrInsertAnchor( ... + points, point, state.imageSize, state.style, ... + state.closed, state.maxPoints); end function applyStoredView() diff --git a/+labkit/+app/+internal/private/createPointSlotsEditor.m b/+labkit/+app/+internal/+native/private/createPointSlotsEditor.m similarity index 100% rename from +labkit/+app/+internal/private/createPointSlotsEditor.m rename to +labkit/+app/+internal/+native/private/createPointSlotsEditor.m diff --git a/+labkit/+app/+internal/private/createRectangleEditor.m b/+labkit/+app/+internal/+native/private/createRectangleEditor.m similarity index 100% rename from +labkit/+app/+internal/private/createRectangleEditor.m rename to +labkit/+app/+internal/+native/private/createRectangleEditor.m diff --git a/+labkit/+app/+internal/private/installColumnResize.m b/+labkit/+app/+internal/+native/private/installColumnResize.m similarity index 100% rename from +labkit/+app/+internal/private/installColumnResize.m rename to +labkit/+app/+internal/+native/private/installColumnResize.m diff --git a/+labkit/+app/+internal/private/installRowResize.m b/+labkit/+app/+internal/+native/private/installRowResize.m similarity index 100% rename from +labkit/+app/+internal/private/installRowResize.m rename to +labkit/+app/+internal/+native/private/installRowResize.m diff --git a/+labkit/+app/+internal/private/nativeLayoutPolicy.m b/+labkit/+app/+internal/+native/private/nativeLayoutPolicy.m similarity index 100% rename from +labkit/+app/+internal/private/nativeLayoutPolicy.m rename to +labkit/+app/+internal/+native/private/nativeLayoutPolicy.m diff --git a/+labkit/+app/+internal/private/reconcileInteractions.m b/+labkit/+app/+internal/+native/private/reconcileInteractions.m similarity index 99% rename from +labkit/+app/+internal/private/reconcileInteractions.m rename to +labkit/+app/+internal/+native/private/reconcileInteractions.m index 31a221a1a..4ec04d594 100644 --- a/+labkit/+app/+internal/private/reconcileInteractions.m +++ b/+labkit/+app/+internal/+native/private/reconcileInteractions.m @@ -546,7 +546,10 @@ function deleteEditor() options = spec.Options; options.onChanged = callback; kind = lower(spec.Kind); - if any(kind == ["scalebarreference", "scalebar"]) + if kind == "pairedanchors" + options.mode = "points"; + options.closed = false; + elseif any(kind == ["scalebarreference", "scalebar"]) options.closed = false; options.style = "Straight lines"; options.maxPoints = 2; diff --git a/+labkit/+app/+internal/private/zoomAxesAtPoint.m b/+labkit/+app/+internal/+native/private/zoomAxesAtPoint.m similarity index 100% rename from +labkit/+app/+internal/private/zoomAxesAtPoint.m rename to +labkit/+app/+internal/+native/private/zoomAxesAtPoint.m diff --git a/+labkit/+app/+internal/ProjectDocumentStore.m b/+labkit/+app/+internal/+project/ProjectDocumentStore.m similarity index 98% rename from +labkit/+app/+internal/ProjectDocumentStore.m rename to +labkit/+app/+internal/+project/ProjectDocumentStore.m index 41a63013c..9a6b0f4a0 100644 --- a/+labkit/+app/+internal/ProjectDocumentStore.m +++ b/+labkit/+app/+internal/+project/ProjectDocumentStore.m @@ -15,19 +15,19 @@ PendingFingerprint (1, 1) string = "" end - methods (Access = ?labkit.app.internal.RuntimeKernel) + methods (Access = ?labkit.app.internal.runtime.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") + ~isa(contract, "labkit.app.internal.contract.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(); + obj.Sources = labkit.app.internal.source.PortableSourceStore(); nowUtc = utcNow(); obj.Metadata = struct( ... "id", newId(), ... diff --git a/+labkit/+app/+internal/ResourceStore.m b/+labkit/+app/+internal/+resource/ResourceStore.m similarity index 98% rename from +labkit/+app/+internal/ResourceStore.m rename to +labkit/+app/+internal/+resource/ResourceStore.m index 8cb0daae1..aa126a0dd 100644 --- a/+labkit/+app/+internal/ResourceStore.m +++ b/+labkit/+app/+internal/+resource/ResourceStore.m @@ -4,7 +4,7 @@ Entries end - methods (Access = ?labkit.app.internal.RuntimeKernel) + methods (Access = ?labkit.app.internal.runtime.RuntimeKernel) function obj = ResourceStore() obj.Entries = containers.Map( ... "KeyType", "char", "ValueType", "any"); diff --git a/+labkit/+app/+internal/ResultWriter.m b/+labkit/+app/+internal/+result/ResultWriter.m similarity index 99% rename from +labkit/+app/+internal/ResultWriter.m rename to +labkit/+app/+internal/+result/ResultWriter.m index f0b76137f..b0b2aa52c 100644 --- a/+labkit/+app/+internal/ResultWriter.m +++ b/+labkit/+app/+internal/+result/ResultWriter.m @@ -7,7 +7,7 @@ Document end - methods (Access = ?labkit.app.internal.RuntimeKernel) + methods (Access = ?labkit.app.internal.runtime.RuntimeKernel) function obj = ResultWriter(application, document) if ~isa(application, "labkit.app.Definition") invalid("Result writer requires an Application value."); diff --git a/+labkit/+app/+internal/RuntimeKernel.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/RuntimeKernel.m similarity index 53% rename from +labkit/+app/+internal/RuntimeKernel.m rename to +labkit/+app/+internal/+runtime/@RuntimeKernel/RuntimeKernel.m index ecf4cc2d0..8935aa60d 100644 --- a/+labkit/+app/+internal/RuntimeKernel.m +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/RuntimeKernel.m @@ -19,43 +19,52 @@ Documents Sources Recorder + Artifacts + Diagnostics PendingDocumentMetadata = [] end - methods (Access = ?labkit.app.internal.RuntimeFactory) + methods (Access = ?labkit.app.internal.runtime.RuntimeFactory) function obj = RuntimeKernel( ... application, contract, initialProject, backend, platform, ... recorder) obj.Application = application; obj.Contract = contract; - if ~isa(recorder, "labkit.app.internal.SessionDiagnostics") || ... + if ~isa(recorder, "labkit.app.internal.diagnostics.SessionDiagnostics") || ... ~isscalar(recorder) error("labkit:app:runtime:InvariantFailure", ... "RuntimeKernel requires one SessionDiagnostics service."); end obj.Recorder = recorder; + obj.Artifacts = ... + labkit.app.internal.artifact.Store(application.AppId); startupOperation = obj.Recorder.begin( ... "runtime.lifecycle", "runtime.construct", ... "Constructing application runtime."); - obj.Resources = labkit.app.internal.ResourceStore(); - obj.Sources = labkit.app.internal.PortableSourceStore(); + obj.Resources = labkit.app.internal.resource.ResourceStore(); + obj.Sources = labkit.app.internal.source.PortableSourceStore(); if nargin < 4 platform = "headless"; end obj.Adapter = ... - labkit.app.internal.RuntimeContractBoundary.createAdapter( ... + labkit.app.internal.runtime.RuntimeContractBoundary.createAdapter( ... obj.Application, obj.Contract, platform); try backend = obj.completeBackend(backend); obj.Context = ... - labkit.app.internal.CallbackContextFactory.create(backend); + labkit.app.internal.runtime.CallbackContextFactory.create(backend); + obj.Diagnostics = ... + labkit.app.internal.diagnostics.RuntimeDiagnostics( ... + obj.Recorder, obj.Context, obj.Artifacts, ... + obj.Application.DisplayName, ... + @(message, title) obj.notifyUser(message, title)); obj.updateStartup("Creating app state..."); if ~isempty(application.ProjectSchema) - obj.Documents = labkit.app.internal.ProjectDocumentStore( ... + obj.Documents = labkit.app.internal.project.ProjectDocumentStore( ... application, obj.Context, contract); end project = ... - labkit.app.internal.RuntimeContractBoundary.initialProject( ... + labkit.app.internal.runtime.RuntimeContractBoundary.initialProject( ... obj.Application, initialProject); session = struct(); if ~isempty(application.CreateSession) @@ -66,10 +75,10 @@ "Application Session must return a scalar struct."); end obj.State = struct("project", project, "session", session); - labkit.app.internal.RuntimeContractBoundary.validateState( ... + labkit.app.internal.runtime.RuntimeContractBoundary.validateState( ... obj.Application, obj.State); obj.updateStartup("Preparing first view..."); - if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + if isa(obj.Adapter, "labkit.app.internal.native.MatlabPlatformAdapter") obj.Adapter.attachRuntime(obj); end obj.Presentation = obj.present(obj.State); @@ -81,14 +90,14 @@ end obj.Recorder.finish( ... startupOperation, "completed", "committed", []); - if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + if isa(obj.Adapter, "labkit.app.internal.native.MatlabPlatformAdapter") obj.Adapter.finishStartup(); end catch cause obj.Recorder.finish( ... startupOperation, "failed", "notApplicable", cause); if isa(obj.Adapter, ... - "labkit.app.internal.MatlabPlatformAdapter") + "labkit.app.internal.native.MatlabPlatformAdapter") obj.StartupFailed = true; obj.Adapter.failStartup(cause); return @@ -102,7 +111,7 @@ methods function dispatch(obj, binding, payload) obj.assertOpen(); - labkit.app.internal.RuntimeContractBoundary.validateDispatch( ... + labkit.app.internal.runtime.RuntimeContractBoundary.validateDispatch( ... obj.Contract, binding, payload); obj.Queue{end + 1} = struct( ... "Binding", binding, "Payload", {payload}); @@ -110,9 +119,9 @@ function dispatch(obj, binding, payload) return; end obj.Processing = true; - if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + if isa(obj.Adapter, "labkit.app.internal.native.MatlabPlatformAdapter") obj.Adapter.beginBusy( ... - labkit.app.internal.RuntimeContractBoundary.busyMessage( ... + labkit.app.internal.runtime.RuntimeContractBoundary.busyMessage( ... obj.Contract, binding)); end cleanup = onCleanup(@() obj.finishProcessing()); @@ -161,79 +170,53 @@ function failNextCommit(obj) end function events = diagnosticEvents(obj) - events = obj.Recorder.events(); + events = obj.Diagnostics.events(); end function snapshot = diagnosticSnapshot(obj) - snapshot = obj.Recorder.captureSnapshot(); + snapshot = obj.Diagnostics.snapshot(); + end + + function title = sessionLogTitle(obj) + title = obj.Diagnostics.title(); end function token = subscribeDiagnostics(obj, callback) - token = obj.Recorder.subscribe(callback); + token = obj.Diagnostics.subscribe(callback); end function unsubscribeDiagnostics(obj, token) - obj.Recorder.unsubscribe(token); + obj.Diagnostics.unsubscribe(token); end function setTraceCapture(obj, enabled) - obj.Recorder.setTraceEnabled(enabled); + obj.Diagnostics.setTraceCapture(enabled); end - function destination = exportDiagnosticBundle(obj, destination) - operation = obj.Recorder.begin( ... - "runtime.lifecycle", "diagnostics.bundle_exported", ... - "Exporting diagnostic bundle."); - try - destination = obj.Recorder.exportBundle( ... - destination, operation.Id); - obj.Recorder.finish( ... - operation, "completed", "notApplicable", []); - catch cause - obj.Recorder.finish( ... - operation, "failed", "notApplicable", cause); - destination = obj.exportDiagnosticTextFallback( ... - destination, cause); + function destination = exportDiagnosticBundle( ... + obj, destination, stateMode) + if nargin < 3 + stateMode = "exact"; end + destination = obj.Diagnostics.exportBundle( ... + destination, obj.State, stateMode); end function destination = exportDiagnosticBundleInteractive(obj) - destination = ""; - try - choice = obj.Context.chooseOutputFile( ... - {"*.zip", "Diagnostic bundle (*.zip)"}, ... - "labkit-diagnostics.zip"); - catch cause - destination = obj.exportDiagnosticTextFallback("", cause); - obj.alertDiagnosticTextFallback(destination); - return; - end - if ~choice.Cancelled - destination = obj.exportDiagnosticBundle(choice.Value); - if endsWith(destination, ".txt", ... - IgnoreCase=true) - obj.alertDiagnosticTextFallback(destination); - end - end + destination = obj.Diagnostics.exportInteractive(obj.State); end function destination = exportDiagnosticTextFallback( ... - obj, preferredDestination, cause) - obj.Recorder.log( ... - "warning", "diagnostics.text_fallback.started", ... - "Diagnostic ZIP export failed; writing a plain-text fallback.", ... - Category="runtime.lifecycle", Audience="user", ... - Exception=cause); - destination = obj.Recorder.exportTextFallback( ... - preferredDestination, cause); + obj, preferredDestination, cause, stateMode) + if nargin < 4 + stateMode = "exact"; + end + destination = obj.Diagnostics.exportTextFallback( ... + preferredDestination, cause, stateMode); end function alertDiagnosticTextFallback(obj, destination) - obj.Context.alert( ... - "The diagnostic ZIP could not be exported. A plain-text " + ... - "diagnostic fallback was written to:" + newline + ... - string(destination), ... - "Diagnostic Text Fallback"); + obj.Diagnostics.alertTextFallback(destination); end function supported = supportsSyntheticInputs(obj) @@ -245,7 +228,7 @@ function alertDiagnosticTextFallback(obj, destination) "runtime.source", "synthetic_inputs.generated", ... "Generating synthetic inputs."); try - pack = labkit.app.internal.SyntheticInputGenerator.generate( ... + pack = labkit.app.internal.source.SyntheticInputGenerator.generate( ... obj.Application, folder); obj.Recorder.finish( ... operation, "completed", "notApplicable", []); @@ -269,8 +252,19 @@ function alertDiagnosticTextFallback(obj, destination) "Synthetic Inputs"); end + function destination = automaticArtifactDestination( ... + obj, category, stem, extension) + destination = obj.Artifacts.destination( ... + category, stem, extension); + end + + function filename = automaticArtifactFilename( ... + obj, stem, extension) + filename = obj.Artifacts.filename(stem, extension); + end + function figure = figureHandle(obj) - if ~isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + if ~isa(obj.Adapter, "labkit.app.internal.native.MatlabPlatformAdapter") error("labkit:app:runtime:InvariantFailure", ... "Headless runtime has no MATLAB figure."); end @@ -278,7 +272,7 @@ function alertDiagnosticTextFallback(obj, destination) end function showFigure(obj) - if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + if isa(obj.Adapter, "labkit.app.internal.native.MatlabPlatformAdapter") obj.Adapter.show(obj.formattedWindowTitle()); end end @@ -352,48 +346,7 @@ function saveRecovery(obj, state, filepath) end end - function restoreProject(obj, filepath, asRecovery) - if nargin < 3 - asRecovery = false; - end - obj.assertOpen(); - obj.assertProjectStore(); - previousState = obj.State; - previousPresentation = obj.Presentation; - operation = obj.Recorder.begin( ... - "runtime.project", "project.restored", ... - "Restoring project document."); - try - [candidate, metadata] = ... - obj.Documents.restore(filepath, asRecovery); - catch cause - obj.Recorder.finish( ... - operation, "failed", "notApplicable", cause); - rethrow(cause); - end - 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(); - obj.Recorder.finish( ... - operation, "completed", "committed", []); - catch cause - obj.State = previousState; - obj.Presentation = previousPresentation; - obj.Recorder.finish( ... - operation, "failed", "rolledBack", cause); - failure = MException( ... - "labkit:app:runtime:ProjectRestoreFailed", ... - "Project restore failed transactionally."); - failure = addCause(failure, cause); - throwAsCaller(failure); - end - end + restoreProject(obj, filepath, asRecovery) function metadata = documentMetadata(obj) obj.assertProjectStore(); @@ -405,7 +358,7 @@ function restoreProject(obj, filepath, asRecovery) if ~isempty(obj.Documents) metadata = obj.Documents.Metadata; end - writer = labkit.app.internal.ResultWriter(obj.Application, metadata); + writer = labkit.app.internal.result.ResultWriter(obj.Application, metadata); written = obj.recordOperation( ... "runtime.result", "result.written", ... "Writing result package.", ... @@ -480,7 +433,7 @@ function apply() obj.applyBoundControl(target, value, true); else binding = ... - labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... + labkit.app.internal.runtime.RuntimeContractBoundary.signalForTarget( ... obj.Contract, target, "valueChanged", false); if isempty(binding) error("labkit:app:contract:UnknownReference", ... @@ -495,7 +448,7 @@ function apply() function invokeAction(obj, target) obj.assertOpen(); binding = ... - labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... + labkit.app.internal.runtime.RuntimeContractBoundary.signalForTarget( ... obj.Contract, target, "pressed"); obj.recordOperation( ... "runtime.interaction", "interaction.action_invoked", ... @@ -511,7 +464,7 @@ function applyTableEdit(obj, target, edit) "Table edit payload must be a TableEdit value."); end binding = ... - labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... + labkit.app.internal.runtime.RuntimeContractBoundary.signalForTarget( ... obj.Contract, target, "cellEdited"); obj.recordOperation( ... "runtime.interaction", "interaction.table_edited", ... @@ -524,7 +477,7 @@ function applyTableSelection(obj, target, cells) obj.assertOpen(); selection = labkit.app.event.TableCellSelection(cells); binding = ... - labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... + labkit.app.internal.runtime.RuntimeContractBoundary.signalForTarget( ... obj.Contract, target, "cellSelectionChanged"); obj.recordOperation( ... "runtime.interaction", "interaction.table_selected", ... @@ -536,7 +489,7 @@ function applyTableSelection(obj, target, cells) function applyInteraction(obj, interactionId, signal, payload) obj.assertOpen(); binding = ... - labkit.app.internal.RuntimeContractBoundary.interactionSignal( ... + labkit.app.internal.runtime.RuntimeContractBoundary.interactionSignal( ... obj.Contract, interactionId, signal); obj.recordOperation( ... "runtime.interaction", "interaction.managed_committed", ... @@ -548,7 +501,7 @@ function applyInteraction(obj, interactionId, signal, payload) function applyFilePanelSelection(obj, target, indices) obj.assertOpen(); [config, current] = ... - labkit.app.internal.RuntimeContractBoundary.fileListState( ... + labkit.app.internal.runtime.RuntimeContractBoundary.fileListState( ... obj.Contract, obj.State, target); obj.recordOperation( ... "runtime.source", "source.selection_changed", ... @@ -558,87 +511,9 @@ function applyFilePanelSelection(obj, target, indices) 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 + applyBoundControl(obj, target, value, dispatchChanged) - function applyFileSelection(obj, target, paths, indices) - obj.assertOpen(); - operation = obj.Recorder.begin( ... - "runtime.source", "source.files_selected", ... - "Applying selected source files."); - try - [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); - obj.Recorder.finish( ... - operation, "completed", "committed", []); - catch cause - obj.Recorder.finish( ... - operation, "failed", "rolledBack", cause); - rethrow(cause); - end - end + applyFileSelection(obj, target, paths, indices) function removeFileSelection(obj, target, indices) obj.assertOpen(); @@ -647,7 +522,7 @@ function removeFileSelection(obj, target, indices) "Removing selected source files."); try [config, current] = ... - labkit.app.internal.RuntimeContractBoundary.fileListState( ... + labkit.app.internal.runtime.RuntimeContractBoundary.fileListState( ... obj.Contract, obj.State, target); visible = obj.Sources.recordsForRole( ... current, config.SourceRole); @@ -732,178 +607,13 @@ function delete(obj) timestamp + "-" + nonce)); end - 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 + commitFilePanel(obj, target, config, sources, indices, rebuildSession) - function backend = completeBackend(obj, backend) - if nargin < 2 || isempty(backend) - backend = struct(); - end - builtins = struct( ... - "log", @(severity, eventName, message, category, audience, attributes, exception) ... - obj.log(severity, eventName, message, category, audience, attributes, exception), ... - "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 - backend = obj.wrapDialogOperations(backend); - end + backend = completeBackend(obj, backend) - function execute(obj, binding, payload) - previousState = obj.State; - previousPresentation = obj.Presentation; - obj.PendingDocumentMetadata = []; - operation = obj.Recorder.begin( ... - "runtime.callback", "callback." + binding.Signal, ... - "Dispatching callback.", Attributes=struct("runtimeAlias", binding.Id)); - 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", "committed", []); - catch cause - obj.State = previousState; - obj.Presentation = previousPresentation; - obj.PendingDocumentMetadata = []; - obj.Resources.clearScope("event"); - obj.Recorder.finish(operation, "failed", "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 + execute(obj, binding, payload) - function view = present(obj, state) - operation = obj.Recorder.begin( ... - "runtime.presentation", "presentation.rendered", ... - "Preparing application presentation."); - try - view = labkit.app.internal.RuntimePresentation.fromState( ... - obj.Contract.PlatformPlan, state, ... - @(records, role) ... - obj.presentationSourcePaths(records, role), ... - obj.CurrentStatus); - 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); - obj.Recorder.finish( ... - operation, "completed", "notApplicable", []); - catch cause - obj.Recorder.finish( ... - operation, "failed", "notApplicable", cause); - rethrow(cause); - end - end + view = present(obj, state) function paths = presentationSourcePaths(obj, records, role) records = obj.Sources.recordsForRole(records, role); @@ -917,57 +627,15 @@ function log(obj, severity, eventName, message, category, audience, attributes, if audience == "user" && ... any(severity == ["info", "warning", "error", "critical"]) obj.CurrentStatus = message; + if obj.Processing && ... + isa(obj.Adapter, ... + "labkit.app.internal.native.MatlabPlatformAdapter") + obj.Adapter.updateBusy(message); + end end end - function backend = wrapDialogOperations(obj, backend) - if isfield(backend, "alert") - operation = backend.alert; - backend.alert = @(message, title) ... - obj.invokeDialogAlert(operation, message, title); - end - if isfield(backend, "choose") - operation = backend.choose; - backend.choose = @(prompt, choices, title, ... - defaultChoice, cancelChoice) ... - obj.invokeDialogChoice( ... - "dialog.option_chosen", "Showing option dialog.", ... - operation, prompt, choices, title, ... - defaultChoice, cancelChoice); - end - if isfield(backend, "chooseInputFile") - operation = backend.chooseInputFile; - backend.chooseInputFile = @(filters, startPath) ... - obj.invokeDialogChoice( ... - "dialog.input_file_chosen", ... - "Showing input-file dialog.", ... - operation, filters, startPath); - end - if isfield(backend, "chooseInputFolder") - operation = backend.chooseInputFolder; - backend.chooseInputFolder = @(startPath) ... - obj.invokeDialogChoice( ... - "dialog.input_folder_chosen", ... - "Showing input-folder dialog.", ... - operation, startPath); - end - if isfield(backend, "chooseOutputFile") - operation = backend.chooseOutputFile; - backend.chooseOutputFile = @(filters, startPath) ... - obj.invokeDialogChoice( ... - "dialog.output_file_chosen", ... - "Showing output-file dialog.", ... - operation, filters, startPath); - end - if isfield(backend, "chooseOutputFolder") - operation = backend.chooseOutputFolder; - backend.chooseOutputFolder = @(startPath) ... - obj.invokeDialogChoice( ... - "dialog.output_folder_chosen", ... - "Showing output-folder dialog.", ... - operation, startPath); - end - end + backend = wrapDialogOperations(obj, backend) function invokeDialogAlert(obj, operation, message, title) obj.recordOperation( ... @@ -1006,13 +674,13 @@ function invokeDialogAlert(obj, operation, message, title) function finishProcessing(obj) obj.Processing = false; - if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") - obj.Adapter.endBusy(); + if isa(obj.Adapter, "labkit.app.internal.native.MatlabPlatformAdapter") + obj.Adapter.endBusy(obj.Presentation); end end function updateStartup(obj, message) - if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + if isa(obj.Adapter, "labkit.app.internal.native.MatlabPlatformAdapter") obj.Adapter.startupUpdate(message); end end @@ -1025,8 +693,17 @@ function markDocumentChanged(obj) obj.refreshWindowTitle(); end + function notifyUser(obj, message, title) + if isa(obj.Adapter, ... + "labkit.app.internal.native.MatlabPlatformAdapter") + obj.Adapter.alert(message, title, "info"); + else + obj.Context.alert(message, title); + end + end + function refreshWindowTitle(obj) - if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") + if isa(obj.Adapter, "labkit.app.internal.native.MatlabPlatformAdapter") obj.Adapter.setWindowTitle(obj.formattedWindowTitle()); end end diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/applyBoundControl.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/applyBoundControl.m new file mode 100644 index 000000000..a0485fc6e --- /dev/null +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/applyBoundControl.m @@ -0,0 +1,47 @@ +function applyBoundControl(obj, target, value, dispatchChanged) +%APPLYBOUNDCONTROL Commit one declared bound-control state transition. + 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.runtime.RuntimeStatePath.write( ... + previousState, path, value); + if dispatchChanged + binding = ... + labkit.app.internal.runtime.RuntimeContractBoundary.signalForTarget( ... + obj.Contract, target, "valueChanged", false); + if ~isempty(binding) + candidate = binding.UpdateState( ... + candidate, value, obj.Context); + end + end + labkit.app.internal.runtime.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 diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/applyFileSelection.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/applyFileSelection.m new file mode 100644 index 000000000..bcfb8f366 --- /dev/null +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/applyFileSelection.m @@ -0,0 +1,55 @@ +function applyFileSelection(obj, target, paths, indices) +%APPLYFILESELECTION Filter and commit one native file-list selection. + obj.assertOpen(); + operation = obj.Recorder.begin( ... + "runtime.source", "source.files_selected", ... + "Applying selected source files."); + try + [config, current] = ... + labkit.app.internal.runtime.RuntimeContractBoundary.fileListState( ... + obj.Contract, obj.State, target); + currentRole = obj.Sources.recordsForRole( ... + current, config.SourceRole); + currentPaths = obj.Sources.sourcePaths(currentRole); + [paths, filtering] = ... + labkit.app.internal.runtime.RuntimeContractBoundary.filterFilePaths( ... + config, paths, currentPaths); + if filtering.changed + obj.Recorder.log( ... + "info", "source.paths_filtered", ... + "Filtered unsupported source files.", ... + Category="runtime.source", Audience="user", ... + Attributes=struct( ... + "acceptedCount", filtering.acceptedCount, ... + "rejectedCount", filtering.rejectedCount)); + obj.Context.alert(filtering.message, ... + "Unsupported files filtered"); + end + sources = obj.Sources.reconcileRolePaths( ... + current, paths, config.SourceRole, ... + config.SourceIdPrefix, config.Required, ... + config.AllowDuplicatePaths); + visibleSources = obj.Sources.recordsForRole( ... + sources, config.SourceRole); + if filtering.changed + indices = 1:numel(visibleSources); + end + 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); + obj.Recorder.finish( ... + operation, "completed", "committed", []); + catch cause + obj.Recorder.finish( ... + operation, "failed", "rolledBack", cause); + rethrow(cause); + end +end diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/commitFilePanel.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/commitFilePanel.m new file mode 100644 index 000000000..562fb6e81 --- /dev/null +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/commitFilePanel.m @@ -0,0 +1,58 @@ +function commitFilePanel(obj, target, config, sources, indices, rebuildSession) +%COMMITFILEPANEL Rebuild and atomically publish file-panel state. + 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.runtime.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.runtime.RuntimeStatePath.write( ... + candidate, config.SelectionBind, selection); + else + selection = labkit.app.event.ListSelection(Indices=indices); + end + binding = ... + labkit.app.internal.runtime.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.runtime.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 diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/completeBackend.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/completeBackend.m new file mode 100644 index 000000000..88710398a --- /dev/null +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/completeBackend.m @@ -0,0 +1,47 @@ +function backend = completeBackend(obj, backend) +%COMPLETEBACKEND Compose missing callback capabilities from Runtime owners. + if nargin < 2 || isempty(backend) + backend = struct(); + end + builtins = struct( ... + "log", @(severity, eventName, message, category, audience, attributes, exception) ... + obj.log(severity, eventName, message, category, audience, attributes, exception), ... + "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.native.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 + backend = obj.wrapDialogOperations(backend); +end diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/execute.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/execute.m new file mode 100644 index 000000000..380ddc859 --- /dev/null +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/execute.m @@ -0,0 +1,62 @@ +function execute(obj, binding, payload) +%EXECUTE Run one callback transaction with presentation rollback. + previousState = obj.State; + previousPresentation = obj.Presentation; + obj.PendingDocumentMetadata = []; + operation = obj.Recorder.begin( ... + "runtime.callback", "callback." + binding.Signal, ... + "Dispatching callback.", Attributes=struct("runtimeAlias", binding.Id)); + try + if ~binding.AcceptsPayload + candidate = binding.UpdateState(previousState, obj.Context); + else + candidate = binding.UpdateState( ... + previousState, payload, obj.Context); + end + obj.Recorder.log( ... + "trace", "callback.state_updated", ... + "Callback state update completed.", ... + Category="runtime.callback", Audience="developer", ... + Attributes=struct("runtimeAlias", binding.Id)); + labkit.app.internal.runtime.RuntimeContractBoundary.validateState( ... + obj.Application, candidate); + obj.Recorder.log( ... + "trace", "callback.state_validated", ... + "Callback state validation completed.", ... + Category="runtime.callback", Audience="developer", ... + Attributes=struct("runtimeAlias", binding.Id)); + view = obj.present(candidate); + obj.Adapter.reconcile(previousPresentation, view); + obj.Recorder.log( ... + "trace", "callback.presentation_committed", ... + "Native presentation commit completed.", ... + Category="runtime.callback", Audience="developer", ... + Attributes=struct("runtimeAlias", binding.Id)); + 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", "committed", []); + catch cause + obj.State = previousState; + obj.Presentation = previousPresentation; + obj.PendingDocumentMetadata = []; + obj.Resources.clearScope("event"); + obj.Recorder.finish(operation, "failed", "rolledBack", cause); + obj.Recorder.log( ... + "trace", "callback.rollback_completed", ... + "Callback rollback cleanup completed.", ... + Category="runtime.callback", Audience="developer", ... + Attributes=struct("runtimeAlias", binding.Id)); + 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 diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/present.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/present.m new file mode 100644 index 000000000..c7e6b1e58 --- /dev/null +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/present.m @@ -0,0 +1,38 @@ +function view = present(obj, state) +%PRESENT Compose and validate the complete presentation snapshot. + operation = obj.Recorder.begin( ... + "runtime.presentation", "presentation.rendered", ... + "Preparing application presentation."); + try + view = labkit.app.internal.runtime.RuntimePresentation.fromState( ... + obj.Contract.PlatformPlan, state, ... + @(records, role) ... + obj.presentationSourcePaths(records, role), ... + obj.CurrentStatus); + obj.Recorder.log( ... + "trace", "presentation.runtime_prepared", ... + "Runtime presentation model prepared.", ... + Category="runtime.presentation", Audience="developer"); + if isempty(obj.Application.PresentWorkbench) + custom = labkit.app.view.Snapshot(); + else + custom = obj.Application.PresentWorkbench(state); + end + obj.Recorder.log( ... + "trace", "presentation.app_prepared", ... + "App presentation overlay prepared.", ... + Category="runtime.presentation", Audience="developer"); + view = view.overlayForRuntime(custom); + obj.Application.validateViewSnapshot(view); + obj.Recorder.log( ... + "trace", "presentation.validated", ... + "Combined presentation validated.", ... + Category="runtime.presentation", Audience="developer"); + obj.Recorder.finish( ... + operation, "completed", "notApplicable", []); + catch cause + obj.Recorder.finish( ... + operation, "failed", "notApplicable", cause); + rethrow(cause); + end +end diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/restoreProject.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/restoreProject.m new file mode 100644 index 000000000..305fb6542 --- /dev/null +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/restoreProject.m @@ -0,0 +1,43 @@ +function restoreProject(obj, filepath, asRecovery) +%RESTOREPROJECT Commit one restored project and presentation atomically. + if nargin < 3 + asRecovery = false; + end + obj.assertOpen(); + obj.assertProjectStore(); + previousState = obj.State; + previousPresentation = obj.Presentation; + operation = obj.Recorder.begin( ... + "runtime.project", "project.restored", ... + "Restoring project document."); + try + [candidate, metadata] = ... + obj.Documents.restore(filepath, asRecovery); + catch cause + obj.Recorder.finish( ... + operation, "failed", "notApplicable", cause); + rethrow(cause); + end + try + labkit.app.internal.runtime.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(); + obj.Recorder.finish( ... + operation, "completed", "committed", []); + catch cause + obj.State = previousState; + obj.Presentation = previousPresentation; + obj.Recorder.finish( ... + operation, "failed", "rolledBack", cause); + failure = MException( ... + "labkit:app:runtime:ProjectRestoreFailed", ... + "Project restore failed transactionally."); + failure = addCause(failure, cause); + throwAsCaller(failure); + end +end diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/wrapDialogOperations.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/wrapDialogOperations.m new file mode 100644 index 000000000..9de438afb --- /dev/null +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/wrapDialogOperations.m @@ -0,0 +1,49 @@ +function backend = wrapDialogOperations(obj, backend) +%WRAPDIALOGOPERATIONS Instrument available dialog capability callbacks. + if isfield(backend, "alert") + operation = backend.alert; + backend.alert = @(message, title) ... + obj.invokeDialogAlert(operation, message, title); + end + if isfield(backend, "choose") + operation = backend.choose; + backend.choose = @(prompt, choices, title, ... + defaultChoice, cancelChoice) ... + obj.invokeDialogChoice( ... + "dialog.option_chosen", "Showing option dialog.", ... + operation, prompt, choices, title, ... + defaultChoice, cancelChoice); + end + if isfield(backend, "chooseInputFile") + operation = backend.chooseInputFile; + backend.chooseInputFile = @(filters, startPath) ... + obj.invokeDialogChoice( ... + "dialog.input_file_chosen", ... + "Showing input-file dialog.", ... + operation, filters, startPath); + end + if isfield(backend, "chooseInputFolder") + operation = backend.chooseInputFolder; + backend.chooseInputFolder = @(startPath) ... + obj.invokeDialogChoice( ... + "dialog.input_folder_chosen", ... + "Showing input-folder dialog.", ... + operation, startPath); + end + if isfield(backend, "chooseOutputFile") + operation = backend.chooseOutputFile; + backend.chooseOutputFile = @(filters, startPath) ... + obj.invokeDialogChoice( ... + "dialog.output_file_chosen", ... + "Showing output-file dialog.", ... + operation, filters, startPath); + end + if isfield(backend, "chooseOutputFolder") + operation = backend.chooseOutputFolder; + backend.chooseOutputFolder = @(startPath) ... + obj.invokeDialogChoice( ... + "dialog.output_folder_chosen", ... + "Showing output-folder dialog.", ... + operation, startPath); + end +end diff --git a/+labkit/+app/+internal/CallbackContextFactory.m b/+labkit/+app/+internal/+runtime/CallbackContextFactory.m similarity index 100% rename from +labkit/+app/+internal/CallbackContextFactory.m rename to +labkit/+app/+internal/+runtime/CallbackContextFactory.m diff --git a/+labkit/+app/+internal/RuntimeContractBoundary.m b/+labkit/+app/+internal/+runtime/RuntimeContractBoundary.m similarity index 70% rename from +labkit/+app/+internal/RuntimeContractBoundary.m rename to +labkit/+app/+internal/+runtime/RuntimeContractBoundary.m index b222159b3..e9bc4d29d 100644 --- a/+labkit/+app/+internal/RuntimeContractBoundary.m +++ b/+labkit/+app/+internal/+runtime/RuntimeContractBoundary.m @@ -63,10 +63,46 @@ error("labkit:app:contract:UnknownReference", ... "fileList target has no source binding: %s.", target); end - current = labkit.app.internal.RuntimeStatePath.read( ... + current = labkit.app.internal.runtime.RuntimeStatePath.read( ... state, config.Bind); end + function [paths, result] = filterFilePaths( ... + config, paths, currentPaths) + result = struct("changed", false, "acceptedCount", 0, ... + "rejectedCount", 0, "message", ""); + if isempty(config.PathFilter) || isempty(paths) + return; + end + paths = normalizeFilePaths(paths); + currentPaths = normalizeFilePaths(currentPaths); + proposed = ~ismember(paths, currentPaths); + candidatePaths = paths(proposed); + if isempty(candidatePaths) + return; + end + accepted = config.PathFilter(candidatePaths); + if ~(islogical(accepted) && isrow(accepted) && ... + numel(accepted) == numel(candidatePaths)) + error("labkit:app:contract:InvalidValue", ... + "fileList PathFilter must return one logical value " + ... + "per newly proposed path."); + end + retained = ~proposed; + retained(proposed) = accepted; + paths = paths(retained); + rejectedCount = sum(~accepted); + result.changed = rejectedCount > 0; + if rejectedCount > 0 + acceptedCount = sum(accepted); + result.acceptedCount = acceptedCount; + result.rejectedCount = rejectedCount; + result.message = filterNotice( ... + acceptedCount, rejectedCount, ... + config.PathFilterDescription); + end + end + function adapter = createAdapter(application, contract, platform) if ~(ischar(platform) || ... (isstring(platform) && isscalar(platform))) @@ -75,7 +111,7 @@ end switch string(platform) case "headless" - adapter = labkit.app.internal.HeadlessPlatformAdapter(); + adapter = labkit.app.internal.native.HeadlessPlatformAdapter(); case "matlab" plan = contract.PlatformPlan; title = application.Title + " v" + ... @@ -84,7 +120,7 @@ if ~isempty(application.ProjectSchema) title = title + " *"; end - adapter = labkit.app.internal.MatlabPlatformAdapter( ... + adapter = labkit.app.internal.native.MatlabPlatformAdapter( ... plan, title); otherwise error("labkit:app:runtime:InvariantFailure", ... @@ -107,7 +143,7 @@ end function validateDispatch(contract, binding, payload) - if ~isa(binding, "labkit.app.internal.SignalBinding") || ... + if ~isa(binding, "labkit.app.internal.contract.SignalBinding") || ... ~contract.hasSignal(binding) error("labkit:app:contract:UnknownReference", ... "Runtime dispatch callback is undeclared."); @@ -158,3 +194,37 @@ function validateState(application, state) end end end + +function paths = normalizeFilePaths(paths) +if ischar(paths) || (isstring(paths) && isscalar(paths)) + paths = string(paths); +elseif isstring(paths) + paths = reshape(paths, 1, []); +elseif iscell(paths) + valid = cellfun(@(value) ischar(value) || ... + (isstring(value) && isscalar(value)), paths); + if ~all(valid, "all") + invalidFilePaths(); + end + paths = reshape(string(paths), 1, []); +else + invalidFilePaths(); +end +end + +function invalidFilePaths() +error("labkit:app:contract:InvalidValue", ... + "fileList paths must be text paths."); +end + +function message = filterNotice(acceptedCount, rejectedCount, description) +if acceptedCount == 0 + message = sprintf( ... + "No %s files matched. Filtered %d unsupported file(s).", ... + description, rejectedCount); +else + message = sprintf( ... + "Kept %d %s file(s) and filtered %d unsupported file(s).", ... + acceptedCount, description, rejectedCount); +end +end diff --git a/+labkit/+app/+internal/RuntimeFactory.m b/+labkit/+app/+internal/+runtime/RuntimeFactory.m similarity index 80% rename from +labkit/+app/+internal/RuntimeFactory.m rename to +labkit/+app/+internal/+runtime/RuntimeFactory.m index 85ccbbcbf..0ddf4f5ce 100644 --- a/+labkit/+app/+internal/RuntimeFactory.m +++ b/+labkit/+app/+internal/+runtime/RuntimeFactory.m @@ -14,7 +14,7 @@ journal = []; end journalRoot = parseJournalRoot(journal, varargin{:}); - runtime = labkit.app.internal.RuntimeFactory.create( ... + runtime = labkit.app.internal.runtime.RuntimeFactory.create( ... definition, initialProject, backend, ... "headless", journal, journalRoot); end @@ -31,7 +31,7 @@ journal = []; end journalRoot = parseJournalRoot(journal, varargin{:}); - runtime = labkit.app.internal.RuntimeFactory.create( ... + runtime = labkit.app.internal.runtime.RuntimeFactory.create( ... definition, initialProject, backend, ... "matlab", journal, journalRoot); end @@ -47,11 +47,11 @@ end journal = prepareJournal(definition, journal, journalRoot); try - projection = labkit.app.internal.SessionJournalProjection(journal); - stream = labkit.app.internal.SessionEventStream(definition, ... + projection = labkit.app.internal.diagnostics.SessionJournalProjection(journal); + stream = labkit.app.internal.diagnostics.SessionEventStream(definition, ... SessionId=journal.sessionId(), ProjectionHook=@projection.project, ... ProjectionHealthHook=@projection.drainHealth); - recorder = labkit.app.internal.SessionDiagnostics( ... + recorder = labkit.app.internal.diagnostics.SessionDiagnostics( ... definition, stream, projection, journal); catch cause try @@ -62,7 +62,7 @@ rethrow(cause); end try - runtime = labkit.app.internal.RuntimeKernel( ... + runtime = labkit.app.internal.runtime.RuntimeKernel( ... definition, definition.Compiled, initialProject, ... backend, platform, recorder); catch cause @@ -76,14 +76,14 @@ function journal = prepareJournal(definition, journal, journalRoot) if isempty(journal) if strlength(journalRoot) == 0 - journal = labkit.app.internal.SessionJournal(definition); + journal = labkit.app.internal.diagnostics.SessionJournal(definition); else - journal = labkit.app.internal.SessionJournal(definition, ... + journal = labkit.app.internal.diagnostics.SessionJournal(definition, ... RootFolder=journalRoot); end return; end -if ~isa(journal, "labkit.app.internal.SessionJournal") || ~isscalar(journal) +if ~isa(journal, "labkit.app.internal.diagnostics.SessionJournal") || ~isscalar(journal) error("labkit:app:runtime:InvariantFailure", ... "RuntimeFactory journal seam requires one SessionJournal."); end @@ -94,7 +94,7 @@ if isempty(varargin) return; end -options = labkit.app.internal.OptionParser.parse( ... +options = labkit.app.internal.contract.OptionParser.parse( ... "RuntimeFactory", "JournalRoot", varargin{:}); if ~isfield(options, "JournalRoot") return; diff --git a/+labkit/+app/+internal/RuntimePresentation.m b/+labkit/+app/+internal/+runtime/RuntimePresentation.m similarity index 90% rename from +labkit/+app/+internal/RuntimePresentation.m rename to +labkit/+app/+internal/+runtime/RuntimePresentation.m index 575474e88..4cb92683d 100644 --- a/+labkit/+app/+internal/RuntimePresentation.m +++ b/+labkit/+app/+internal/+runtime/RuntimePresentation.m @@ -19,10 +19,10 @@ view = view.enabled(node.Id, config.Enabled); case "field" value = ... - labkit.app.internal.RuntimePresentation.neutralValue( ... + labkit.app.internal.runtime.RuntimePresentation.neutralValue( ... config.Value, config.Kind, config.Choices); if strlength(config.Bind) > 0 - value = labkit.app.internal.RuntimeStatePath.read( ... + value = labkit.app.internal.runtime.RuntimeStatePath.read( ... state, config.Bind); end view = view.value(node.Id, value); @@ -43,7 +43,7 @@ value = limits; end if strlength(config.Bind) > 0 - value = labkit.app.internal.RuntimeStatePath.read( ... + value = labkit.app.internal.runtime.RuntimeStatePath.read( ... state, config.Bind); end view = view.value(node.Id, value); @@ -52,7 +52,7 @@ case "slider" value = config.Value; if strlength(config.Bind) > 0 - value = labkit.app.internal.RuntimeStatePath.read( ... + value = labkit.app.internal.runtime.RuntimeStatePath.read( ... state, config.Bind); end view = view.value(node.Id, value); @@ -64,7 +64,7 @@ paths = strings(0, 1); if strlength(config.Bind) > 0 sourceRecords = ... - labkit.app.internal.RuntimeStatePath.read( ... + labkit.app.internal.runtime.RuntimeStatePath.read( ... state, config.Bind); paths = sourcePathsForRole( ... sourceRecords, config.SourceRole); @@ -72,7 +72,7 @@ view = view.filePaths(node.Id, paths); if strlength(config.SelectionBind) > 0 selection = ... - labkit.app.internal.RuntimeStatePath.read( ... + labkit.app.internal.runtime.RuntimeStatePath.read( ... state, config.SelectionBind); view = view.listSelection(node.Id, selection); end diff --git a/+labkit/+app/+internal/RuntimeStatePath.m b/+labkit/+app/+internal/+runtime/RuntimeStatePath.m similarity index 91% rename from +labkit/+app/+internal/RuntimeStatePath.m rename to +labkit/+app/+internal/+runtime/RuntimeStatePath.m index a53287d6c..c8edb9734 100644 --- a/+labkit/+app/+internal/RuntimeStatePath.m +++ b/+labkit/+app/+internal/+runtime/RuntimeStatePath.m @@ -21,7 +21,7 @@ function state = write(state, path, value) parts = split(path, "."); - state = labkit.app.internal.RuntimeStatePath.assign( ... + state = labkit.app.internal.runtime.RuntimeStatePath.assign( ... state, parts, value, path); end end @@ -37,7 +37,7 @@ owner.(name) = value; return; end - owner.(name) = labkit.app.internal.RuntimeStatePath.assign( ... + owner.(name) = labkit.app.internal.runtime.RuntimeStatePath.assign( ... owner.(name), parts(2:end), value, path); end end diff --git a/+labkit/+app/+internal/PortableSourceStore.m b/+labkit/+app/+internal/+source/PortableSourceStore.m similarity index 99% rename from +labkit/+app/+internal/PortableSourceStore.m rename to +labkit/+app/+internal/+source/PortableSourceStore.m index 3e2847cec..81df5ada2 100644 --- a/+labkit/+app/+internal/PortableSourceStore.m +++ b/+labkit/+app/+internal/+source/PortableSourceStore.m @@ -5,7 +5,7 @@ % 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}) + methods (Access = {?labkit.app.internal.runtime.RuntimeKernel, ?labkit.app.internal.project.ProjectDocumentStore}) function obj = PortableSourceStore() end diff --git a/+labkit/+app/+internal/SyntheticInputGenerator.m b/+labkit/+app/+internal/+source/SyntheticInputGenerator.m similarity index 94% rename from +labkit/+app/+internal/SyntheticInputGenerator.m rename to +labkit/+app/+internal/+source/SyntheticInputGenerator.m index b9d41100c..ef0e480e3 100644 --- a/+labkit/+app/+internal/SyntheticInputGenerator.m +++ b/+labkit/+app/+internal/+source/SyntheticInputGenerator.m @@ -22,11 +22,11 @@ "BuildSyntheticSample must return one " + ... "labkit.app.synthetic.Pack value."); end - labkit.app.internal.SyntheticInputGenerator.validateProject( ... + labkit.app.internal.source.SyntheticInputGenerator.validateProject( ... definition, pack); - labkit.app.internal.SyntheticInputGenerator.verifyArtifacts( ... + labkit.app.internal.source.SyntheticInputGenerator.verifyArtifacts( ... context, pack); - labkit.app.internal.SyntheticInputGenerator.writeManifest( ... + labkit.app.internal.source.SyntheticInputGenerator.writeManifest( ... context, pack); end end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyEnabled.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyEnabled.m deleted file mode 100644 index 0bbf7fc88..000000000 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyEnabled.m +++ /dev/null @@ -1,21 +0,0 @@ -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); - label = labkit.app.internal.NativeAdapterValues.linkedLabel(component); - if ~isempty(label) - labkit.app.internal.NativeAdapterValues.setIfProperty(label, "Enable", value); - 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/installFilePanelCallbacks.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/installFilePanelCallbacks.m deleted file mode 100644 index 7062a07f2..000000000 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/installFilePanelCallbacks.m +++ /dev/null @@ -1,24 +0,0 @@ -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/AGENTS.md b/+labkit/+app/+internal/AGENTS.md new file mode 100644 index 000000000..6a218af9a --- /dev/null +++ b/+labkit/+app/+internal/AGENTS.md @@ -0,0 +1,70 @@ +# App SDK Internal Ownership + +`labkit.app.internal` is a private composition boundary, not a miscellaneous +helper namespace. The package root contains no MATLAB implementation files; +every type belongs to one named subsystem. + +The dependency direction is: + +```text +Definition / CallbackContext + |-- contract -> immutable layout, signal, and compiled plan + `-- runtime -> transaction ordering and callback capabilities + |-- diagnostics + |-- project / source / result / resource + |-- artifact + `-- native -> interaction + MATLAB handles +``` + +Contract, storage, artifact, source, result, resource, and journal owners do +not look up or invoke `RuntimeKernel`. The native adapter and Session Log +viewer are the two explicit UI callback edges: they may receive the Runtime at +construction and call its named boundary methods, but must not expose it, +redistribute it, or turn it into a general service bag. A narrower lifecycle +receives one callback from its caller rather than acquiring the caller. + +- Put immutable definition compilation under `+contract`; it must not acquire + runtime state, native handles, persistence, or diagnostics. +- Put transaction ordering, state-path mutation, callback construction, and + runtime factories under `+runtime`. +- Put concrete MATLAB window behavior under `+native` and keep the platform + adapter as the semantic reconciliation boundary, not the owner of every + window lifecycle. +- Keep `+launcher/dispatch.m` as entry routing only. Discovery, catalog + projection, documentation resolution, request parsing, metadata, and the + stateful launcher window remain separately named package functions. +- Put artifact naming and scratch-destination policy under `+artifact`. +- Put Runtime-level diagnostic viewing and export coordination under + `+diagnostics`; keep event, journal, and bundle primitives focused and move + them only when the move clarifies ownership independently of taxonomy. +- Keep project documents, portable sources, results, resources, and + interactions with their existing focused owners; when one subsystem needs + multiple new types, create one semantically named internal subpackage + instead of adding another root-level bucket. +- `RuntimeKernel` owns transaction order and cross-subsystem commit/rollback. + It delegates independent storage, export, diagnostics, naming, and native + lifecycle mechanics rather than implementing them inline. +- Keep `RuntimeKernel` as a class-folder coordinator. Complete callback, + presentation, bound-control, file-selection, backend-composition, dialog, + and project-restore workflows stay in their named class methods rather than + accumulating again in the class definition file. +- `MatlabPlatformAdapter` owns translation between semantic Snapshot + operations and native components. It delegates independent busy, startup, + close, acquisition, and utility workflows once they have state or lifecycle + of their own. +- Split a class-folder method when it is still part of the adapter's semantic + reconciliation. Extract a separate owner when a workflow has its own state, + timing, fallback, cleanup, or transaction. File length alone does not decide + ownership, but a growing file is a signal to make this check before adding + another inline workflow. +- Do not introduce `misc`, `common`, `utils`, `helpers`, `manager`, or + `service` buckets. A new internal type names the state or lifecycle it owns, + has one production caller direction, and is directly testable through that + owner. +- Do not add MATLAB files directly to this package root. Extend the narrowest + existing subsystem, or update the architecture guardrail together with a + justified new cohesive subsystem. + +Moving code is not itself an ownership improvement. Preserve transaction, +rollback, appearance, input, status, diagnostics, and close semantics while +extracting one complete responsibility. diff --git a/+labkit/+app/+internal/private/addOrInsertAnchor.m b/+labkit/+app/+internal/private/addOrInsertAnchor.m deleted file mode 100644 index 2d5e01c35..000000000 --- a/+labkit/+app/+internal/private/addOrInsertAnchor.m +++ /dev/null @@ -1,219 +0,0 @@ -% Private anchor-editor insertion helper. Expected caller: -% createAnchorEditor. Inputs are existing and candidate image-pixel -% anchor coordinates plus axes/image/style constraints; output is the updated -% N-by-2 anchor array. No graphics or app state are mutated. -function points = addOrInsertAnchor(points, newPoint, ax, imageSize, curveStyle, closed, maxPoints) -%ADDORINSERTANCHOR Apply anchor insertion policy for createAnchorCurveEditor. -% -% Expected caller: -% createAnchorEditor when users double-click/add anchors. -% -% Inputs: -% points - existing N-by-2 normalized anchor coordinates. -% newPoint - 1-by-2 candidate anchor coordinate. -% ax - axes handle used only for current view-dependent insertion thresholds. -% imageSize - image size vector used for curve clamping. -% curveStyle - "Curve" or "Straight lines". -% closed - logical; true inserts into the nearest closed visible segment. -% maxPoints - maximum anchor count; existing anchors are replaced when full. -% -% Output: -% points - updated N-by-2 anchor coordinates. -% -% Side effects: -% None. This helper does not mutate graphics or app state. - - n = size(points, 1); - if n < 2 - points(end+1, :) = newPoint; - return; - end - - if isfinite(maxPoints) && n >= maxPoints - idx = nearestPointIndex(points, newPoint); - points(idx, :) = newPoint; - return; - end - - if ~closed - points = addOrInsertOpenAnchor(points, newPoint, ax, imageSize, curveStyle); - return; - end - - [segmentIdx, ~] = nearestVisibleSegment(points, newPoint, imageSize, curveStyle, closed); - if isempty(segmentIdx) - points(end+1, :) = newPoint; - return; - end - - points = insertAnchorAfterSegment(points, newPoint, segmentIdx); -end - -function idx = nearestPointIndex(points, point) - [~, idx] = min(hypot(points(:, 1) - point(1), points(:, 2) - point(2))); -end - -function points = addOrInsertOpenAnchor(points, newPoint, ax, imageSize, curveStyle) - firstDistance = hypot(newPoint(1) - points(1, 1), newPoint(2) - points(1, 2)); - lastDistance = hypot(newPoint(1) - points(end, 1), newPoint(2) - points(end, 2)); - [segmentIdx, segmentDistance] = nearestVisibleSegment(points, newPoint, imageSize, curveStyle, false); - segmentThreshold = anchorInsertionThreshold(ax, 0.025, 6, 30); - correctionThreshold = anchorInsertionThreshold(ax, 0.045, 10, 55); - - if ~isempty(segmentIdx) && segmentDistance <= segmentThreshold && ... - segmentDistance <= min(firstDistance, lastDistance) * 0.45 - points = insertAnchorAfterSegment(points, newPoint, segmentIdx); - return; - end - - endpointThreshold = anchorInsertionThreshold(ax, 0.08, 12, 80); - if min(firstDistance, lastDistance) <= endpointThreshold - if firstDistance < lastDistance - endpointPoints = [newPoint; points]; - prepend = true; - else - endpointPoints = [points; newPoint]; - prepend = false; - end - if endpointExtensionIntersectsVisiblePath( ... - points, newPoint, imageSize, curveStyle, prepend) && ... - ~isempty(segmentIdx) && segmentDistance <= correctionThreshold - points = insertAnchorAfterSegment(points, newPoint, segmentIdx); - else - points = endpointPoints; - end - return; - end - - if ~isempty(segmentIdx) && segmentDistance <= segmentThreshold - points = insertAnchorAfterSegment(points, newPoint, segmentIdx); - return; - end - - points(end+1, :) = newPoint; -end - -function points = insertAnchorAfterSegment(points, newPoint, segmentIdx) - points = [points(1:segmentIdx, :); newPoint; points((segmentIdx + 1):end, :)]; -end - -function threshold = anchorInsertionThreshold(ax, fraction, minPixels, maxPixels) - xSpan = max(1, diff(ax.XLim)); - ySpan = max(1, diff(ax.YLim)); - threshold = min(maxPixels, max(minPixels, fraction * max(xSpan, ySpan))); -end - -function [segmentIdx, bestDistance] = nearestVisibleSegment(points, point, imageSize, curveStyle, closed) - segmentIdx = []; - bestDistance = inf; - n = size(points, 1); - if n < 2 - return; - end - - [curve, owners] = labkit.app.interaction.interpolateAnchorPath(points, imageSize, ... - "Style", string(curveStyle), "Closed", closed); - if size(curve, 1) < 2 - return; - end - - for k = 1:(size(curve, 1) - 1) - distance = pointSegmentDistance(point, curve(k, :), curve(k + 1, :)); - if distance < bestDistance - bestDistance = distance; - segmentIdx = owners(k); - end - end -end - -function tf = endpointExtensionIntersectsVisiblePath(points, newPoint, imageSize, curveStyle, prepend) - tf = false; - n = size(points, 1); - if n < 3 - return; - end - - if prepend - a = newPoint; - b = points(1, :); - adjacentOwner = 1; - else - a = points(end, :); - b = newPoint; - adjacentOwner = n - 1; - end - - [curve, owners] = labkit.app.interaction.interpolateAnchorPath(points, imageSize, ... - "Style", string(curveStyle), "Closed", false); - if size(curve, 1) < 2 - return; - end - - for k = 1:(size(curve, 1) - 1) - if owners(k) == adjacentOwner - continue; - end - if segmentsIntersect(a, b, curve(k, :), curve(k + 1, :)) - tf = true; - return; - end - end -end - -function distance = pointSegmentDistance(point, a, b) - ab = b - a; - denom = dot(ab, ab); - if denom <= eps - distance = hypot(point(1) - a(1), point(2) - a(2)); - return; - end - t = dot(point - a, ab) / denom; - t = min(max(t, 0), 1); - projection = a + t .* ab; - distance = hypot(point(1) - projection(1), point(2) - projection(2)); -end - -function tf = segmentsIntersect(a, b, c, d) - % Constant: 1e-9 coordinate units absorbs floating-point orientation - % noise without changing visible anchor-curve intersections. - intersectionTolerance = 1e-9; - if max(min(a(1), b(1)), min(c(1), d(1))) > ... - min(max(a(1), b(1)), max(c(1), d(1))) + intersectionTolerance || ... - max(min(a(2), b(2)), min(c(2), d(2))) > ... - min(max(a(2), b(2)), max(c(2), d(2))) + intersectionTolerance - tf = false; - return; - end - - o1 = orient2d(a, b, c); - o2 = orient2d(a, b, d); - o3 = orient2d(c, d, a); - o4 = orient2d(c, d, b); - - tf = (oppositeSigns(o1, o2, intersectionTolerance) && ... - oppositeSigns(o3, o4, intersectionTolerance)) || ... - (abs(o1) <= intersectionTolerance && ... - pointOnSegment(c, a, b, intersectionTolerance)) || ... - (abs(o2) <= intersectionTolerance && ... - pointOnSegment(d, a, b, intersectionTolerance)) || ... - (abs(o3) <= intersectionTolerance && ... - pointOnSegment(a, c, d, intersectionTolerance)) || ... - (abs(o4) <= intersectionTolerance && ... - pointOnSegment(b, c, d, intersectionTolerance)); -end - -function value = orient2d(a, b, c) - value = (b(1) - a(1)) * (c(2) - a(2)) - ... - (b(2) - a(2)) * (c(1) - a(1)); -end - -function tf = oppositeSigns(a, b, tol) - tf = (a > tol && b < -tol) || (a < -tol && b > tol); -end - -function tf = pointOnSegment(p, a, b, tol) - tf = p(1) >= min(a(1), b(1)) - tol && ... - p(1) <= max(a(1), b(1)) + tol && ... - p(2) >= min(a(2), b(2)) - tol && ... - p(2) <= max(a(2), b(2)) + tol; -end diff --git a/+labkit/+app/+layout/button.m b/+labkit/+app/+layout/button.m index 997a844c6..69cdec166 100644 --- a/+labkit/+app/+layout/button.m +++ b/+labkit/+app/+layout/button.m @@ -14,7 +14,11 @@ % state = onPressed(state,context). % % Options: -% BusyMessage - Status text while the action runs. Default: "". +% BusyMessage - Reader-facing stage text shown when the action remains +% active beyond the Runtime's brief busy-display delay. Empty text uses +% the button label. Runtime blocks new input immediately, freezes native +% controls when feedback becomes visible, and restores the committed +% view when the transaction ends. Default: "". % Enabled - Initial logical enabled state. Default: true. % Tooltip - Nonempty hover text explaining the action's scientific or % workflow effect. Default: label. @@ -30,6 +34,6 @@ % Tooltip="Compute the current analysis from the selected inputs."); % % See also labkit.app.layout.workbench, labkit.app.CallbackContext -node = labkit.app.internal.LayoutNode.button( ... +node = labkit.app.internal.contract.LayoutNode.button( ... id, label, onPressed, varargin{:}); end diff --git a/+labkit/+app/+layout/dataTable.m b/+labkit/+app/+layout/dataTable.m index 3fd7b6e06..161375877 100644 --- a/+labkit/+app/+layout/dataTable.m +++ b/+labkit/+app/+layout/dataTable.m @@ -34,5 +34,5 @@ % % See also labkit.app.event.TableCellEdit, % labkit.app.event.TableCellSelection -node = labkit.app.internal.LayoutNode.dataTable(id, varargin{:}); +node = labkit.app.internal.contract.LayoutNode.dataTable(id, varargin{:}); end diff --git a/+labkit/+app/+layout/field.m b/+labkit/+app/+layout/field.m index c11d7b19d..c608a96dc 100644 --- a/+labkit/+app/+layout/field.m +++ b/+labkit/+app/+layout/field.m @@ -36,5 +36,5 @@ % Bind="project.parameters.gain"); % % See also labkit.app.layout.rangeField, labkit.app.layout.slider -node = labkit.app.internal.LayoutNode.field(id, varargin{:}); +node = labkit.app.internal.contract.LayoutNode.field(id, varargin{:}); end diff --git a/+labkit/+app/+layout/fileList.m b/+labkit/+app/+layout/fileList.m index f850b5081..715a46f43 100644 --- a/+labkit/+app/+layout/fileList.m +++ b/+labkit/+app/+layout/fileList.m @@ -15,7 +15,10 @@ % 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". +% SelectionMode - "single" or "multiple" for both the native file chooser +% and list-row selection. Multi-file collections use "multiple"; a +% single semantic input normally combines "single" with MaxFiles=1. +% Default: "multiple". % MaxFiles - Positive scalar or Inf. Default: Inf. % FolderWarningThreshold - Positive scalar or Inf. Default: 500. % ShowStatus - Logical status visibility. Default: true. @@ -35,6 +38,14 @@ % 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. +% PathFilter - Optional callback accepted = callback(paths). paths is a row +% string array containing newly proposed files. accepted must be a +% logical row with one value per path. Rejected paths are omitted before +% portable source records are created, and the runtime reports aggregate +% retained/filtered counts without exposing filenames. Default: empty. +% PathFilterDescription - Reader-facing description of files accepted by +% PathFilter, used in the aggregate filtering notice. Default: +% "supported". % Bind - Project source-record field path. Default: "". % SelectionBind - ListSelection field path. Default: "". % OnSelectionChanged - Optional callback @@ -51,6 +62,8 @@ % % Errors: % Throws labkit:app:contract:* for invalid options, paths, or callbacks. +% In a native App, an unhandled file-panel validation or parsing exception +% is rolled back and shown in an alert. % % Typical Call: % node = labkit.app.layout.fileList("files", ... @@ -59,5 +72,5 @@ % % See also labkit.app.event.ListSelection, % labkit.app.CallbackContext, labkit.app.view.Snapshot -node = labkit.app.internal.LayoutNode.fileList(id, varargin{:}); +node = labkit.app.internal.contract.LayoutNode.fileList(id, varargin{:}); end diff --git a/+labkit/+app/+layout/group.m b/+labkit/+app/+layout/group.m index a80810b60..0fe240544 100644 --- a/+labkit/+app/+layout/group.m +++ b/+labkit/+app/+layout/group.m @@ -26,5 +26,5 @@ % 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{:}); +node = labkit.app.internal.contract.LayoutNode.group(id, children, varargin{:}); end diff --git a/+labkit/+app/+layout/plotArea.m b/+labkit/+app/+layout/plotArea.m index 9a8977b92..96a5757f4 100644 --- a/+labkit/+app/+layout/plotArea.m +++ b/+labkit/+app/+layout/plotArea.m @@ -43,7 +43,16 @@ % node = labkit.app.layout.plotArea("preview", @drawTrace, ... % AxisIds="trace"); % +% top = labkit.app.layout.plotArea("top", @drawTop, ... +% Layout="pair", AxisIds=["left" "right"]); +% bottom = labkit.app.layout.plotArea("bottom", @drawBottom, ... +% Layout="pair", AxisIds=["summary" "scale"], ... +% ColumnWidths={'1x', 90}); +% workspace = labkit.app.layout.workspace(Title="Four plots"); +% workspace = workspace.page("plots", "Plots", {top, bottom}); +% workspace = workspace.initialPage("plots"); +% % See also labkit.app.view.Snapshot, labkit.app.layout.workspace, % labkit.app.interaction.anchorPath -node = labkit.app.internal.LayoutNode.plotArea(id, renderer, varargin{:}); +node = labkit.app.internal.contract.LayoutNode.plotArea(id, renderer, varargin{:}); end diff --git a/+labkit/+app/+layout/rangeField.m b/+labkit/+app/+layout/rangeField.m index fd25e8652..a7f2a46dc 100644 --- a/+labkit/+app/+layout/rangeField.m +++ b/+labkit/+app/+layout/rangeField.m @@ -29,5 +29,5 @@ % 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{:}); +node = labkit.app.internal.contract.LayoutNode.rangeField(id, varargin{:}); end diff --git a/+labkit/+app/+layout/section.m b/+labkit/+app/+layout/section.m index c8aec476d..8c950a1b1 100644 --- a/+labkit/+app/+layout/section.m +++ b/+labkit/+app/+layout/section.m @@ -26,5 +26,5 @@ % 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{:}); +node = labkit.app.internal.contract.LayoutNode.section(id, title, children, varargin{:}); end diff --git a/+labkit/+app/+layout/slider.m b/+labkit/+app/+layout/slider.m index 08e12582a..d73601191 100644 --- a/+labkit/+app/+layout/slider.m +++ b/+labkit/+app/+layout/slider.m @@ -33,5 +33,5 @@ % 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{:}); +node = labkit.app.internal.contract.LayoutNode.slider(id, varargin{:}); end diff --git a/+labkit/+app/+layout/statusPanel.m b/+labkit/+app/+layout/statusPanel.m index e981f23bb..09ebf1015 100644 --- a/+labkit/+app/+layout/statusPanel.m +++ b/+labkit/+app/+layout/statusPanel.m @@ -28,5 +28,5 @@ % node = labkit.app.layout.statusPanel("status"); % % See also labkit.app.CallbackContext -node = labkit.app.internal.LayoutNode.statusPanel(id, varargin{:}); +node = labkit.app.internal.contract.LayoutNode.statusPanel(id, varargin{:}); end diff --git a/+labkit/+app/+layout/tab.m b/+labkit/+app/+layout/tab.m index 5fd98d3b4..eb4410a65 100644 --- a/+labkit/+app/+layout/tab.m +++ b/+labkit/+app/+layout/tab.m @@ -22,5 +22,5 @@ % 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); +node = labkit.app.internal.contract.LayoutNode.tab(id, title, children); end diff --git a/+labkit/+app/+layout/workbench.m b/+labkit/+app/+layout/workbench.m index db316e607..da5e0a46f 100644 --- a/+labkit/+app/+layout/workbench.m +++ b/+labkit/+app/+layout/workbench.m @@ -27,5 +27,5 @@ % % See also labkit.app.Definition, % labkit.app.layout.workspace -node = labkit.app.internal.LayoutNode.workbench(children, varargin{:}); +node = labkit.app.internal.contract.LayoutNode.workbench(children, varargin{:}); end diff --git a/+labkit/+app/+layout/workspace.m b/+labkit/+app/+layout/workspace.m index 48ef2fb93..143b15e9f 100644 --- a/+labkit/+app/+layout/workspace.m +++ b/+labkit/+app/+layout/workspace.m @@ -32,5 +32,5 @@ % % See also labkit.app.layout.workbench, % labkit.app.layout.plotArea -node = labkit.app.internal.LayoutNode.workspace(varargin{:}); +node = labkit.app.internal.contract.LayoutNode.workspace(varargin{:}); end diff --git a/+labkit/+app/+plot/fitAxesToGraphics.m b/+labkit/+app/+plot/fitAxesToGraphics.m index 9dcd8b208..f7d86f689 100644 --- a/+labkit/+app/+plot/fitAxesToGraphics.m +++ b/+labkit/+app/+plot/fitAxesToGraphics.m @@ -16,10 +16,10 @@ % 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. -% EqualDataUnits - Logical value. true expands one fitted dimension so one -% data unit occupies the same screen distance on X and Y without -% changing the axes position. For logarithmic dimensions, equality is -% evaluated in base-10 logarithmic space. Default: false. +% EqualDataUnits - Logical value. true applies an equal data aspect ratio so +% one data unit occupies the same screen distance on X and Y. The fitted +% limits are retained; MATLAB may use only part of the available plot +% box to preserve that ratio. Default: false. % % Outputs: % limits - Scalar struct with x and y fields. Each field contains the applied @@ -50,8 +50,18 @@ validateAxesHandle(ax, 'fit'); [handles, opts] = parseFitInputs(ax, varargin); - [xLim, yLim] = finitePlotLimits( ... - ax, handles, opts.Padding, logicalScalar(opts.EqualDataUnits)); + equalDataUnits = logicalScalar(opts.EqualDataUnits); + [xLim, yLim] = finitePlotLimits(ax, handles, opts.Padding); + applyLimits(ax, xLim, yLim); + if equalDataUnits + daspect(ax, [1 1 1]); + else + daspect(ax, 'auto'); + end + limits = struct('x', xLim, 'y', yLim); +end + +function applyLimits(ax, xLim, yLim) if isempty(xLim) xlim(ax, 'auto'); else @@ -62,7 +72,6 @@ else ylim(ax, yLim); end - limits = struct('x', xLim, 'y', yLim); end function [handles, opts] = parseFitInputs(ax, args) diff --git a/+labkit/+app/+plot/private/finitePlotLimits.m b/+labkit/+app/+plot/private/finitePlotLimits.m index f46dbaf16..e1115352c 100644 --- a/+labkit/+app/+plot/private/finitePlotLimits.m +++ b/+labkit/+app/+plot/private/finitePlotLimits.m @@ -1,59 +1,10 @@ % Private UI plot axes helper. Expected caller: fit. Inputs are an axes, % graphics handles, and fractional padding. Outputs are X/Y limits fitted to % finite plotted data. -function [xLim, yLim] = finitePlotLimits(ax, handles, padding, equalDataUnits) +function [xLim, yLim] = finitePlotLimits(ax, handles, padding) [x, y] = collectFiniteXY(handles); xLim = paddedDataLimits(x, ax.XScale, padding); yLim = paddedDataLimits(y, ax.YScale, padding); - if equalDataUnits && ~isempty(xLim) && ~isempty(yLim) - [xLim, yLim] = equalDataUnitLimits(ax, xLim, yLim); - end -end - -function [xLim, yLim] = equalDataUnitLimits(ax, xLim, yLim) - drawnow nocallbacks - position = getpixelposition(ax, true); - if numel(position) ~= 4 || position(3) <= 0 || position(4) <= 0 - return; - end - xLog = string(ax.XScale) == "log"; - yLog = string(ax.YScale) == "log"; - xWork = scaleLimits(xLim, xLog); - yWork = scaleLimits(yLim, yLog); - if isempty(xWork) || isempty(yWork) - return; - end - targetRatio = position(3) / position(4); - xSpan = diff(xWork); - ySpan = diff(yWork); - if xSpan / ySpan < targetRatio - xWork = expandAroundCenter(xWork, ySpan * targetRatio); - else - yWork = expandAroundCenter(yWork, xSpan / targetRatio); - end - xLim = unscaleLimits(xWork, xLog); - yLim = unscaleLimits(yWork, yLog); -end - -function limits = scaleLimits(limits, isLog) - if isLog - if any(limits <= 0) - limits = []; - return; - end - limits = log10(limits); - end -end - -function limits = unscaleLimits(limits, isLog) - if isLog - limits = 10 .^ limits; - end -end - -function limits = expandAroundCenter(limits, span) - center = mean(limits); - limits = center + [-0.5, 0.5] * span; end function [x, y] = collectFiniteXY(handles) diff --git a/+labkit/+app/+project/Schema.m b/+labkit/+app/+project/Schema.m index b23d630e5..979931134 100644 --- a/+labkit/+app/+project/Schema.m +++ b/+labkit/+app/+project/Schema.m @@ -79,7 +79,7 @@ varargin = {"Version", 1, "Create", @createProject, ... "Validate", @validateProject}; end - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.project.Schema", names, varargin{:}); for name = ["Version", "Create", "Validate"] if ~isfield(options, name) diff --git a/+labkit/+app/+result/File.m b/+labkit/+app/+result/File.m index eddc00ef8..2ed4b6c49 100644 --- a/+labkit/+app/+result/File.m +++ b/+labkit/+app/+result/File.m @@ -50,7 +50,7 @@ methods function obj = File(id, role, relativePath, varargin) names = ["MediaType", "Status", "Message", "Warnings"]; - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.result.File", names, varargin{:}); obj.Id = nonemptyText(id, "id"); obj.Role = nonemptyText(role, "role"); diff --git a/+labkit/+app/+result/Package.m b/+labkit/+app/+result/Package.m index 965f2251f..f1cffde8b 100644 --- a/+labkit/+app/+result/Package.m +++ b/+labkit/+app/+result/Package.m @@ -53,7 +53,7 @@ function obj = Package(varargin) names = ["Outputs", "Inputs", "Parameters", "Summary", ... "Warnings", "ManifestName"]; - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.result.Package", names, varargin{:}); for name = ["Outputs", "Inputs", "Parameters", "Summary"] if ~isfield(options, name) diff --git a/+labkit/+app/+synthetic/Artifact.m b/+labkit/+app/+synthetic/Artifact.m index e25be8877..89c224d82 100644 --- a/+labkit/+app/+synthetic/Artifact.m +++ b/+labkit/+app/+synthetic/Artifact.m @@ -45,7 +45,7 @@ methods function obj = Artifact(id, role, relativePath, varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.synthetic.Artifact", "Expectation", ... varargin{:}); obj.Id = nonemptyText(id, "Id"); diff --git a/+labkit/+app/+synthetic/Pack.m b/+labkit/+app/+synthetic/Pack.m index 2059b990e..60fc4a8e7 100644 --- a/+labkit/+app/+synthetic/Pack.m +++ b/+labkit/+app/+synthetic/Pack.m @@ -48,7 +48,7 @@ methods function obj = Pack(varargin) names = ["Scenario", "InitialProject", "Artifacts"]; - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.synthetic.Pack", names, varargin{:}); for name = names if ~isfield(options, name) diff --git a/+labkit/+app/+view/Snapshot.m b/+labkit/+app/+view/Snapshot.m index 543da2fea..68d915ffa 100644 --- a/+labkit/+app/+view/Snapshot.m +++ b/+labkit/+app/+view/Snapshot.m @@ -163,7 +163,7 @@ error("labkit:app:contract:InvalidValue", ... "View snapshot table data has an unsupported type."); end - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.view.Snapshot.tableData", ... ["Columns", "RowNames", "ColumnEditable"], varargin{:}); columns = textRow(optionValue( ... @@ -180,7 +180,7 @@ end function obj = renderPlot(obj, target, model, varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.view.Snapshot.renderPlot", ... "ViewRevision", varargin{:}); revision = optionValue(options, "ViewRevision", 0); @@ -198,7 +198,7 @@ end function obj = workspacePage(obj, target, varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.view.Snapshot.workspacePage", ... ["Enabled", "Status"], varargin{:}); enabled = true; @@ -287,14 +287,14 @@ end methods (Access = { ... - ?labkit.app.internal.CompiledDefinition, ... - ?labkit.app.internal.MatlabPlatformAdapter}) + ?labkit.app.internal.contract.CompiledDefinition, ... + ?labkit.app.internal.native.MatlabPlatformAdapter}) function operations = operationsForCompiler(obj) operations = obj.Operations; end end - methods (Access = ?labkit.app.internal.RuntimeKernel) + methods (Access = ?labkit.app.internal.runtime.RuntimeKernel) function result = overlayForRuntime(base, custom) if ~isa(custom, "labkit.app.view.Snapshot") error("labkit:app:contract:InvalidValue", ... @@ -328,7 +328,7 @@ end function obj = appendInteraction(obj, kind, interaction, value, varargin) -options = labkit.app.internal.OptionParser.parse( ... +options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.view.Snapshot." + kind, ... ["ImageSize", "Enabled"], varargin{:}); imageSize = optionValue(options, "ImageSize", []); diff --git a/+labkit/+app/CallbackContext.m b/+labkit/+app/CallbackContext.m index f806c8563..0544b4322 100644 --- a/+labkit/+app/CallbackContext.m +++ b/+labkit/+app/CallbackContext.m @@ -35,7 +35,9 @@ % eventName - Stable semantic event identifier. % Category - Semantic App capability category. Default: "workflow". % Audience - "user" or "developer"; default: "user". - % Attributes - Scalar privacy-safe structured details. Default: struct(). + % Attributes - Scalar structured diagnostic details. The Session Log, + % persistent journal, and diagnostic export retain these complete + % values. Diagnostic bundles are sensitive. Default: struct(). % Exception - Scalar MException associated with the event. Default: []. % id - Stable semantic diagnostic or resource identifier. % count - Nonnegative integer diagnostic count. @@ -90,7 +92,7 @@ Backend (1, 1) struct end - methods (Access = ?labkit.app.internal.CallbackContextFactory) + methods (Access = ?labkit.app.internal.runtime.CallbackContextFactory) function obj = CallbackContext(backend) if ~isstruct(backend) || ~isscalar(backend) error("labkit:app:runtime:InvariantFailure", ... @@ -112,10 +114,10 @@ methods function log(obj, severity, eventName, message, varargin) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.CallbackContext.log", ... ["Category", "Audience", "Attributes", "Exception"], varargin{:}); - values = labkit.app.internal.SessionEventValidator.logInputs( ... + values = labkit.app.internal.diagnostics.SessionEventValidator.logInputs( ... severity, eventName, message, ... optionValue(options, "Category", "workflow"), ... optionValue(options, "Audience", "user"), ... @@ -139,7 +141,7 @@ function alert(obj, message, title) error("labkit:app:contract:InvalidValue", ... "CallbackContext choices must be nonempty and unique."); end - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.CallbackContext.chooseOption", ... ["Title", "DefaultChoice", "CancelChoice"], varargin{:}); title = scalarText(optionValue( ... diff --git a/+labkit/+app/Definition.m b/+labkit/+app/Definition.m index 552911863..87c18a57a 100644 --- a/+labkit/+app/Definition.m +++ b/+labkit/+app/Definition.m @@ -98,8 +98,8 @@ end properties (SetAccess = immutable, GetAccess = { ... - ?labkit.app.internal.RuntimeFactory, ... - ?labkit.app.internal.DefinitionInspector}) + ?labkit.app.internal.runtime.RuntimeFactory, ... + ?labkit.app.internal.contract.DefinitionInspector}) Compiled end @@ -110,7 +110,7 @@ "AppVersion", "Updated", "Requirements", "Workbench", ... "ProjectSchema", "CreateSession", "PresentWorkbench", ... "OnStart", "BuildSyntheticSample"]; - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.Definition", names, varargin{:}); required = [ ... "Entrypoint", "AppId", "Title", "Family", "AppVersion", ... @@ -146,7 +146,7 @@ obj.OnStart = startCallback; obj.BuildSyntheticSample = optionalFixedCallback( ... options, "BuildSyntheticSample", 1, 1); - obj.Compiled = labkit.app.internal.CompiledDefinition( ... + obj.Compiled = labkit.app.internal.contract.CompiledDefinition( ... options.Workbench, startCallback); end @@ -161,7 +161,7 @@ ~(isscalar(varargin) && ... (ischar(varargin{1}) || ... (isstring(varargin{1}) && isscalar(varargin{1})))) - options = labkit.app.internal.OptionParser.parse( ... + options = labkit.app.internal.contract.OptionParser.parse( ... "labkit.app.Definition.launch", ... "InitialProject", ... varargin{:}); @@ -209,7 +209,7 @@ error("labkit:app:contract:InvalidValue", ... "Definition launch returns at most one figure."); end - runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + runtime = labkit.app.internal.runtime.RuntimeFactory.createMatlab( ... obj, initialProject, struct()); runtime.showFigure(); figure = runtime.figureHandle(); diff --git a/+labkit/+app/version.m b/+labkit/+app/version.m index f79f41ac3..3e0d1b954 100644 --- a/+labkit/+app/version.m +++ b/+labkit/+app/version.m @@ -30,6 +30,6 @@ % labkit.app.Definition info = labkit.contract.versionInfo( ... - "app", "2.1.0", ">=2 <3", "stable", ... + "app", "2.2.0", ">=2 <3", "stable", ... "Explicit LabKit App SDK contract for tracked production Apps."); end diff --git a/.agents/skills/labkit-pr-preparer/SKILL.md b/.agents/skills/labkit-pr-preparer/SKILL.md new file mode 100644 index 000000000..1f78f43ea --- /dev/null +++ b/.agents/skills/labkit-pr-preparer/SKILL.md @@ -0,0 +1,118 @@ +--- +name: labkit-pr-preparer +description: "Prepare LabKit develop for a squash PR into main by auditing the complete base-to-head diff, consolidating component versions and structured history, running the one final local gate, and assembling the repository PR record. Use only when the user asks to prepare, open, update, review, or make merge-ready a develop-to-main PR. Do not use during ordinary branch iteration." +--- + +# LabKit PR Preparer + +Treat `origin/main..develop` as one proposed product change. Intermediate +commits, temporary versions, and checkpoint history files are working state; +the merge-ready tree must describe one coherent squash result. + +## Read + +Read `AGENTS.md`, the nearest changed-component rules, +`docs/development/maintain-and-release/release.md`, +`docs/development/maintain-and-release/testing.md`, +`docs/history/record-format.md`, and `.github/pull_request_template.md`. +Use `labkit-documentation-maintainer` when rewriting component history and +`labkit-test-planner` for the final local gate. + +## Establish the PR boundary + +1. Fetch `origin` with host network permission. +2. Require the canonical `develop` branch and a clean understood worktree. +3. Record `origin/main`, `develop`, `origin/develop`, the complete + `origin/main...develop` diff, and the intermediate commit list. +4. Stop if `develop` was not created from the current main delivery stream, + an existing develop-to-main PR already freezes a different head, or + unrelated local work cannot be separated safely. + +Do not merge `main` into `develop`, create a sync commit, force-push, or rewrite +Git commits without explicit approval. PR preparation rewrites the proposed +tree and authored component history; GitHub performs the final squash. + +Run the bundled inventory before editing versions or history. It resolves +versions from both current and legacy metadata owners, maps every net +transition to changed history, and reports policy errors without reconstructing +the inventory by hand: + +```bash +python3 .agents/skills/labkit-pr-preparer/scripts/audit_pr.py \ + --base origin/main --head develop +``` + +## Consolidate versions and history + +Build one inventory of every changed App definition, facade `version.m`, +launcher metadata file, manual, and structured history record. + +- Derive every final component version directly from `origin/main`, never from + an intermediate develop version. Choose exactly one direct patch, minor, or + major step for the net behavior. +- Delete intermediate transitions such as `2.1.0 -> 2.2.0` followed by + `2.2.0 -> 2.3.0`. The merge-ready history contains only the chosen direct + main-baseline-to-PR-final transition. +- Require one changed structured history record per versioned component. A + cross-component decision uses one record listing all affected components. + Merge related checkpoint records; remove tiny mechanical records and + duplicate unversioned component references that fragment the same PR story. +- Rewrite titles, IDs, filenames, scopes, rationale, compatibility, evidence, + and follow-up as the net delivered behavior. Preserve published mainline + history; freely consolidate records introduced only on `develop` while + keeping global sequence metadata valid. +- Update each owning manual once for the net public behavior. Do not repeat + framework defaults in App manuals or preserve prose that merely narrates + intermediate commits. + +Inspect the result manually even when policy automation passes. Automation can +prove exact transitions and record presence; it cannot decide whether two +records tell one logical product story. + +## Run merge-readiness checks + +Run the integration policy against the actual proposed refs before broad +MATLAB validation: + +```bash +python3 .github/scripts/check_integration_policy.py \ + --event-name pull_request \ + --base-ref main \ + --head-ref develop \ + --head-repository Pluze/LabKit-MATLAB-Workbench \ + --repository Pluze/LabKit-MATLAB-Workbench \ + --base-sha origin/main \ + --head-sha develop +``` + +Then: + +1. Run authored-link maintenance after moved Markdown and review rewrites. +2. Run `changedFast` exactly once for the final merge-ready tree. +3. Inspect the complete diff, data hygiene, component versions, structured + history, manuals, test evidence, and remaining native/manual checks. +4. Fill the repository PR template with net behavior and exact evidence. +5. Push the final develop checkpoint, open or update the PR, and freeze + `develop` until the PR is merged or closed. +6. Unless a prerequisite failure prevents useful downstream execution, let an + active platform matrix finish and collect every failed identity before the + next push. Read only those failing logs, repair the narrowest responsible + boundaries, rerun exact evidence, and batch the verified repairs into one + push. Do not repeat `changedFast` after every repair. + +Do not declare merge readiness when the policy audit, final local gate, +required PR CI, review, or conversation resolution is incomplete. + +After merge, use resolved SHAs rather than branch-name assumptions. Verify the +PR is merged, the exact main-push policy gate passed, no open PR depends on +`develop`, and `develop` contains no unmerged commit. Only then delete and +recreate local and remote `develop` at `origin/main`, restore its protection, +and verify both refs are identical. Never delete a branch based only on a +successful merge command response. + +## Handoff + +Report the base and head SHAs, consolidated version transitions and history +records, final local evidence, PR/CI state, manual checks, data-hygiene result, +develop freeze state, and any blocker. Distinguish completed automated proof +from developer-led interactive validation. diff --git a/.agents/skills/labkit-pr-preparer/agents/openai.yaml b/.agents/skills/labkit-pr-preparer/agents/openai.yaml new file mode 100644 index 000000000..7bf8464fb --- /dev/null +++ b/.agents/skills/labkit-pr-preparer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "LabKit PR Preparer" + short_description: "Audit and consolidate a LabKit develop-to-main PR" + default_prompt: "Use $labkit-pr-preparer to prepare develop for a squash PR into main." diff --git a/.agents/skills/labkit-pr-preparer/manifest.yaml b/.agents/skills/labkit-pr-preparer/manifest.yaml new file mode 100644 index 000000000..88d38d7dc --- /dev/null +++ b/.agents/skills/labkit-pr-preparer/manifest.yaml @@ -0,0 +1 @@ +{"schema_version":1,"name":"labkit-pr-preparer","scope":"labkit-repository","dependencies":["labkit-documentation-maintainer","labkit-test-planner"]} diff --git a/.agents/skills/labkit-pr-preparer/scripts/audit_pr.py b/.agents/skills/labkit-pr-preparer/scripts/audit_pr.py new file mode 100644 index 000000000..a4105aa7f --- /dev/null +++ b/.agents/skills/labkit-pr-preparer/scripts/audit_pr.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Inventory one LabKit squash-PR boundary from repository-owned sources.""" + +from __future__ import annotations + +import argparse +import importlib.util +import pathlib +import re +import subprocess +import sys + + +def command(*args: str) -> str: + result = subprocess.run( + args, check=True, text=True, stdout=subprocess.PIPE + ) + return result.stdout.strip() + + +def load_policy(root: pathlib.Path): + path = root / ".github" / "scripts" / "check_integration_policy.py" + spec = importlib.util.spec_from_file_location("labkit_integration_policy", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load integration policy from {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def git_text(revision: str, path: str) -> str | None: + result = subprocess.run( + ["git", "show", f"{revision}:{path}"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + return result.stdout if result.returncode == 0 else None + + +def metadata(source: str | None, name: str) -> str: + if source is None: + return "" + match = re.search(rf"^{name}:\s*(.+)$", source, re.MULTILINE) + return match.group(1).strip() if match else "" + + +def owning_manual(root: pathlib.Path, component: str, owner: str) -> str | None: + if component == "labkit.app": + return "docs/framework/README.md" + if component == "labkit_launcher": + return "docs/apps/labkit-core/launcher/README.md" + parts = pathlib.PurePosixPath(owner).parts + if len(parts) < 3 or parts[0] != "apps": + return None + slug = parts[2].replace("_", "-") + matches = sorted((root / "docs" / "apps").glob(f"*/{slug}/README.md")) + if len(matches) != 1: + return None + return matches[0].relative_to(root).as_posix() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base", default="origin/main") + parser.add_argument("--head", default="develop") + args = parser.parse_args() + + root = pathlib.Path(command("git", "rev-parse", "--show-toplevel")) + policy = load_policy(root) + base_sha = command("git", "rev-parse", args.base) + head_sha = command("git", "rev-parse", args.head) + paths = policy.changed_paths(base_sha, head_sha) + commits = command("git", "rev-list", "--count", f"{base_sha}..{head_sha}") + + owners = { + owner + for path in paths + if (owner := policy.metadata_path_for_source(path)) is not None + } + transitions = [] + for owner in sorted(owners): + before = policy.version_for_owner( + owner, lambda path: git_text(base_sha, path) + ) + after = policy.version_for_owner( + owner, lambda path: git_text(head_sha, path) + ) + if before and after and before != after: + transitions.append((after[0], owner, before[1], after[1])) + + histories = [] + for path in paths: + if not path.startswith("docs/history/records/") or not path.endswith(".md"): + continue + source = git_text(head_sha, path) + histories.append( + ( + metadata(source, "sequence"), + metadata(source, "id"), + path, + policy.parse_history_components(source), + ) + ) + histories.sort(key=lambda item: int(item[0]) if item[0].isdigit() else 10**9) + + print("# PR boundary") + print(f"- Base: `{base_sha}` ({args.base})") + print(f"- Head: `{head_sha}` ({args.head})") + print(f"- Commits: {commits}") + print(f"- Changed paths: {len(paths)}") + print("\n# Version transitions") + if not transitions: + print("- None") + for component, owner, before, after in transitions: + records = sorted( + path + for _, _, path, entries in histories + if any( + entry == (component, before, after) + for entry in entries + ) + ) + record = ", ".join(f"`{path}`" for path in records) or "missing" + manual = owning_manual(root, component, owner) + manual_status = ( + f"`{manual}` ({'changed' if manual in paths else 'UNCHANGED'})" + if manual else "not resolved" + ) + print( + f"- `{component}`: `{before} -> {after}` via `{owner}`; " + f"history: {record}; manual: {manual_status}" + ) + + print("\n# Changed history records") + if not histories: + print("- None") + for sequence, change_id, path, entries in histories: + components = ", ".join( + f"{component} ({before} -> {after})" + if before else component + for component, before, after in entries + ) or "no components" + print( + f"- sequence {sequence or '?'} `{change_id or '?'}`: `{path}`; " + f"{components}" + ) + + errors = policy.validate_versions( + paths, + lambda path: git_text(base_sha, path), + lambda path: git_text(head_sha, path), + ) + print("\n# Integration policy") + if errors: + for error in errors: + print(f"- ERROR: {error}") + return 1 + print("- Passed") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except subprocess.CalledProcessError as cause: + print(f"audit_pr.py: command failed: {cause}", file=sys.stderr) + raise SystemExit(2) from cause diff --git a/.agents/skills/labkit-test-planner/SKILL.md b/.agents/skills/labkit-test-planner/SKILL.md index 92f97b5ef..2fb1bc6e5 100644 --- a/.agents/skills/labkit-test-planner/SKILL.md +++ b/.agents/skills/labkit-test-planner/SKILL.md @@ -35,6 +35,21 @@ For focused MATLAB execution, add `tests` to the path and call changed `projectSpec.m` must explain to nonempty App-owned `persistence` evidence even when an end-to-end save/restore workflow also passes. +If `explain` shows that a framework source shares an intentionally broad owner +and `labkittest.run(File=...)` would expand a narrow iteration into that whole +owner, report the selected identity count before executing. For a user-requested +narrow iteration, run only the already identified owning specification files +with `scripts/runFocusedSpecs.m`; this helper establishes the repository and +test paths and rejects paths outside `tests/specs`. It is an iteration tool, +not a substitute for missing catalog evidence, `changedFast`, or CI. + +```matlab +addpath("/absolute/repo/.agents/skills/labkit-test-planner/scripts") +runFocusedSpecs([ ... + "tests/specs/labkit/app/SessionLogProjectionSpec.m" + "tests/specs/labkit/app/SessionDiagnosticBundleSpec.m"]); +``` + ## Choose Evidence Use the smallest behavior that proves the change: @@ -79,3 +94,12 @@ Report the exact owner/contract or profile command, selected identity count, pass/fail result, artifact folder, GUI/manual boundary, and why any broader gate is intentionally deferred. For final integration, report `changedFast` and the CI state for the exact pushed commit. + +## Repair CI Failures + +Read only the failed check and copy its exact test identity. Reproduce the +smallest method, owning specification file, or owner/contract; repair that +source boundary; rerun the same focused evidence; then push and let CI restore +the full platform claim. Do not rerun `changedFast` or a full local profile for +each CI repair. Re-plan only when the repair intentionally changes additional +behavior or ownership. diff --git a/.agents/skills/labkit-test-planner/scripts/runFocusedSpecs.m b/.agents/skills/labkit-test-planner/scripts/runFocusedSpecs.m new file mode 100644 index 000000000..32d74668e --- /dev/null +++ b/.agents/skills/labkit-test-planner/scripts/runFocusedSpecs.m @@ -0,0 +1,132 @@ +function results = runFocusedSpecs(specFiles) +%RUNFOCUSEDSPECS Run explicitly selected LabKit specification files. +% This agent-only helper owns repository path setup for narrow iteration. It +% accepts only existing MATLAB specifications beneath tests/specs and fails +% the MATLAB process when any selected identity fails or is incomplete. + + if ischar(specFiles) + specFiles = string(specFiles); + elseif iscell(specFiles) + specFiles = string(specFiles); + end + if ~(isstring(specFiles) && ~isempty(specFiles) && ... + all(~ismissing(specFiles)) && ... + all(strlength(strip(specFiles)) > 0)) + error("labkit:test:InvalidFocusedSpecs", ... + "Focused specification files must be nonempty text."); + end + specFiles = specFiles(:); + repoRoot = repositoryRoot(); + specsRoot = string(fullfile(repoRoot, "tests", "specs")); + addpath(char(repoRoot), "-begin"); + addpath(char(fullfile(repoRoot, "tests")), "-begin"); + + selected = cell(numel(specFiles), 1); + selectedPaths = strings(numel(specFiles), 1); + for index = 1:numel(specFiles) + filepath = validatedSpecPath( ... + repoRoot, specsRoot, specFiles(index)); + selectedPaths(index) = filepath; + selected{index} = ... + matlab.unittest.TestSuite.fromFile(char(filepath)); + end + suite = [selected{:}]; + sourcePathCleanup = configureSourcePaths(repoRoot, specsRoot, selectedPaths); + environmentCleanup = configureEnvironment(selectedPaths); + fprintf("LabKit focused specifications: %d identities from %d file(s).\n", ... + numel(suite), numel(specFiles)); + results = run(suite); + disp(table(results)); + assertSuccess(results); + clear environmentCleanup sourcePathCleanup +end + +function cleanup = configureSourcePaths(repoRoot, specsRoot, paths) +% App specifications need their independently launchable App roots on path. +% Add every represented App rather than assuming all selected specs share the +% first App owner. +appSpecsRoot = string(fullfile(specsRoot, "apps")) + filesep; +sourceRoots = strings(0, 1); +for filepath = paths.' + if ~startsWith(filepath, appSpecsRoot) + continue; + end + relative = extractAfter(filepath, strlength(appSpecsRoot)); + parts = split(relative, filesep); + if numel(parts) < 3 || parts(1) == "conformance" + continue; + end + sourceRoot = string(fullfile(repoRoot, "apps", parts(1), parts(2))); + if isfolder(sourceRoot) + sourceRoots(end + 1, 1) = sourceRoot; + end +end +sourceRoots = unique(sourceRoots, "stable"); +existing = string(strsplit(path, pathsep)); +added = strings(0, 1); +for sourceRoot = sourceRoots.' + if ~any(existing == sourceRoot) + addpath(char(sourceRoot), "-begin"); + added(end + 1, 1) = sourceRoot; + end +end +cleanup = onCleanup(@() removeSourcePaths(added)); +end + +function removeSourcePaths(paths) +for sourceRoot = paths.' + if any(string(strsplit(path, pathsep)) == sourceRoot) + rmpath(char(sourceRoot)); + end +end +end + +function cleanup = configureEnvironment(paths) +hasHiddenGui = false; +for path = paths.' + source = string(fileread(path)); + if contains(source, "Env:path-isolated") + error("labkit:test:InvalidFocusedSpecs", ... + "Path-isolated specifications must run through labkittest.run."); + end + hasHiddenGui = hasHiddenGui || contains(source, "Env:hidden-gui"); +end +previous = getenv("LABKIT_GUI_TEST_MODE"); +cleanup = onCleanup(@() setenv("LABKIT_GUI_TEST_MODE", previous)); +if hasHiddenGui + setenv("LABKIT_GUI_TEST_MODE", "hidden"); +end +end + +function root = repositoryRoot() +root = string(fileparts(mfilename("fullpath"))); +for index = 1:4 + root = string(fileparts(root)); +end +end + +function filepath = validatedSpecPath(repoRoot, specsRoot, value) +value = strip(string(value)); +if contains(replace(value, "\\", "/"), "../") || ... + endsWith(replace(value, "\\", "/"), "/..") + error("labkit:test:InvalidFocusedSpecs", ... + "Focused specification paths cannot traverse parent folders."); +end +if isAbsolutePath(value) + filepath = value; +else + filepath = fullfile(repoRoot, value); +end +filepath = string(filepath); +prefix = specsRoot + filesep; +if ~(startsWith(filepath, prefix) && endsWith(filepath, ".m") && ... + isfile(filepath)) + error("labkit:test:InvalidFocusedSpecs", ... + "Focused specification must be an existing .m file under tests/specs."); +end +end + +function tf = isAbsolutePath(value) +tf = startsWith(value, filesep) || ... + ~isempty(regexp(char(value), '^[A-Za-z]:[\\/]', 'once')); +end diff --git a/.github/scripts/check_integration_policy.py b/.github/scripts/check_integration_policy.py index b0389cdd1..a02d3a4a1 100644 --- a/.github/scripts/check_integration_policy.py +++ b/.github/scripts/check_integration_policy.py @@ -24,7 +24,14 @@ r'"version"\s*,\s*"(\d+\.\d+\.\d+)"', re.DOTALL, ) -LAUNCHER_METADATA = "+labkit/+app/+internal/+launcher/dispatch.m" +LAUNCHER_METADATA = "+labkit/+app/+internal/+launcher/launcherVersion.m" +LEGACY_LAUNCHER_METADATA = "+labkit/+app/+internal/+launcher/dispatch.m" +LAUNCHER_METADATA_PATHS = (LAUNCHER_METADATA, LEGACY_LAUNCHER_METADATA) +HISTORY_COMPONENT = re.compile( + r"^component:\s*`([^`]+)`" + r"(?:\s*\|\s*`([^`]+)\s*->\s*([^`]+)`)?\s*$", + re.MULTILINE, +) def command(*arguments: str, allow_missing: bool = False) -> str | None: @@ -64,7 +71,7 @@ def parse_version(path: str, source: str | None) -> tuple[str, str] | None: match = FACADE_VERSION.search(source) if match: return f"labkit.{match.group(1)}", match.group(2) - if path == LAUNCHER_METADATA: + if path in LAUNCHER_METADATA_PATHS: match = LAUNCHER_VERSION.search(source) if match: return "labkit_launcher", match.group(1) @@ -101,6 +108,33 @@ def metadata_path_for_source(path: str) -> str | None: return None +def version_for_owner( + path: str, read: Callable[[str], str | None] +) -> tuple[str, str] | None: + if path != LAUNCHER_METADATA: + return parse_version(path, read(path)) + for candidate in LAUNCHER_METADATA_PATHS: + parsed = parse_version(candidate, read(candidate)) + if parsed is not None: + return parsed + return None + + +def parse_history_components( + source: str | None, +) -> list[tuple[str, str | None, str | None]]: + if source is None: + return [] + return [ + ( + component.strip(), + before.strip() if before else None, + after.strip() if after else None, + ) + for component, before, after in HISTORY_COMPONENT.findall(source) + ] + + def validate_branch( event_name: str, base_ref: str, @@ -140,8 +174,8 @@ def validate_versions( transitions: list[tuple[str, str, str]] = [] for path in sorted(metadata): - before = parse_version(path, read_base(path)) - after = parse_version(path, read_head(path)) + before = version_for_owner(path, read_base) + after = version_for_owner(path, read_head) if before is None or after is None: continue component_before, version_before = before @@ -163,21 +197,60 @@ def validate_versions( ) transitions.append((component_after, version_before, version_after)) - history = "\n".join( - read_head(path) or "" + history_records = { + path: parse_history_components(read_head(path)) for path in paths if path.startswith("docs/history/records/") and path.endswith(".md") - ) + } + net_transitions = { + component: (before, after) + for component, before, after in transitions + } for component, before, after in transitions: - expected = re.compile( - rf"component:\s*`{re.escape(component)}`\s*\|\s*" - rf"`{re.escape(before)}\s*->\s*{re.escape(after)}`" - ) - if not expected.search(history): + occurrences = [ + (path, recorded_before, recorded_after) + for path, records in history_records.items() + for recorded_component, recorded_before, recorded_after in records + if recorded_component == component + ] + record_paths = sorted({path for path, _, _ in occurrences}) + if len(record_paths) > 1: + errors.append( + f"{component}: changed history is split across " + f"{', '.join(record_paths)}; consolidate the component's " + "net PR history into one record." + ) + exact = [ + item for item in occurrences + if item[1:] == (before, after) + ] + if not exact: errors.append( f"{component}: history must record `{before} -> {after}` " "in this change." ) + elif len(exact) > 1: + errors.append( + f"{component}: history records `{before} -> {after}` more " + "than once; keep one net transition." + ) + for path, records in history_records.items(): + for component, before, after in records: + if before is None: + continue + expected = net_transitions.get(component) + if expected is None: + errors.append( + f"{path}: history records `{component}` as " + f"`{before} -> {after}`, but the component has no net " + "version change from the PR base." + ) + elif expected != (before, after): + errors.append( + f"{path}: history records `{component}` as " + f"`{before} -> {after}`, but the net PR transition is " + f"`{expected[0]} -> {expected[1]}`." + ) return errors diff --git a/.github/scripts/test_check_integration_policy.py b/.github/scripts/test_check_integration_policy.py index 1adac525e..0c5115863 100644 --- a/.github/scripts/test_check_integration_policy.py +++ b/.github/scripts/test_check_integration_policy.py @@ -109,6 +109,73 @@ def test_facade_double_jump_is_rejected(self): ], ) + def test_history_rejects_intermediate_and_split_component_records(self): + version_path = "+labkit/+app/version.m" + first_history = "docs/history/records/2026/08/LK-first.md" + second_history = "docs/history/records/2026/08/LK-second.md" + before = 'labkit.contract.versionInfo("app", "2.1.0", ">=2 <3")' + after = before.replace("2.1.0", "2.2.0") + base = {version_path: before} + head = { + version_path: after, + first_history: "component: `labkit.app` | `2.1.0 -> 2.2.0`", + second_history: "\n".join([ + "component: `labkit.app` | `2.2.0 -> 2.3.0`", + "component: `sample_app` | `1.0.0 -> 1.0.1`", + ]), + } + + errors = MODULE.validate_versions( + [version_path, first_history, second_history], + base.get, + head.get, + ) + + self.assertIn( + "labkit.app: changed history is split across " + f"{first_history}, {second_history}; consolidate the component's " + "net PR history into one record.", + errors, + ) + self.assertIn( + f"{second_history}: history records `labkit.app` as " + "`2.2.0 -> 2.3.0`, but the net PR transition is " + "`2.1.0 -> 2.2.0`.", + errors, + ) + self.assertIn( + f"{second_history}: history records `sample_app` as " + "`1.0.0 -> 1.0.1`, but the component has no net version change " + "from the PR base.", + errors, + ) + + def test_one_consolidated_history_record_accepts_the_net_transition(self): + version_path = "+labkit/+app/version.m" + history_path = "docs/history/records/2026/08/LK-sdk.md" + before = 'labkit.contract.versionInfo("app", "2.1.0", ">=2 <3")' + after = before.replace("2.1.0", "2.2.0") + base = {version_path: before} + head = { + version_path: after, + history_path: "\n".join([ + "component: `labkit.app` | `2.1.0 -> 2.2.0`", + "component: `sample_app` | `1.0.0 -> 1.0.1`", + ]), + } + + errors = MODULE.validate_versions( + [version_path, history_path], base.get, head.get + ) + + self.assertEqual( + errors, + [ + f"{history_path}: history records `sample_app` as " + "`1.0.0 -> 1.0.1`, but the component has no net version " + "change from the PR base." + ], + ) def test_launcher_source_uses_launcher_metadata(self): metadata = MODULE.LAUNCHER_METADATA before = ( @@ -128,6 +195,27 @@ def test_launcher_source_uses_launcher_metadata(self): [], ) + def test_launcher_metadata_can_move_without_losing_the_transition(self): + current = MODULE.LAUNCHER_METADATA + legacy = MODULE.LEGACY_LAUNCHER_METADATA + before = ( + 'info = struct("name", "labkit_launcher", ' + '"version", "1.8.2");' + ) + after = before.replace("1.8.2", "1.8.3") + history = "docs/history/records/2026/08/LK-launcher.md" + paths = [legacy, current, history] + base = {legacy: before} + head = { + current: after, + history: "component: `labkit_launcher` | `1.8.2 -> 1.8.3`", + } + + self.assertEqual( + MODULE.validate_versions(paths, base.get, head.get), + [], + ) + if __name__ == "__main__": unittest.main() diff --git a/AGENTS.md b/AGENTS.md index 6e4bc93e8..c2e474de5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,8 +154,13 @@ tests, history, and details out of the public repository. changed task; required PR CI owns complete validation. The protected main-push run repeats only policy and the aggregate gate for the exact squash commit because its tree is the already-validated PR result. -- After failure, fix and rerun the narrowest failed file, method, or suite; do not repeatedly - invoke the planner. Exact commands and scope live in +- After a local or hosted-CI failure, inspect only the failing identity and its + log, fix the smallest responsible source boundary, and rerun the narrowest + failed method, specification file, or owner/contract. Push the focused repair + and let required CI re-establish the complete claim; do not rerun + `changedFast` or a local full profile after every CI repair. Re-plan only when + the repair intentionally widens the changed behavior or ownership boundary. + Exact commands and scope live in `docs/development/maintain-and-release/testing.md`. - MATLAB and GitHub inspection require host runtime/network permissions. Run every `gh` command with host permissions on its first attempt, including @@ -204,7 +209,11 @@ tests, history, and details out of the public repository. merging the final PR, inspect the complete base-to-head diff, user docs, component versions, structured history, validation evidence, and remaining risks as one net change that `main` will squash into; do not derive release - semantics from intermediate branch commits. + semantics from intermediate branch commits. Versions and small history + records may remain provisional during ordinary iteration, but PR preparation + rewrites them from the `origin/main` baseline: remove intermediate version + transitions, merge related checkpoint records, and leave exactly one changed + structured history record for each versioned component. 5. Main accepts PRs only from the repository-owned `develop` branch. Run `changedFast` once before final review, inspect required PR CI, and read only failing logs. Squash-merge with an explicit compliant subject. @@ -257,6 +266,17 @@ explicit compliant squash subject; do not rely on GitHub defaults. - History records use stable Change ID and sequence metadata plus rationale, compatibility, user/data impact, validation, evidence, and follow-up. Do not restore a root changelog or separate history parser. +- While work remains on `develop`, treat component history as the net pending + integration record rather than a commit diary. Merge compatible incremental + changes into an existing unpublished record when they share the same + component evolution, user outcome, and compatibility decision. Before the + squash PR is ready for review, compare the complete base-to-head change and + rewrite its history as the smallest coherent set of independently reviewable + product decisions: fold minor follow-on edits into their owning record, + remove records that describe no durable transition, and consolidate + development-only version steps so each affected component advances exactly + once for the net change. Update rationale, compatibility, user impact, and + evidence to describe the final PR diff rather than its commit sequence. - New release tags are `vX.Y.Z`; do not rename published legacy tags. Release titles are `LabKit MATLAB Workbench vX.Y.Z` with relevant `Highlights`, `Fixes`, `Upgrade Note`, and `Validation` sections. diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 547bdd986..7b4957fc1 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -123,6 +123,11 @@ find the exact owner and contract; App authors never invent test paths. ## Version, docs, and tests +- Document framework-provided default lifecycle and interaction behavior only + in the owning framework manual and public API help. Family manuals own + family-domain meaning; App manuals own only App-specific meaning or explicit + deviations. Never restate an SDK default across family or App pages, and + never copy one shared-behavior paragraph across every App page. - Source or user-visible behavior changes update `AppVersion` and `Updated` in the App's `definition.m`, owned documentation, and component history before the `develop` PR is merge-ready. diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m index f1ca2c505..176b2b51f 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m @@ -22,6 +22,8 @@ applicationState.project); applicationState = ... dic_preprocess.analysisRun.rebuildCache(applicationState); +applicationState.session.cache.plotViewRevision = ... + applicationState.session.cache.plotViewRevision + 1; applicationState = ... dic_preprocess.analysisRun.stopEditors(applicationState); applicationState.project.parameters.previewMode = "Current pair"; diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m index 7eaf9965f..a4a3e8f43 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m @@ -1,34 +1,43 @@ -function [alignedImage, tformRigid, method] = autoAlignMovingToReference(referenceImage, movingImage) -%AUTOALIGNMOVINGTOREFERENCE Estimate and apply an integer translation. +function [alignedImage, tformRigid, method, quality] = autoAlignMovingToReference(referenceImage, movingImage) +%AUTOALIGNMOVINGTOREFERENCE Estimate and apply a rigid transform. % % Usage: -% [alignedImage, transform, method] = ... +% [alignedImage, transform, method, quality] = ... % dic_preprocess.analysisRun.autoAlignMovingToReference( ... % referenceImage, movingImage) % % Inputs: % referenceImage - Numeric grayscale or RGB reference image. Its first two -% dimensions define the output canvas and correlation size. -% movingImage - Numeric grayscale or RGB image to translate. +% dimensions define the output canvas. +% movingImage - Numeric grayscale or RGB image to register rigidly. % % Outputs: -% alignedImage - Original movingImage translated onto the reference canvas, -% with linear interpolation and zero fill. -% tformRigid - Three-by-three row-vector homogeneous translation transform, +% alignedImage - Original movingImage rotated and translated onto the +% reference canvas, with linear interpolation and zero fill. +% tformRigid - Three-by-three row-vector homogeneous rigid transform, % shown as transform in the usage syntax. -% method - Character vector identifying the fixed phase-correlation method. +% method - Character vector identifying the fixed coarse-to-fine method. +% quality - Scalar structure containing angleDegrees, translationX, +% translationY, score, overlapFraction, scoreMargin, and +% translationPeakMargin for the accepted match. % % Description: -% Each image is converted to normalized grayscale independently. For shift -% estimation only, moving grayscale data is resized to the reference size by -% nearest-neighbor sampling. Phase correlation returns a whole-pixel circular -% shift. Rotation, scale, deformation, repeated texture, and large nonoverlap -% can produce a poor fit. +% Each image is converted to normalized grayscale independently. The search +% covers the full rotation circle at six-degree spacing, then refines the +% best neighborhood at one-degree and quarter-degree spacing. Anti-aliased +% previews and zero-padded amplitude-weighted phase correlation provide subpixel +% translation estimates. Candidates are ranked with robust, overlap-aware +% oriented structure at a finer resolution. The accepted rotation and +% translation are applied to the original moving image. Scale and +% deformation are not estimated; repeated texture and large nonoverlap can +% still produce a poor fit. % % Failure Behavior: +% dic_preprocess:AutoAlignmentFailed - No candidate has a finite oriented +% structural match, including uniform or otherwise uninformative pairs. % The function does not assign a confidence score or reject an ambiguous -% phase-correlation peak; low-texture or repeated-pattern inputs can return a -% numerically valid but poor translation. Empty arrays, unsupported image +% registration peak; low-texture or repeated-pattern inputs can return a +% numerically valid but poor transform. Empty arrays, unsupported image % classes, or invalid channel shapes propagate image conversion/interpolation % errors. % @@ -47,11 +56,11 @@ fixedGray = normalizeGray(referenceImage); movingGray = normalizeGray(movingImage); - [rowShift, colShift] = estimateTranslation(fixedGray, movingGray); - tformRigid = [1 0 0; 0 1 0; colShift rowShift 1]; + [tformRigid, quality] = estimateRigidTransform(fixedGray, movingGray); alignedImage = dic_preprocess.analysisRun.applyRigidTransform( ... referenceImage, movingImage, tformRigid); - method = 'toolbox-free phase-correlation translation registration'; + method = ['toolbox-free coarse-to-fine rigid phase-correlation ' ... + 'registration with fine structural scoring']; end function gray = normalizeGray(imageData) @@ -62,39 +71,266 @@ gray = labkit.image.im2double(imageData); end values = gray(:); - values = values(~isnan(values)); + values = values(isfinite(values)); if isempty(values) return; end - mn = min(values); - mx = max(values); + values = sort(values); + mn = percentileValue(values, .01); + mx = percentileValue(values, .99); + if ~(isfinite(mn) && isfinite(mx) && mx > mn) + mn = values(1); + mx = values(end); + end if isfinite(mn) && isfinite(mx) && mx > mn gray = (gray - mn) ./ (mx - mn); + gray = min(1, max(0, gray)); + end +end + +function value = percentileValue(sortedValues, fraction) + position = 1 + fraction * (numel(sortedValues) - 1); + lower = floor(position); + upper = ceil(position); + weight = position - lower; + value = (1 - weight) * sortedValues(lower) + ... + weight * sortedValues(upper); +end + +function [transform, quality] = estimateRigidTransform(fixedGray, movingGray) + % A global-to-fine full-circle search supports camera reorientation while + % keeping the expensive high-resolution scoring bounded. A 256-pixel + % preview bounds translation work; + % a finer 1024-pixel structural score avoids selecting angles from an + % aliased DIC texture preview without changing the source-resolution + % output transform. + coarseAngleStepDegrees = 6; + intermediateAngleStepDegrees = 1; + fineAngleStepDegrees = .25; + maximumTranslationFraction = .75; + maximumPreviewDimension = 256; + maximumScoreDimension = 1024; + fixedSize = [size(fixedGray, 1), size(fixedGray, 2)]; + movingSize = [size(movingGray, 1), size(movingGray, 2)]; + sampleStep = max(1, ceil(max([fixedSize, movingSize]) / ... + maximumPreviewDimension)); + fixedRows = 1:sampleStep:size(fixedGray, 1); + fixedCols = 1:sampleStep:size(fixedGray, 2); + scoreStep = max(1, ceil(max([fixedSize, movingSize]) / ... + maximumScoreDimension)); + scoreRows = 1:scoreStep:size(fixedGray, 1); + scoreCols = 1:scoreStep:size(fixedGray, 2); + fixedTranslationImage = antiAliasForStep(fixedGray, sampleStep); + movingTranslationImage = antiAliasForStep(movingGray, sampleStep); + fixedScoreImage = antiAliasForStep(fixedGray, scoreStep); + movingScoreImage = antiAliasForStep(movingGray, scoreStep); + fixedPreview = fixedTranslationImage(fixedRows, fixedCols); + fixedScorePreview = fixedScoreImage(scoreRows, scoreCols); + fixedFeature = registrationFeature(fixedPreview); + coarseAngles = -180:coarseAngleStepDegrees: ... + 180 - coarseAngleStepDegrees; + [bestTransform, bestAngle, bestScore, bestDetails] = bestCandidate( ... + coarseAngles, fixedGray, movingTranslationImage, movingScoreImage, ... + fixedFeature, fixedScorePreview, fixedRows, fixedCols, sampleStep, ... + scoreRows, scoreCols, maximumTranslationFraction); + intermediateAngles = angleNeighborhood(bestAngle, ... + coarseAngleStepDegrees, intermediateAngleStepDegrees); + [intermediateTransform, intermediateAngle, intermediateScore, ... + intermediateDetails] = bestCandidate( ... + intermediateAngles, fixedGray, movingTranslationImage, ... + movingScoreImage, fixedFeature, fixedScorePreview, fixedRows, ... + fixedCols, sampleStep, scoreRows, scoreCols, ... + maximumTranslationFraction); + if intermediateScore >= bestScore + bestTransform = intermediateTransform; + bestAngle = intermediateAngle; + bestScore = intermediateScore; + bestDetails = intermediateDetails; + end + fineAngles = angleNeighborhood(bestAngle, ... + intermediateAngleStepDegrees, fineAngleStepDegrees); + [fineTransform, ~, fineScore, fineDetails] = bestCandidate( ... + fineAngles, fixedGray, movingTranslationImage, movingScoreImage, ... + fixedFeature, fixedScorePreview, fixedRows, fixedCols, sampleStep, ... + scoreRows, scoreCols, maximumTranslationFraction); + if fineScore >= bestScore + bestTransform = fineTransform; + bestScore = fineScore; + bestDetails = fineDetails; + end + if ~isfinite(bestScore) + error("dic_preprocess:AutoAlignmentFailed", ... + "Automatic alignment could not find a finite structural match."); end + transform = bestTransform; + quality = struct( ... + "angleDegrees", atan2d(transform(1, 2), transform(1, 1)), ... + "translationX", transform(3, 1), ... + "translationY", transform(3, 2), ... + "score", bestScore, ... + "overlapFraction", bestDetails.overlapFraction, ... + "scoreMargin", bestDetails.scoreMargin, ... + "translationPeakMargin", bestDetails.translationPeakMargin); +end + +function angles = angleNeighborhood(centerAngle, radius, step) + angles = centerAngle + (-radius:step:radius); + angles = mod(angles + 180, 360) - 180; + angles = unique(angles, "stable"); end -function [rowShift, colShift] = estimateTranslation(fixedGray, movingGray) - targetSize = size(fixedGray); - movingGray = resizeToMatch(movingGray, targetSize); - fixedGray = fixedGray - finiteMean(fixedGray); - movingGray = movingGray - finiteMean(movingGray); - fixedGray(~isfinite(fixedGray)) = 0; - movingGray(~isfinite(movingGray)) = 0; +function [bestTransform, bestAngle, bestScore, bestDetails] = bestCandidate( ... + angles, fixedGray, movingTranslationImage, movingScoreImage, ... + fixedFeature, fixedScorePreview, fixedRows, fixedCols, sampleStep, ... + scoreRows, scoreCols, maximumTranslationFraction) + fixedCenter = ([size(fixedGray, 2), size(fixedGray, 1)] + 1) / 2; + movingCenter = ([size(movingScoreImage, 2), ... + size(movingScoreImage, 1)] + 1) / 2; + bestScore = -inf; + bestAngle = 0; + bestTransform = eye(3); + bestDetails = candidateDetails(); + candidateScores = -inf(size(angles)); + for angleIndex = 1:numel(angles) + angle = angles(angleIndex); + radians = angle * pi / 180; + rotation = [cos(radians) sin(radians); ... + -sin(radians) cos(radians)]; + centerTranslation = fixedCenter - movingCenter * rotation; + centered = warpPreview( ... + movingTranslationImage, rotation, centerTranslation, ... + fixedRows, fixedCols); + [rowShift, colShift, translationPeakMargin] = estimateTranslation( ... + fixedFeature, registrationFeature(centered), ... + maximumTranslationFraction); + translation = centerTranslation + ... + sampleStep * [colShift rowShift]; + warped = warpPreview( ... + movingScoreImage, rotation, translation, scoreRows, scoreCols); + [score, overlapFraction] = orientedAlignmentScore( ... + fixedScorePreview, warped); + candidateScores(angleIndex) = score; + if score > bestScore + bestScore = score; + bestAngle = angle; + bestTransform = [rotation [0; 0]; translation 1]; + bestDetails.overlapFraction = overlapFraction; + bestDetails.translationPeakMargin = translationPeakMargin; + end + end + finiteScores = sort(candidateScores(isfinite(candidateScores)), "descend"); + if numel(finiteScores) >= 2 + bestDetails.scoreMargin = finiteScores(1) - finiteScores(2); + end +end + +function value = candidateDetails() + value = struct("overlapFraction", 0, "scoreMargin", 0, ... + "translationPeakMargin", 0); +end + +function preview = warpPreview(imageData, rotation, translation, rows, cols) + [xGrid, yGrid] = meshgrid(cols, rows); + source = ([xGrid(:), yGrid(:)] - translation) * rotation.'; + preview = interp2(double(imageData), ... + reshape(source(:, 1), size(xGrid)), ... + reshape(source(:, 2), size(yGrid)), 'linear', NaN); +end + +function feature = registrationFeature(imageData) + imageData(~isfinite(imageData)) = finiteMean(imageData); + horizontal = [diff(imageData, 1, 2), zeros(size(imageData, 1), 1)]; + vertical = [diff(imageData, 1, 1); zeros(1, size(imageData, 2))]; + feature = hypot(horizontal, vertical); +end - spectrum = fft2(fixedGray) .* conj(fft2(movingGray)); +function [rowShift, colShift, peakMargin] = estimateTranslation( ... + fixedFeature, movingFeature, maximumTranslationFraction) + fixedFeature = fixedFeature - finiteMean(fixedFeature); + movingFeature = movingFeature - finiteMean(movingFeature); + fixedFeature(~isfinite(fixedFeature)) = 0; + movingFeature(~isfinite(movingFeature)) = 0; + transformSize = 2 .* ... + [size(fixedFeature, 1), size(fixedFeature, 2)]; + spectrum = fft2(fixedFeature, transformSize(1), transformSize(2)) .* ... + conj(fft2(movingFeature, transformSize(1), transformSize(2))); magnitude = abs(spectrum); magnitude(magnitude == 0) = 1; - correlation = real(ifft2(spectrum ./ magnitude)); + % Retain part of the spectral amplitude so broad DIC texture contributes + % to the peak instead of letting weak periodic frequencies dominate it. + correlation = real(ifft2(spectrum ./ sqrt(magnitude))); + rowValues = 0:size(correlation, 1)-1; + colValues = 0:size(correlation, 2)-1; + rowValues(rowValues > size(correlation, 1) / 2) = ... + rowValues(rowValues > size(correlation, 1) / 2) - size(correlation, 1); + colValues(colValues > size(correlation, 2) / 2) = ... + colValues(colValues > size(correlation, 2) / 2) - size(correlation, 2); + allowedRows = abs(rowValues) <= ... + floor(maximumTranslationFraction * size(fixedFeature, 1)); + allowedCols = abs(colValues) <= ... + floor(maximumTranslationFraction * size(fixedFeature, 2)); + correlation(~allowedRows, :) = -inf; + correlation(:, ~allowedCols) = -inf; [~, idx] = max(correlation(:)); [peakRow, peakCol] = ind2sub(size(correlation), idx); - rowShift = peakRow - 1; - colShift = peakCol - 1; - if rowShift > floor(size(correlation, 1) / 2) - rowShift = rowShift - size(correlation, 1); + rowOffset = quadraticPeakOffset( ... + correlation, peakRow, peakCol, 1); + colOffset = quadraticPeakOffset( ... + correlation, peakRow, peakCol, 2); + rowShift = rowValues(peakRow) + rowOffset; + colShift = colValues(peakCol) + colOffset; + peakValue = correlation(peakRow, peakCol); + sidelobes = correlation; + rowWindow = max(1, peakRow - 2):min(size(correlation, 1), peakRow + 2); + colWindow = max(1, peakCol - 2):min(size(correlation, 2), peakCol + 2); + sidelobes(rowWindow, colWindow) = -inf; + secondPeak = max(sidelobes(:)); + if isfinite(secondPeak) + peakMargin = (peakValue - secondPeak) / max(abs(peakValue), eps); + else + peakMargin = 0; + end +end + +function offset = quadraticPeakOffset(values, row, col, dimension) + offset = 0; + if dimension == 1 + if row <= 1 || row >= size(values, 1) + return; + end + previous = values(row - 1, col); + center = values(row, col); + following = values(row + 1, col); + else + if col <= 1 || col >= size(values, 2) + return; + end + previous = values(row, col - 1); + center = values(row, col); + following = values(row, col + 1); end - if colShift > floor(size(correlation, 2) / 2) - colShift = colShift - size(correlation, 2); + denominator = previous - 2 * center + following; + if all(isfinite([previous center following])) && denominator < -eps + offset = .5 * (previous - following) / denominator; + offset = min(.5, max(-.5, offset)); + end +end + +function filtered = antiAliasForStep(imageData, sampleStep) + filtered = double(imageData); + if sampleStep <= 1 + return; end + kernelWidth = 2 * floor(sampleStep / 2) + 1; + kernel = ones(1, kernelWidth) / kernelWidth; + valid = isfinite(filtered); + filtered(~valid) = 0; + weights = conv2(conv2(double(valid), kernel, "same"), ... + kernel.', "same"); + filtered = conv2(conv2(filtered, kernel, "same"), ... + kernel.', "same"); + filtered = filtered ./ max(weights, eps); + filtered(weights == 0) = NaN; end function value = finiteMean(imageData) @@ -106,22 +342,88 @@ end end -function imageOut = resizeToMatch(imageIn, targetSize) - if isequal(size(imageIn, 1), targetSize(1)) && ... - isequal(size(imageIn, 2), targetSize(2)) - imageOut = imageIn; +function [score, overlapFraction] = alignmentScore(fixedImage, movingImage) + valid = isfinite(fixedImage) & isfinite(movingImage); + overlapFraction = nnz(valid) / numel(valid); + if overlapFraction < .2 + score = -inf; return; end - rowIdx = nearestIndices(size(imageIn, 1), targetSize(1)); - colIdx = nearestIndices(size(imageIn, 2), targetSize(2)); - imageOut = imageIn(rowIdx, colIdx, :); + fixedValues = fixedImage(valid); + movingValues = movingImage(valid); + fixedValues = fixedValues - mean(fixedValues); + movingValues = movingValues - mean(movingValues); + denominator = norm(fixedValues) * norm(movingValues); + if denominator <= eps + score = -inf; + return; + end + globalScore = (fixedValues.' * movingValues) / denominator; + tileScores = localCorrelationScores(fixedImage, movingImage, valid); + if isempty(tileScores) + robustScore = globalScore; + else + robustScore = median(tileScores); + end + score = .6 * globalScore + .4 * robustScore - ... + .1 * (1 - overlapFraction); end -function idx = nearestIndices(inputLength, outputLength) - if outputLength <= 1 - idx = 1; - return; +function scores = localCorrelationScores(fixedImage, movingImage, valid) + tileCount = 4; + rowEdges = round(linspace(1, size(fixedImage, 1) + 1, tileCount + 1)); + colEdges = round(linspace(1, size(fixedImage, 2) + 1, tileCount + 1)); + scores = zeros(tileCount^2, 1); + scoreCount = 0; + for rowIndex = 1:tileCount + rows = rowEdges(rowIndex):rowEdges(rowIndex + 1) - 1; + for colIndex = 1:tileCount + cols = colEdges(colIndex):colEdges(colIndex + 1) - 1; + tileValid = valid(rows, cols); + if nnz(tileValid) < max(4, ceil(.5 * numel(tileValid))) + continue; + end + fixedTile = fixedImage(rows, cols); + movingTile = movingImage(rows, cols); + fixedValues = fixedTile(tileValid); + movingValues = movingTile(tileValid); + fixedValues = fixedValues - mean(fixedValues); + movingValues = movingValues - mean(movingValues); + denominator = norm(fixedValues) * norm(movingValues); + if denominator > eps + scoreCount = scoreCount + 1; + scores(scoreCount, 1) = ... + (fixedValues.' * movingValues) / denominator; + end + end + end + scores = scores(1:scoreCount); +end + +function [score, overlapFraction] = orientedAlignmentScore( ... + fixedImage, movingImage) + fixedHorizontal = [diff(fixedImage, 1, 2), ... + zeros(size(fixedImage, 1), 1)]; + fixedVertical = [diff(fixedImage, 1, 1); ... + zeros(1, size(fixedImage, 2))]; + movingHorizontal = [diff(movingImage, 1, 2), ... + zeros(size(movingImage, 1), 1)]; + movingVertical = [diff(movingImage, 1, 1); ... + zeros(1, size(movingImage, 2))]; + [horizontalScore, horizontalOverlap] = alignmentScore( ... + fixedHorizontal, movingHorizontal); + [verticalScore, verticalOverlap] = alignmentScore( ... + fixedVertical, movingVertical); + [magnitudeScore, magnitudeOverlap] = alignmentScore( ... + hypot(fixedHorizontal, fixedVertical), ... + hypot(movingHorizontal, movingVertical)); + componentScores = [horizontalScore, verticalScore, magnitudeScore]; + componentScores = componentScores(isfinite(componentScores)); + if isempty(componentScores) + score = -inf; + else + score = mean(componentScores); end - positions = linspace(1, inputLength, outputLength); - idx = min(max(round(positions), 1), inputLength); + overlapFraction = min( ... + [horizontalOverlap, verticalOverlap, magnitudeOverlap]); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m index f7455efa8..423ec5fa8 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/drawPreview.m @@ -28,6 +28,8 @@ function drawOne(ax, model) background.Tag = backgroundTag(); axis(ax, 'image'); ax.YDir = 'reverse'; + ax.XLim = [.5 size(model.imageData, 2) + .5]; + ax.YLim = [.5 size(model.imageData, 1) + .5]; end delete(findobj(ax, 'Tag', overlayTag())); diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/present.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/present.m index 9cf3a2a25..4102b7c36 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/present.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/present.m @@ -24,7 +24,8 @@ .enabled("cancelCropRoi", cropping) ... .enabled("undoEdit", ~isempty(annotations.history)) ... .enabled("resetToOriginals", hasPair) ... - .renderPlot("preview", model) ... + .renderPlot("preview", model, ... + ViewRevision=cache.plotViewRevision) ... .pairedAnchors("matchPoints", ... {annotations.matchReferencePoints, ... annotations.matchMovingPoints}, ... @@ -41,6 +42,7 @@ "moving", axisModel(request.bottomImage, request.bottomTitle)); if state.session.workflow.mode == "crop" model.reference.rectangle = state.project.annotations.cropRect; + model.moving.rectangle = state.project.annotations.cropRect; elseif state.session.workflow.mode == "matching" model.reference.pointLabels = ... state.project.annotations.matchReferencePoints; diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/previewRequest.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/previewRequest.m index be4399095..8920e9a2e 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/previewRequest.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/previewRequest.m @@ -35,10 +35,7 @@ otherwise request.topImage = cache.currentReferenceImage; request.topTitle = "Current reference"; - if previewValue == "Current moving image" - request.bottomImage = cache.currentMovingImage; - request.bottomTitle = previewValue; - elseif previewValue == "False-color overlay" && ... + if previewValue == "False-color overlay" && ... dic_preprocess.sourceFiles.hasImagePair(cache) request.bottomImage = dic_preprocess.analysisRun.makeFalseColorOverlay( ... cache.currentReferenceImage, cache.currentMovingImage); diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/rebuildCache.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/rebuildCache.m index 3982b89dc..072bf3159 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/rebuildCache.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/rebuildCache.m @@ -2,8 +2,10 @@ function applicationState = rebuildCache(applicationState) %REBUILDCACHE Replay durable edit steps into transient working images. cache = applicationState.session.cache; +plotViewRevision = cache.plotViewRevision; applicationState.session.cache = ... dic_preprocess.analysisRun.replayEditSteps( ... cache.referenceImage, cache.movingImage, ... applicationState.project.annotations.editSteps); +applicationState.session.cache.plotViewRevision = plotViewRevision; end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/replayEditSteps.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/replayEditSteps.m index 5366c5950..0db191188 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/replayEditSteps.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/replayEditSteps.m @@ -9,7 +9,8 @@ "currentMovingImage", movingImage, ... "alignedImage", [], ... "cropReference", [], ... - "cropMoving", []); + "cropMoving", [], ... + "plotViewRevision", 0); for k = 1:numel(steps) step = steps(k); switch string(step.kind) diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m index d200689d2..c4f39fe9d 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m @@ -7,7 +7,8 @@ return; end try - [~, transform, method] = dic_preprocess.analysisRun.autoAlignMovingToReference( ... + [~, transform, method, quality] = ... + dic_preprocess.analysisRun.autoAlignMovingToReference( ... cache.currentReferenceImage, cache.currentMovingImage); catch ME context.log("error", "dic_preprocess.analysisrun.runautomaticregistration.exception", 'Automatic alignment', ... @@ -17,5 +18,6 @@ end state = dic_preprocess.analysisRun.recordAlignment(state, transform, "automatic alignment"); context.log("info", "dic_preprocess.analysisrun.runautomaticregistration.status", ... - "Automatically aligned current pair using " + string(method) + "."); + "Automatically aligned current pair using " + string(method) + ".", ... + Category="workflow", Audience="user", Attributes=quality); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m index 05aa7cb87..ce48d259e 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startPointMatching.m @@ -2,6 +2,7 @@ function state = startPointMatching(state, ~) if dic_preprocess.sourceFiles.hasImagePair(state.session.cache) state.session.workflow.mode = "matching"; + state.project.parameters.previewMode = "Current pair"; state.project.annotations.matchReferencePoints = zeros(0,2); state.project.annotations.matchMovingPoints = zeros(0,2); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/present.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/present.m index 75d625e13..22bbdf7dd 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/present.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/present.m @@ -25,6 +25,6 @@ .enabled("clearMaskBoundary", active && pointCount > 0) ... .enabled("clearMaskCanvas", active && ... ~isempty(annotations.maskImage)) ... - .pointSlots("maskPoints", annotations.maskPoints, ... + .anchorPath("maskPoints", annotations.maskPoints, ... ImageSize=imageSize, Enabled=active); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/layoutSection.m b/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/layoutSection.m index 7b6382e6f..18494d552 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/layoutSection.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/layoutSection.m @@ -16,8 +16,8 @@ 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"], ... + Kind="choice", Choices=["Current pair", "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/+workbench/buildLayout.m b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m index 421b71031..066e0563e 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m @@ -21,9 +21,9 @@ crop = labkit.app.interaction.rectangle("cropRectangle", ... @dic_preprocess.analysisRun.changeCropRectangle, Axis="reference", ... ViewportPolicy="preserve"); -maskPoints = labkit.app.interaction.pointSlots("maskPoints", ... +maskPoints = labkit.app.interaction.anchorPath("maskPoints", ... @dic_preprocess.maskEditing.changeBoundaryPoints, Axis="reference", ... - ViewportPolicy="preserve"); + Style=struct("closed", true), ViewportPolicy="preserve"); workspace = labkit.app.layout.workspace(labkit.app.layout.plotArea("preview", ... @dic_preprocess.analysisRun.drawPreview, ... AxisIds=["reference", "moving"], ... diff --git a/apps/dic/dic_preprocess/+dic_preprocess/definition.m b/apps/dic/dic_preprocess/+dic_preprocess/definition.m index a3c009104..f89d3767d 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/definition.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/definition.m @@ -4,8 +4,8 @@ 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.7.1", ... - Updated="2026-07-30", Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... + DisplayName="DIC Preprocess", Family="DIC", AppVersion="1.7.2", ... + Updated="2026-08-03", Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=dic_preprocess.projectSpec(), CreateSession=@dic_preprocess.createSession, ... Workbench=dic_preprocess.workbench.buildLayout(), PresentWorkbench=@dic_preprocess.workbench.present, ... BuildSyntheticSample=@dic_preprocess.syntheticInputs.writeSamplePack); diff --git a/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m b/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m index 7820d665b..5a2ed2471 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/projectSpec.m @@ -2,8 +2,9 @@ % Expected caller: dic_preprocess.definition. Output owns the current payload % version, creation defaults, and validation. Side effects are none. function spec = projectSpec() - spec = labkit.app.project.Schema(Version=1, Create=@createProject, ... - Validate=@validateProject, SourceBindings="inputs.sources"); + spec = labkit.app.project.Schema(Version=2, Create=@createProject, ... + Validate=@validateProject, Migrate=@migrateProject, ... + SourceBindings="inputs.sources"); end function project = createProject() @@ -38,9 +39,30 @@ {'previewMode', 'maskBoundaryStyle'})), ... 'dic_preprocess:InvalidProject', ... 'DIC preprocess project parameters are incomplete.'); + previewMode = string(project.parameters.previewMode); + assert(isscalar(previewMode) && ~ismissing(previewMode) && ... + any(previewMode == ["Current pair", "False-color overlay", ... + "Original pair", "ROI mask"]), ... + 'dic_preprocess:InvalidProject', ... + 'DIC preprocess preview mode is unsupported.'); accepted = true; end +function project = migrateProject(project, fromVersion) + if double(fromVersion) ~= 1 + error('dic_preprocess:UnsupportedProjectMigration', ... + 'DIC Preprocess cannot migrate project version %d.', fromVersion); + end + if isfield(project, "parameters") && ... + isfield(project.parameters, "previewMode") + previewMode = string(project.parameters.previewMode); + if isscalar(previewMode) && ... + previewMode == "Current moving image" + project.parameters.previewMode = "Current pair"; + end + end +end + function history = emptyEditHistory() history = struct('editSteps', {}, 'maskImage', {}, ... 'maskPoints', {}, 'description', {}); diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/matchesDtaKind.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/matchesDtaKind.m new file mode 100644 index 000000000..399a90677 --- /dev/null +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/matchesDtaKind.m @@ -0,0 +1,13 @@ +% Expected caller: Chrono Overlay fileList PathFilter. Input is newly +% proposed source paths. Output retains only DTA files detected as chrono; +% no GUI side effects. +function accepted = matchesDtaKind(paths) +arguments + paths (1, :) string +end +accepted = false(size(paths)); +for k = 1:numel(paths) + [kind, status] = labkit.dta.detectType(paths(k)); + accepted(k) = status.ok && kind == "chrono"; +end +end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m index d20f9e724..e24f33c0e 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m @@ -11,6 +11,8 @@ RecursiveFolderLabel="Add folder tree", ... RemoveLabel="Remove selected", ClearLabel="Clear all", ... EmptyText="No files loaded", ... + PathFilter=@chrono_overlay.sourceFiles.matchesDtaKind, ... + PathFilterDescription="chrono DTA", ... Bind="project.inputs.sources", ... SelectionBind="session.selection.files", ... SourceRole="chrono", SourceIdPrefix="dta", Required=true); diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m b/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m index ae96a6e85..f7a888365 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m @@ -7,8 +7,8 @@ Title="Gamry Multi-DTA Plot Export GUI", ... DisplayName="Chrono Overlay", ... Family="Electrochem", ... - AppVersion="1.6.1", ... - Updated="2026-07-30", ... + AppVersion="1.6.2", ... + Updated="2026-08-03", ... Requirements=labkit.contract.requirements( ... "app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=chrono_overlay.projectSpec(), ... diff --git a/apps/electrochem/cic/+cic/+sourceFiles/matchesDtaKind.m b/apps/electrochem/cic/+cic/+sourceFiles/matchesDtaKind.m new file mode 100644 index 000000000..41a33f8fe --- /dev/null +++ b/apps/electrochem/cic/+cic/+sourceFiles/matchesDtaKind.m @@ -0,0 +1,12 @@ +% Expected caller: CIC fileList PathFilter. Input is newly proposed source +% paths. Output retains only DTA files detected as chrono; no GUI side effects. +function accepted = matchesDtaKind(paths) +arguments + paths (1, :) string +end +accepted = false(size(paths)); +for k = 1:numel(paths) + [kind, status] = labkit.dta.detectType(paths(k)); + accepted(k) = status.ok && kind == "chrono"; +end +end diff --git a/apps/electrochem/cic/+cic/+workbench/buildLayout.m b/apps/electrochem/cic/+cic/+workbench/buildLayout.m index 8524cbc9d..0c9500cf3 100644 --- a/apps/electrochem/cic/+cic/+workbench/buildLayout.m +++ b/apps/electrochem/cic/+cic/+workbench/buildLayout.m @@ -11,7 +11,9 @@ RemoveLabel="Remove selected", ClearLabel="Clear all", ... EmptyText="No files loaded", ... Filters=["*.DTA;*.dta", "Gamry DTA (*.DTA)"], ... - SelectionMode="single", ... + SelectionMode="multiple", ... + PathFilter=@cic.sourceFiles.matchesDtaKind, ... + PathFilterDescription="chrono DTA", ... Bind="project.inputs.sources", ... SelectionBind="session.selection.files", ... SourceRole="chrono", SourceIdPrefix="dta"); diff --git a/apps/electrochem/cic/+cic/definition.m b/apps/electrochem/cic/+cic/definition.m index ae2948e81..3f9bf9cdd 100644 --- a/apps/electrochem/cic/+cic/definition.m +++ b/apps/electrochem/cic/+cic/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_CIC_app", AppId="cic", ... Title="Gamry CIC GUI (Voltage Transient)", DisplayName="CIC", ... - Family="Electrochem", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Electrochem", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=cic.projectSpec(), CreateSession=@cic.createSession, ... Workbench=cic.workbench.buildLayout(), ... diff --git a/apps/electrochem/csc/+csc/+sourceFiles/matchesDtaKind.m b/apps/electrochem/csc/+csc/+sourceFiles/matchesDtaKind.m new file mode 100644 index 000000000..42963b809 --- /dev/null +++ b/apps/electrochem/csc/+csc/+sourceFiles/matchesDtaKind.m @@ -0,0 +1,12 @@ +% Expected caller: CSC fileList PathFilter. Input is newly proposed source +% paths. Output retains only DTA files detected as CV/CT; no GUI side effects. +function accepted = matchesDtaKind(paths) +arguments + paths (1, :) string +end +accepted = false(size(paths)); +for k = 1:numel(paths) + [kind, status] = labkit.dta.detectType(paths(k)); + accepted(k) = status.ok && kind == "cvct"; +end +end diff --git a/apps/electrochem/csc/+csc/+workbench/buildLayout.m b/apps/electrochem/csc/+csc/+workbench/buildLayout.m index 5d6242039..87455f27c 100644 --- a/apps/electrochem/csc/+csc/+workbench/buildLayout.m +++ b/apps/electrochem/csc/+csc/+workbench/buildLayout.m @@ -9,7 +9,9 @@ RemoveLabel="Remove selected", ClearLabel="Clear all", ... EmptyText="No files loaded", ... Filters=["*.DTA;*.dta", "Gamry DTA files (*.DTA)"], ... - SelectionMode="single", ... + SelectionMode="multiple", ... + PathFilter=@csc.sourceFiles.matchesDtaKind, ... + PathFilterDescription="CV/CT DTA", ... Bind="project.inputs.sources", ... SelectionBind="session.selection.files", ... SourceRole="cvct", SourceIdPrefix="dta", ... diff --git a/apps/electrochem/csc/+csc/definition.m b/apps/electrochem/csc/+csc/definition.m index a6fca855f..beff08b90 100644 --- a/apps/electrochem/csc/+csc/definition.m +++ b/apps/electrochem/csc/+csc/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_CSC_app", AppId="csc", ... Title="Gamry DTA GUI (literature CSC)", DisplayName="CSC", ... - Family="Electrochem", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Electrochem", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=csc.projectSpec(), CreateSession=@csc.createSession, ... Workbench=csc.workbench.buildLayout(), ... diff --git a/apps/electrochem/eis/+eis/+sourceFiles/matchesDtaKind.m b/apps/electrochem/eis/+eis/+sourceFiles/matchesDtaKind.m new file mode 100644 index 000000000..1434a3432 --- /dev/null +++ b/apps/electrochem/eis/+eis/+sourceFiles/matchesDtaKind.m @@ -0,0 +1,12 @@ +% Expected caller: EIS fileList PathFilter. Input is newly proposed source +% paths. Output retains only DTA files detected as EIS; no GUI side effects. +function accepted = matchesDtaKind(paths) +arguments + paths (1, :) string +end +accepted = false(size(paths)); +for k = 1:numel(paths) + [kind, status] = labkit.dta.detectType(paths(k)); + accepted(k) = status.ok && kind == "eis"; +end +end diff --git a/apps/electrochem/eis/+eis/+workbench/buildLayout.m b/apps/electrochem/eis/+eis/+workbench/buildLayout.m index 74a532e3c..2878571ab 100644 --- a/apps/electrochem/eis/+eis/+workbench/buildLayout.m +++ b/apps/electrochem/eis/+eis/+workbench/buildLayout.m @@ -10,6 +10,8 @@ RecursiveFolderLabel="Add folder tree", ... RemoveLabel="Remove selected", ClearLabel="Clear all", ... EmptyText="No files loaded", ... + PathFilter=@eis.sourceFiles.matchesDtaKind, ... + PathFilterDescription="EIS DTA", ... Bind="project.inputs.sources", SelectionBind="session.selection.files", ... SourceRole="eis", SourceIdPrefix="dta"); filesSection = labkit.app.layout.section("filesSection", "Files", { ... diff --git a/apps/electrochem/eis/+eis/definition.m b/apps/electrochem/eis/+eis/definition.m index 6f8f8e0e7..e13eb058d 100644 --- a/apps/electrochem/eis/+eis/definition.m +++ b/apps/electrochem/eis/+eis/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_EIS_app", AppId="eis", ... Title="Gamry EIS Multi-DTA Plot GUI", DisplayName="EIS Overlay", ... - Family="Electrochem", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Electrochem", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=eis.projectSpec(), CreateSession=@eis.createSession, ... Workbench=eis.workbench.buildLayout(), ... diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/matchesDtaKind.m b/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/matchesDtaKind.m new file mode 100644 index 000000000..82d521243 --- /dev/null +++ b/apps/electrochem/vt_resistance/+vt_resistance/+sourceFiles/matchesDtaKind.m @@ -0,0 +1,13 @@ +% Expected caller: VT Resistance fileList PathFilter. Input is newly proposed +% source paths. Output retains only DTA files detected as chrono; no GUI side +% effects. +function accepted = matchesDtaKind(paths) +arguments + paths (1, :) string +end +accepted = false(size(paths)); +for k = 1:numel(paths) + [kind, status] = labkit.dta.detectType(paths(k)); + accepted(k) = status.ok && kind == "chrono"; +end +end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m b/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m index 8df66b486..aa7c9d941 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m @@ -3,7 +3,9 @@ %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", ... + Filters=["*.DTA;*.dta", "Gamry DTA (*.DTA)"], SelectionMode="multiple", ... + PathFilter=@vt_resistance.sourceFiles.matchesDtaKind, ... + PathFilterDescription="chrono DTA", ... 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", ... diff --git a/apps/electrochem/vt_resistance/+vt_resistance/definition.m b/apps/electrochem/vt_resistance/+vt_resistance/definition.m index 14a80f463..6f85a33d0 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/definition.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_VTResistance_app", AppId="vt_resistance", ... Title="VT Steady Resistance", DisplayName="VT Resistance", ... - Family="Electrochem", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Electrochem", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=vt_resistance.projectSpec(), CreateSession=@vt_resistance.createSession, ... Workbench=vt_resistance.workbench.buildLayout(), ... diff --git a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsChanged.m b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsChanged.m index ac5ee01b1..d621cfd8b 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsChanged.m +++ b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/optionsChanged.m @@ -10,4 +10,6 @@ applicationState.project.results.lastExport = []; applicationState.session.cache.lastRunFingerprint = ""; applicationState.session.selection.currentStepIndex = 1; +applicationState.session.cache.plotViewRevision = ... + applicationState.session.cache.plotViewRevision + 1; end diff --git a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m index e77e38b03..5c3716b73 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m +++ b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m @@ -36,6 +36,8 @@ state.project.results.lastExport = []; state.session.cache.lastRunFingerprint = task.fingerprint; state.session.selection.currentStepIndex = 1; +state.session.cache.plotViewRevision = ... + state.session.cache.plotViewRevision + 1; context.log("info", "gait_analysis.analysisrun.runfromworkbench.status", sprintf("Gait analysis complete: %d valid step(s).", ... sum(result.stepTable.is_valid))); end diff --git a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/draw.m b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/draw.m index 7d469fcfc..932bb464b 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/draw.m +++ b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/draw.m @@ -1,9 +1,11 @@ % 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")); +axisIds = string(fieldnames(axesById)); +for k = 1:numel(axisIds) + axisId = axisIds(k); + drawOne(axesById.(char(axisId)), withKind(model, axisId)); +end end function model = withKind(model, kind) @@ -17,7 +19,9 @@ function drawOne(ax, model) labkit.app.plot.showMessage(ax, ... "Load pose data to preview gait analysis."); elseif model.kind == "skeleton" - drawSkeletons(ax, model); + drawSkeletons(ax, model, false); + elseif model.kind == "overview" + drawSkeletons(ax, model, true); elseif ~model.result.ok ax.YDir = "normal"; labkit.app.plot.showMessage(ax, ... @@ -34,11 +38,12 @@ function drawOne(ax, model) disableHitTesting(ax); end -function drawSkeletons(ax, model) +function drawSkeletons(ax, model, showFullRecording) pose = model.pose; frames = 1:size(pose.coords, 1); titleText = "All overlaid skeleton trajectories"; - if model.result.ok && ~isempty(model.result.stepTable) + if ~showFullRecording && 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", ... @@ -54,7 +59,9 @@ function drawSkeletons(ax, model) 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]); + plot(ax, x(:), y(:), "-", ... + "Color", [0.55 0.55 0.55], ... + "HandleVisibility", "off"); end for k = 1:numel(pose.pointNames) plot(ax, pose.coords(frames, k, 1), pose.coords(frames, k, 2), ... @@ -67,7 +74,9 @@ function drawSkeletons(ax, model) ylabel(ax, "Pixel Y"); grid(ax, "on"); legend(ax, "Location", "best"); - if model.result.ok && ~isempty(model.result.stepTable) + labkit.app.plot.fitAxesToGraphics(ax, EqualDataUnits=true); + if ~showFullRecording && model.result.ok && ... + ~isempty(model.result.stepTable) addStepAnnotation(ax, model.result.stepTable(model.selectedStep, :)); end end @@ -91,6 +100,7 @@ function drawAngles(ax, model) ylabel(ax, "Angle (deg)"); grid(ax, "on"); legend(ax, "Location", "best"); + labkit.app.plot.fitAxesToGraphics(ax); end function drawSegments(ax, model) @@ -114,6 +124,7 @@ function drawSegments(ax, model) ylabel(ax, "Length (" + unit + ")"); grid(ax, "on"); legend(ax, "Location", "best"); + labkit.app.plot.fitAxesToGraphics(ax); end function value = selectedFrames(model) diff --git a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/layoutArea.m b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/layoutArea.m index 302184cb6..2f48c17d9 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/layoutArea.m +++ b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/layoutArea.m @@ -1,11 +1,17 @@ % 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"]); +function areas = layoutArea() +%LAYOUTAREA Declare two paired rows forming the 2-by-2 Gait Preview. +areas = { ... + labkit.app.layout.plotArea("gaitStepAxes", ... + @gait_analysis.gaitPreview.draw, ... + Title="Selected Step", Layout="pair", ... + AxisIds=["skeleton", "angles"], ... + AxisTitles=["Skeleton trajectories", "Joint angles"], ... + ScrollZoomAxes=["xy", "x"]), ... + labkit.app.layout.plotArea("gaitContextAxes", ... + @gait_analysis.gaitPreview.draw, ... + Title="Lengths and Full Recording", Layout="pair", ... + AxisIds=["segments", "overview"], ... + AxisTitles=["Segment lengths", "Full gait overlay"], ... + ScrollZoomAxes=["x", "xy"])}; end diff --git a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/present.m b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/present.m index be6d00876..a2b91acf8 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/present.m +++ b/apps/gait/gait_analysis/+gait_analysis/+gaitPreview/present.m @@ -1,5 +1,7 @@ % 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); +function view = present(model, viewRevision) +%PRESENT Supply one model and viewport revision to both preview rows. +view = labkit.app.view.Snapshot() ... + .renderPlot("gaitStepAxes", model, ViewRevision=viewRevision) ... + .renderPlot("gaitContextAxes", model, ViewRevision=viewRevision); end diff --git a/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m b/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m index 01b61181f..a17e50867 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m +++ b/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m @@ -13,6 +13,8 @@ applicationState.project.results.lastExport = []; applicationState.session.cache.lastRunFingerprint = ""; applicationState.session.selection.currentStepIndex = 1; +applicationState.session.cache.plotViewRevision = ... + applicationState.session.cache.plotViewRevision + 1; if ~pose.ok || isempty(selection.Indices) return end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/next.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/next.m index ef65ea695..d9e39af33 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/next.m +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/next.m @@ -1,7 +1,13 @@ % 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 = ... +previous = applicationState.session.selection.currentStepIndex; +selected = ... gait_analysis.stepPreview.boundedIndex(applicationState, ... applicationState.session.selection.currentStepIndex + 1); +applicationState.session.selection.currentStepIndex = selected; +if selected ~= previous + applicationState.session.cache.plotViewRevision = ... + applicationState.session.cache.plotViewRevision + 1; +end end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/previous.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/previous.m index df944a4e2..e15f4d1a8 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/previous.m +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/previous.m @@ -1,7 +1,13 @@ % 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 = ... +previous = applicationState.session.selection.currentStepIndex; +selected = ... gait_analysis.stepPreview.boundedIndex(applicationState, ... applicationState.session.selection.currentStepIndex - 1); +applicationState.session.selection.currentStepIndex = selected; +if selected ~= previous + applicationState.session.cache.plotViewRevision = ... + applicationState.session.cache.plotViewRevision + 1; +end end diff --git a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m index b199d1ab7..bd309f192 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m +++ b/apps/gait/gait_analysis/+gait_analysis/+stepPreview/select.m @@ -10,7 +10,13 @@ if isempty(selection.CellIndices) return end -applicationState.session.selection.currentStepIndex = ... +previous = applicationState.session.selection.currentStepIndex; +selected = ... gait_analysis.stepPreview.boundedIndex( ... applicationState, selection.CellIndices(1, 1)); +applicationState.session.selection.currentStepIndex = selected; +if selected ~= previous + applicationState.session.cache.plotViewRevision = ... + applicationState.session.cache.plotViewRevision + 1; +end end diff --git a/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m b/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m index 286d66671..ecc144a64 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m +++ b/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m @@ -12,15 +12,18 @@ "results", "Results + Export", ... [review, {gait_analysis.resultFiles.layoutSection()}]); preview = gait_analysis.gaitPreview.layoutArea(); +workspace = labkit.app.layout.workspace(Title="Gait Preview"); +workspace = workspace.page( ... + "gaitPreview", "Gait Preview", preview); +workspace = workspace.initialPage("gaitPreview"); 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.", ... + "4. Run analysis, then review one step's skeleton, angles, and lengths beside the full-recording overlay.", ... "5. Export coordinates include raw pixel columns plus optional scaled/origin-shifted columns."]; layout = labkit.app.layout.workbench( ... {source, options, results}, ... - Workspace=labkit.app.layout.workspace( ... - preview, Title="Gait Preview"), ... + Workspace=workspace, ... 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 index 21a52062f..abf5ab83b 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+workbench/present.m +++ b/apps/gait/gait_analysis/+gait_analysis/+workbench/present.m @@ -16,5 +16,6 @@ .include(gait_analysis.stepPreview.present(result, selectedStep)) ... .include(gait_analysis.resultFiles.present( ... applicationState.session.workflow.outputFolder, result.ok)) ... - .include(gait_analysis.gaitPreview.present(model)); + .include(gait_analysis.gaitPreview.present( ... + model, applicationState.session.cache.plotViewRevision)); end diff --git a/apps/gait/gait_analysis/+gait_analysis/createSession.m b/apps/gait/gait_analysis/+gait_analysis/createSession.m index 43a9424ae..b659098a7 100644 --- a/apps/gait/gait_analysis/+gait_analysis/createSession.m +++ b/apps/gait/gait_analysis/+gait_analysis/createSession.m @@ -26,6 +26,7 @@ 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), ... + "pose", pose, "lastRunFingerprint", fingerprint, ... + "plotViewRevision", 0), ... "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 0474bcf5e..dfa25a4e6 100644 --- a/apps/gait/gait_analysis/+gait_analysis/definition.m +++ b/apps/gait/gait_analysis/+gait_analysis/definition.m @@ -3,7 +3,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_GaitAnalysis_app", AppId="gait_analysis", ... Title="Gait Analysis", DisplayName="Gait Analysis", Family="Gait", ... - AppVersion="2.2.1", Updated="2026-07-30", ... + AppVersion="2.2.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=gait_analysis.projectSpec(), CreateSession=@gait_analysis.createSession, ... Workbench=gait_analysis.workbench.buildLayout(), ... diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/layoutSection.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/layoutSection.m index 205b89701..8a68bff86 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/layoutSection.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/layoutSection.m @@ -3,11 +3,11 @@ %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", ... + SelectionMode="multiple", 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", ... + ChooseLabel="Add images", 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", ... diff --git a/apps/image_measurement/batch_crop/+batch_crop/definition.m b/apps/image_measurement/batch_crop/+batch_crop/definition.m index 6ea2d4343..bde83d194 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/definition.m +++ b/apps/image_measurement/batch_crop/+batch_crop/definition.m @@ -5,7 +5,7 @@ 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.9.2", Updated="2026-07-30", ... + AppVersion="1.9.3", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=batch_crop.projectSpec(), CreateSession=@batch_crop.createSession, ... Workbench=batch_crop.workbench.buildLayout(), PresentWorkbench=@batch_crop.workbench.present, ... diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/fit.m b/apps/image_measurement/curvature/+curvature/+analysisRun/fit.m index 4e1332f20..b5bfd35a2 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/fit.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/fit.m @@ -1,10 +1,10 @@ % App-owned implementation for curvature.analysisRun.fit within the curvature product workflow. function applicationState = fit(applicationState, callbackContext) -%FIT Execute one fingerprinted circle-fit task. +%FIT Measure traced length and curvature in one fingerprinted task. points = applicationState.project.annotations.curvePoints; if size(points, 1) < 3 callbackContext.alert( ... - "At least 3 curve points are required to fit curvature.", ... + "At least 3 curve points are required to measure length and curvature.", ... "Not enough points"); return end @@ -42,7 +42,7 @@ applicationState.session.cache.fitFingerprint = task.fingerprint; applicationState.session.cache.lengthFingerprint = ""; callbackContext.log("info", "curvature.analysisrun.fit.completed", sprintf( ... - "Fit complete: R = %.6g %s, curvature = %.6g %s.", ... - fitResult.R_show, fitResult.unitLen, ... + "Measurement complete: length = %.6g %s, curvature = %.6g %s.", ... + fitResult.curveLength_show, fitResult.curveLengthUnit, ... 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 index df1221087..384088f38 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/layoutSection.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/layoutSection.m @@ -1,7 +1,7 @@ % 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", { ... +%LAYOUTSECTION Declare combined measurement and result export controls. +section = labkit.app.layout.section("fitExport", "Measure + Export", { ... labkit.app.layout.field("densify", ... Label="Densify before circle fit", Kind="logical", Value=true, ... Bind="project.parameters.densify", ... @@ -16,11 +16,8 @@ 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."), ... + "Measure length + curvature", @curvature.analysisRun.fit, ... + Tooltip="Measure traced length, fit a circle, and report curvature using the active scale."), ... labkit.app.layout.button("exportCsv", ... "Export result CSV", @curvature.resultFiles.exportCsv, ... Tooltip="Export fitted radius, curvature, traced length, calibration, and fit evidence as CSV."), ... diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/present.m b/apps/image_measurement/curvature/+curvature/+analysisRun/present.m index 5b04d8d7f..9c9faaabf 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/present.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/present.m @@ -1,13 +1,12 @@ % 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. +%PRESENT Describe measurement 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/+curvePreview/+presentationData/summaryViewData.m b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/summaryViewData.m index b587297f3..b0d7b73c2 100644 --- a/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/summaryViewData.m +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/+presentationData/summaryViewData.m @@ -34,9 +34,9 @@ elseif referenceEditActive summary.details = {'Reference-pixel edit active. Double-click two endpoints or drag existing endpoints; this sets the calibration pixel length only.'}; elseif numel(xPix) >= 3 - summary.details = {'Curve points are ready. Fit curvature or measure curve length.'}; + summary.details = {'Curve points are ready. Measure length and curvature together.'}; elseif numel(xPix) >= 2 - summary.details = {'Curve points are ready. Measure curve length, or add more points before fitting curvature.'}; + summary.details = {'Add at least one more point before measuring length and curvature.'}; else summary.details = {'Load an image and start curve editing.'}; end diff --git a/apps/image_measurement/curvature/+curvature/+curvePreview/draw.m b/apps/image_measurement/curvature/+curvature/+curvePreview/draw.m index 6ecab7990..a60bc438f 100644 --- a/apps/image_measurement/curvature/+curvature/+curvePreview/draw.m +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/draw.m @@ -20,8 +20,10 @@ function draw(axesById, model) axis(ax, "image"); ax.YDir = "reverse"; hold(ax, "on"); -curvature.curvePreview.presentationData.plotStaticCurveAnchors( ... - ax, model.points, model.curve, model.fit, model.showDensePoints); +if model.showStaticCurve + curvature.curvePreview.presentationData.plotStaticCurveAnchors( ... + ax, model.points, model.curve, model.fit, model.showDensePoints); +end drawFit(ax, model.fit); drawScaleBar(ax, model.scaleBar); hold(ax, "off"); diff --git a/apps/image_measurement/curvature/+curvature/+curvePreview/model.m b/apps/image_measurement/curvature/+curvature/+curvePreview/model.m index 6739bf993..7efd462a3 100644 --- a/apps/image_measurement/curvature/+curvature/+curvePreview/model.m +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/model.m @@ -1,11 +1,16 @@ % App-owned implementation for curvature.curvePreview.model within the curvature product workflow. -function value = model(imageData, points, fit, showDensePoints, scaleBar) +function value = model( ... + imageData, points, fit, showDensePoints, scaleBar, curveEditing) %MODEL Build the shared live/export Curvature overlay model. +if nargin < 6 + curveEditing = false; +end value = struct( ... "imageData", imageData, ... "points", points, ... "curve", curvature.curvePreview.visiblePath(points, imageData), ... "fit", fit, ... "showDensePoints", logical(showDensePoints), ... + "showStaticCurve", ~logical(curveEditing), ... "scaleBar", scaleBar); end diff --git a/apps/image_measurement/curvature/+curvature/+curvePreview/present.m b/apps/image_measurement/curvature/+curvature/+curvePreview/present.m index 5ccedf0e0..d52b86c15 100644 --- a/apps/image_measurement/curvature/+curvature/+curvePreview/present.m +++ b/apps/image_measurement/curvature/+curvature/+curvePreview/present.m @@ -4,7 +4,8 @@ editMode, calibration) %PRESENT Prepare the preview renderer and mutually exclusive interactions. model = curvature.curvePreview.model( ... - imageData, points, fit, showDensePoints, scaleBar); + imageData, points, fit, showDensePoints, scaleBar, ... + editMode == "curve"); imageSize = []; if ~isempty(imageData) imageSize = size(imageData); diff --git a/apps/image_measurement/curvature/+curvature/+workbench/buildLayout.m b/apps/image_measurement/curvature/+curvature/+workbench/buildLayout.m index 6abf6b3b8..19d91470e 100644 --- a/apps/image_measurement/curvature/+curvature/+workbench/buildLayout.m +++ b/apps/image_measurement/curvature/+curvature/+workbench/buildLayout.m @@ -39,7 +39,7 @@ "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."]; + "4. Place the final scale bar, then measure length and curvature together."]; layout = labkit.app.layout.workbench(controls, Workspace=workspace, ... UsageTitle="Workflow Notes", Usage=usage); end diff --git a/apps/image_measurement/curvature/+curvature/definition.m b/apps/image_measurement/curvature/+curvature/definition.m index 31993278e..21a65cff6 100644 --- a/apps/image_measurement/curvature/+curvature/definition.m +++ b/apps/image_measurement/curvature/+curvature/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_CurvatureMeasurement_app", AppId="curvature", ... Title="Image Curvature Measurement", DisplayName="Curvature Measurement", ... - Family="Image Measurement", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Image Measurement", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=curvature.projectSpec(), CreateSession=@curvature.createSession, ... Workbench=curvature.workbench.buildLayout(), ... diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/layoutSection.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/layoutSection.m index 11929a747..1f88406c7 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/layoutSection.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/layoutSection.m @@ -4,11 +4,13 @@ files = labkit.app.layout.fileList("thermalFiles", ... Label="FLIR files", ... Filters=labkit.thermal.fileDialogFilter("IncludeAll", true), ... - SelectionMode="single", Bind="project.inputs.sources", ... + SelectionMode="multiple", 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", ... + PathFilter=@flir_thermal.thermalSources.matchesRadiometricFiles, ... + PathFilterDescription="radiometric FLIR image", ... + ChooseLabel="Add FLIR files", ... 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", ... diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/matchesRadiometricFiles.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/matchesRadiometricFiles.m new file mode 100644 index 000000000..f68225d07 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/matchesRadiometricFiles.m @@ -0,0 +1,11 @@ +% Expected caller: FLIR Thermal fileList PathFilter. Candidate paths are +% inspected through the thermal facade; unreadable or non-radiometric files +% are rejected before portable source records are created. +function accepted = matchesRadiometricFiles(paths) +paths = reshape(string(paths), 1, []); +accepted = false(size(paths)); +for index = 1:numel(paths) + inspection = labkit.thermal.inspectFile(paths(index)); + accepted(index) = inspection.isThermal; +end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/definition.m b/apps/image_measurement/flir_thermal/+flir_thermal/definition.m index 6d8732bfe..f4d11ef81 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/definition.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/definition.m @@ -6,7 +6,7 @@ Entrypoint="labkit_FLIRThermal_app", ... AppId="flir_thermal", ... Title="FLIR Thermal Postprocess", DisplayName="FLIR Thermal", ... - Family="Image Measurement", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Image Measurement", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements( ... "app", ">=2 <3", "image", ">=2.0 <3", ... "thermal", ">=1.1 <2"), ... diff --git a/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m b/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m index 8c998583d..3c774c21d 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m @@ -6,7 +6,7 @@ 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", ... + ChooseLabel="Add images", 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", ... diff --git a/apps/image_measurement/focus_stack/+focus_stack/definition.m b/apps/image_measurement/focus_stack/+focus_stack/definition.m index 19ff52332..6ad6fc080 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/definition.m +++ b/apps/image_measurement/focus_stack/+focus_stack/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_FocusStack_app", AppId="focus_stack", ... Title="Microscope Focus Stack Fusion", DisplayName="Focus Stack", ... - Family="Image Measurement", AppVersion="1.7.1", Updated="2026-07-30", ... + Family="Image Measurement", AppVersion="1.7.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=focus_stack.projectSpec(), CreateSession=@focus_stack.createSession, ... Workbench=focus_stack.workbench.buildLayout(), ... diff --git a/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m b/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m index 080f7b925..2b188ef44 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m @@ -9,7 +9,7 @@ SelectionBind="session.selection.sourceImages", ... OnSelectionChanged=@image_enhance.sourceLibrary.selectPreview, ... SourceRole="source-image", SourceIdPrefix="image", ... - ChooseLabel="Add images or folder", FolderLabel="Add folder", ... + ChooseLabel="Add images", 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", ... diff --git a/apps/image_measurement/image_enhance/+image_enhance/definition.m b/apps/image_measurement/image_enhance/+image_enhance/definition.m index c431f811f..01e5b21af 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/definition.m +++ b/apps/image_measurement/image_enhance/+image_enhance/definition.m @@ -4,7 +4,7 @@ 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.8.1", Updated="2026-07-30", ... + Family="Image Measurement", AppVersion="1.8.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=image_enhance.projectSpec(), CreateSession=@image_enhance.createSession, ... Workbench=image_enhance.workbench.buildLayout(), PresentWorkbench=@image_enhance.workbench.present, ... diff --git a/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m b/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m index 957e5e15e..5d86029d3 100644 --- a/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m +++ b/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m @@ -19,7 +19,7 @@ SelectionBind="session.selection.sourceImages", ... OnSelectionChanged=@image_match.sourceFiles.selectPreview, ... SourceRole="source-image", SourceIdPrefix="image", ... - ChooseLabel="Add images or folder", FolderLabel="Add folder", ... + ChooseLabel="Add images", 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", ... diff --git a/apps/image_measurement/image_match/+image_match/definition.m b/apps/image_measurement/image_match/+image_match/definition.m index 9c001b27f..d4f241c48 100644 --- a/apps/image_measurement/image_match/+image_match/definition.m +++ b/apps/image_measurement/image_match/+image_match/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_ImageMatch_app", AppId="image_match", ... Title="Paper Image Match", DisplayName="Image Match", ... - Family="Image Measurement", AppVersion="1.8.1", Updated="2026-07-30", ... + Family="Image Measurement", AppVersion="1.8.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=image_match.projectSpec(), CreateSession=@image_match.createSession, ... Workbench=image_match.workbench.buildLayout(), ... diff --git a/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m b/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m index 5bf6c158d..7baae630b 100644 --- a/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m +++ b/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m @@ -17,6 +17,7 @@ return end try + previousFrames = state.project.annotations.frames; resource = context.getResource("document", "video"); if ~isstruct(resource) || ~isscalar(resource) || ... ~isfield(resource, "path") || resource.path ~= paths(1) @@ -50,6 +51,9 @@ state.session.workflow.scaleReferenceEditing = false; state.session.view.scaleBar = []; state = video_marker.resultFiles.clearExportState(state); +if ~isequaln(previousFrames, frames) + state = video_marker.sessionControl.saveAutosave(state, context); +end if report.predictedFrames > 0 context.log("info", "video_marker.framenavigation.changeframe.predicted", ... "Predicted " + string(report.predictedFrames) + ... diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m index 0043f2fc9..935a46b64 100644 --- a/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m @@ -17,7 +17,7 @@ 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); +state = video_marker.markerEditing.setPoints(state, points, context); context.log("info", "video_marker.markerediting.changepoints.status", ... "Frame " + string(frame) + " points: " + ... string(size(points, 1)) + " / " + string(total) + "."); diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m index 35c1c8617..5be801744 100644 --- a/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m @@ -5,7 +5,8 @@ return end frame = state.session.cache.frameIndex; -state = video_marker.markerEditing.setPoints(state, zeros(0, 2)); +state = video_marker.markerEditing.setPoints( ... + state, zeros(0, 2), context); context.log("info", "video_marker.markerediting.clear.status", ... "Cleared frame " + string(frame) + " points."); 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 index d8aee39f5..7aa6c4765 100644 --- a/apps/image_measurement/video_marker/+video_marker/+markerEditing/setPoints.m +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/setPoints.m @@ -1,6 +1,6 @@ % 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. +function applicationState = setPoints(applicationState, points, callbackContext) +%SETPOINTS Store one frame's ordered points and update its autosave. total = numel(applicationState.project.annotations.skeleton.pointIds); status = "draft"; if isempty(points) @@ -15,4 +15,6 @@ "manual", ones(size(points, 1), 1)); applicationState = ... video_marker.resultFiles.clearExportState(applicationState); +applicationState = video_marker.sessionControl.saveAutosave( ... + applicationState, callbackContext); 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 index 8db588dc8..22d76ac11 100644 --- a/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m @@ -7,7 +7,7 @@ end points(end, :) = []; frame = state.session.cache.frameIndex; -state = video_marker.markerEditing.setPoints(state, points); +state = video_marker.markerEditing.setPoints(state, points, context); context.log("info", "video_marker.markerediting.undo.status", ... "Undid the last point on frame " + string(frame) + "."); 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 index f165ff1ad..2a3975de1 100644 --- a/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m +++ b/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m @@ -47,6 +47,7 @@ state.project.parameters.coordinateEndFrame = ... max(1, payload.videoInfo.frameCount); state = video_marker.resultFiles.clearExportState(state); +state = video_marker.sessionControl.saveAutosave(state, context); context.log("info", "video_marker.resultfiles.importmarkers.completed", ... "Imported the marker CSV."); end diff --git a/apps/image_measurement/video_marker/+video_marker/definition.m b/apps/image_measurement/video_marker/+video_marker/definition.m index 12caa3a89..5689d14ca 100644 --- a/apps/image_measurement/video_marker/+video_marker/definition.m +++ b/apps/image_measurement/video_marker/+video_marker/definition.m @@ -5,8 +5,8 @@ app = labkit.app.Definition( ... Entrypoint="labkit_VideoMarker_app", AppId="video_marker", ... Title="Video Marker", DisplayName="Video Marker", ... - Family="Image Measurement", AppVersion="1.7.1", ... - Updated="2026-07-30", ... + Family="Image Measurement", AppVersion="1.7.2", ... + Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=video_marker.projectSpec(), ... CreateSession=@video_marker.createSession, ... diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/layoutSection.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/layoutSection.m index 90d9fb129..9e09c299c 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/layoutSection.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/layoutSection.m @@ -3,8 +3,8 @@ 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", ... + SelectionMode="multiple", ShowStatus=false, ... + ChooseLabel="Add FIG files", ... 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", ... diff --git a/apps/labkit_core/figure_studio/+figure_studio/definition.m b/apps/labkit_core/figure_studio/+figure_studio/definition.m index 202071520..79bc77c4c 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/definition.m +++ b/apps/labkit_core/figure_studio/+figure_studio/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_FigureStudio_app", AppId="figure_studio", ... Title="Figure Studio", Family="LabKit Core", ... - AppVersion="0.7.2", Updated="2026-07-30", ... + AppVersion="0.7.3", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=figure_studio.projectSpec(), ... CreateSession=@figure_studio.createSession, ... diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterSection.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterSection.m index c5b12a6d7..a56c79d8d 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterSection.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterSection.m @@ -5,7 +5,7 @@ Label="RHS filter files", ... Filters=["*.rhs", "Intan RHS files"], ... SelectionMode="multiple", ... - ChooseLabel="Add RHS files or folder", ... + ChooseLabel="Add RHS files", ... ChooseTooltip="Add Intan RHS recordings to evaluate against the current reusable file-filter rules.", ... FolderLabel="Add folder", ... RecursiveFolderLabel="Add folder tree", ... diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m b/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m index 104fb60ee..729e5f2bc 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m @@ -4,7 +4,7 @@ function app = definition() app = labkit.app.Definition(Entrypoint="labkit_RHSPreview_app", ... AppId="rhs_preview", Title="RHS Preview", DisplayName="RHS Preview", ... - Family="Neurophysiology", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Neurophysiology", AppVersion="1.6.2", Updated="2026-08-03", ... Requirements=labkit.contract.requirements("app", ">=2 <3", "rhs", ">=1.0 <2"), ... ProjectSchema=rhs_preview.projectSpec(), CreateSession=@rhs_preview.createSession, ... Workbench=rhs_preview.workbench.buildLayout(), PresentWorkbench=@rhs_preview.workbench.present, ... diff --git a/docs/apps/README.md b/docs/apps/README.md index 77f32ad9c..4167bf172 100644 --- a/docs/apps/README.md +++ b/docs/apps/README.md @@ -95,8 +95,9 @@ choices, workflow-specific defaults, result schemas, and exports. See the [App Framework](../framework/README.md) for behavior shared across apps. Every App opens as a clean project. Use **Tools > Diagnostics** to inspect the -current session history, enable future trace capture, or export a diagnostic -bundle after a problem. Apps with declared sample generation expose **Tools > +current session history or export a diagnostic bundle after a problem. Manual +TRACE capture is controlled inside the Session Log window. Apps with declared +sample generation expose **Tools > Developer Tools > Generate Synthetic Inputs...**; generation writes anonymous inputs without loading them or changing the open project. The [runtime guide](../framework/guides/runtime.md) defines these shared contracts. diff --git a/docs/apps/dic/dic-preprocess/README.md b/docs/apps/dic/dic-preprocess/README.md index feb4c3701..941ca7846 100644 --- a/docs/apps/dic/dic-preprocess/README.md +++ b/docs/apps/dic/dic-preprocess/README.md @@ -45,26 +45,29 @@ derived working pair and replays no edits. | Mode | Display | | --- | --- | | Current pair | current reference above the current moving image | -| Current moving image | moving image in the main comparison view | | False-color overlay | red/green registration comparison of the current pair | | Original pair | source images before applied edits | | ROI mask | current binary mask over the image domain | Changing preview mode does not change project data. Point placement, point -dragging, crop editing, mask editing, and applying an operation preserve the -current axes zoom. Use the plot **Fit** action when a full-image view is wanted. +dragging, crop editing, mask editing, and alignment preserve the current axes +zoom. Applying a crop fits both axes to the new pixel domain so the removed +image area does not remain as white plot margins. Use the plot **Fit** action +when a full-image view is otherwise wanted. ## Manual Point Matching -Press **Start point matching**. Click a feature in the reference image, then -click the same feature in the moving image. Repeat this reference/moving order -for at least two complete pairs. Numbered markers show correspondence and the -preview subtitle states which image expects the next point. +Press **Start point matching**. The preview switches to the current reference +and current moving images. Click a feature in the reference image, then click +the same feature in the moving image. Repeat this reference/moving order for at +least two complete pairs. Numbered markers show correspondence and the preview +subtitle states which image expects the next point. -Drag an existing marker to refine it. **Undo point pair** removes the newest -complete pair. **Cancel point matching** discards the pending point set without -changing the current images. **Apply point alignment** estimates and applies a -rigid two-dimensional transform. +Markers are displayed as numbered points without connecting lines. Drag an +existing marker to refine it. **Undo point pair** removes the newest complete +pair. **Cancel point matching** discards the pending point set without changing +the current images. **Apply point alignment** estimates and applies a rigid +two-dimensional transform. Manual alignment uses all pairs in a least-squares rotation-and-translation fit. It does not estimate scale or shear and prevents a reflected solution. @@ -73,19 +76,29 @@ clustered points provide weak rotational leverage. ## Automatic Alignment -**Auto align current pair** runs the app-owned base-MATLAB registration path. -It returns the same aligned image and transform fields as manual alignment. -Automatic alignment is a starting estimate, not a guarantee of DIC-quality +**Auto align current pair** runs the app-owned base-MATLAB rigid-registration +path. It searches the complete rotation circle in global, one-degree, and +quarter-degree stages; translation candidates may span up to 75% of either +preview dimension. It estimates translation with anti-aliased, zero-padded +amplitude-weighted phase correlation and subpixel peak refinement, then ranks candidate angles using robust, +overlap-aware oriented structure on a finer preview. Its diagnostic event +records the accepted angle, translation, overlap, structural score, score +margin, and translation-peak margin. It returns the same +aligned image and rigid-transform fields as manual alignment. Automatic +alignment remains a starting estimate, not a guarantee of DIC-quality correspondence. Always inspect the false-color overlay and prefer manual points -when the image has repeated texture, large occlusion, or weak contrast. +when the image has repeated texture, extremely small overlap, scale change, +deformation, large occlusion, or weak contrast. ## Crop ROI **Start/reset crop ROI** creates a square rectangle constrained to the current -reference image. Drag the rectangle to move it and use its resize handles to -change its size. **Apply ROI crop** uses exactly the same integer image-domain -rectangle on the reference and aligned moving image. **Cancel ROI** exits the -editor without adding a crop step. +reference image and draws the same rectangle on the moving preview for direct +comparison. Drag the reference rectangle to move it and use its resize handles +to change its size. **Apply ROI crop** uses exactly the same integer +image-domain rectangle on the reference and aligned moving image, then fits +both axes to the cropped domain. **Cancel ROI** exits the editor without adding +a crop step. Applied crops change the coordinate domain for later operations. Undo the crop before reusing point coordinates defined on the larger image. diff --git a/docs/apps/electrochemistry/README.md b/docs/apps/electrochemistry/README.md index a088d752b..e41c97136 100644 --- a/docs/apps/electrochemistry/README.md +++ b/docs/apps/electrochemistry/README.md @@ -14,13 +14,7 @@ the owning app. Source DTA files are never modified. | Impedance inspection and export | [EIS](eis/README.md) | `ZCURVE` | configurable Nyquist/Bode-style overlay | | Steady pulse resistance | [VT Resistance](vt-resistance/README.md) | biphasic chrono transient | cathodic, anodic, and mean resistance | -## Shared File Behavior - -File controls accept one or more `.DTA` files from one folder in a single -selection. A canceled chooser leaves the current project unchanged. CIC, CSC, -and VT Resistance use the selected row as the active preview while retaining -the loaded source list for batch export. Invalid items are reported per file; -one failed item does not silently replace another result. +## Shared DTA Contract The DTA library returns structured items, curve tables, headers, units, metadata, parser messages, and status. Apps use exact required columns for diff --git a/docs/apps/electrochemistry/chrono-overlay/README.md b/docs/apps/electrochemistry/chrono-overlay/README.md index 0de6ea902..97b6a87e0 100644 --- a/docs/apps/electrochemistry/chrono-overlay/README.md +++ b/docs/apps/electrochemistry/chrono-overlay/README.md @@ -14,11 +14,9 @@ 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. Saved projects preserve the ordered file list and -reopen it through portable source references. +Inputs are one or more `.DTA` files containing chrono data. Source order +controls curve order and legend labels. Saved projects preserve that order and +reopen sources through portable references. ## Basic Workflow diff --git a/docs/apps/electrochemistry/cic/README.md b/docs/apps/electrochemistry/cic/README.md index 45071683d..dc82555e3 100644 --- a/docs/apps/electrochemistry/cic/README.md +++ b/docs/apps/electrochemistry/cic/README.md @@ -14,8 +14,9 @@ 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 +The Files list retains chrono `.DTA` transients and omits other Gamry +experiment kinds before session reconstruction. The selected row is decoded +for immediate preview; batch calculation uses the same analysis settings when results are exported. This avoids repeatedly decoding every large file while the user is only switching previews. Saved projects preserve file order and portable source identity through removal, later additions, and reopen. diff --git a/docs/apps/electrochemistry/csc/README.md b/docs/apps/electrochemistry/csc/README.md index c39c3154c..0eb6eccc6 100644 --- a/docs/apps/electrochemistry/csc/README.md +++ b/docs/apps/electrochemistry/csc/README.md @@ -15,7 +15,8 @@ labkit_CSC_app ## Inputs And Selection -Add one or more CV/CT `.DTA` files. The selected file determines the current +The Files list retains CV/CT `.DTA` sources and omits other Gamry experiment +kinds before session reconstruction. 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. Saved projects preserve the successfully diff --git a/docs/apps/electrochemistry/eis/README.md b/docs/apps/electrochemistry/eis/README.md index 7e45c6f02..a36e30433 100644 --- a/docs/apps/electrochemistry/eis/README.md +++ b/docs/apps/electrochemistry/eis/README.md @@ -12,8 +12,9 @@ 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 +The Files list retains `.DTA` sources containing a readable EIS `ZCURVE`. +Other Gamry experiment kinds and files without the required curve are omitted +before plotting. The successfully decoded source list and its order are preserved in project state through portable references. diff --git a/docs/apps/electrochemistry/vt-resistance/README.md b/docs/apps/electrochemistry/vt-resistance/README.md index 227e08216..60da0599b 100644 --- a/docs/apps/electrochemistry/vt-resistance/README.md +++ b/docs/apps/electrochemistry/vt-resistance/README.md @@ -14,8 +14,9 @@ labkit_VTResistance_app ## Inputs And Batch Behavior -Add one or more chrono `.DTA` files. The transient session decodes and analyzes -the registered batch so shared setting changes update every result together. +The Files list retains chrono `.DTA` transients and omits other Gamry +experiment kinds before session reconstruction. 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. Saved projects preserve source order and portable identity through removal, later additions, and reopen. diff --git a/docs/apps/gait/gait-analysis/README.md b/docs/apps/gait/gait-analysis/README.md index 1dce2570e..c1b611cae 100644 --- a/docs/apps/gait/gait-analysis/README.md +++ b/docs/apps/gait/gait-analysis/README.md @@ -71,11 +71,18 @@ names; `iliac_crest` is accepted for the iliac role. ### 2. Analyze And Review One Step Choose **Run analysis**. Select a row in the step table or use **Previous -step** and **Next step**. The workspace then shows only that step: +step** and **Next step**. The 2-by-2 workspace shows: 1. all skeleton poses from lift-off through landing, with point trajectories; 2. hip, knee, and ankle angle traces; -3. iliac-hip, hip-knee, knee-ankle, and ankle-foot length traces. +3. iliac-hip, hip-knee, knee-ankle, and ankle-foot length traces; +4. the complete recording's overlaid skeletons and all named point + trajectories as persistent context. + +Both spatial plots preserve equal X/Y data units so gait shape is not stretched +by the available panel geometry. Loading a new source, running analysis, or +changing the selected step fits the new data once; later redraws preserve the +user's zoom. The skeleton plot annotates swing duration, step length, iliac/hip/knee/ankle/ foot translations, and each joint's minimum, maximum, and range of motion. This diff --git a/docs/apps/image-measurement/batch-crop/README.md b/docs/apps/image-measurement/batch-crop/README.md index e17e29f85..b45105865 100644 --- a/docs/apps/image-measurement/batch-crop/README.md +++ b/docs/apps/image-measurement/batch-crop/README.md @@ -12,8 +12,8 @@ labkit_BatchImageCrop_app ## Inputs -Use **Add images or folder** to load supported image files. A folder selection -loads supported files from that folder. Each list row stores its own crop center, +Use **Add images** for selected files, **Add folder** for one directory, or +**Add folder tree** for nested sources. Each list row stores its own crop center, rotation, padding, and optional scale calibration. **Duplicate image** creates another task for the same source so multiple ROIs can be exported without loading duplicate files. Duplicate tasks remain aligned with their source and diff --git a/docs/apps/image-measurement/curvature/README.md b/docs/apps/image-measurement/curvature/README.md index 9f20304ab..c6fcb1e4a 100644 --- a/docs/apps/image-measurement/curvature/README.md +++ b/docs/apps/image-measurement/curvature/README.md @@ -21,8 +21,8 @@ The **Files + Analysis** tab contains the complete workflow: 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. +6. Choose the densification settings and use **Measure length + curvature** + to produce both results together. 7. Export the result CSV and overlay PNG. The edit buttons change to **Finish curve edit** or **Finish reference edit** @@ -44,7 +44,9 @@ upgraded on load without changing the curve, calibration, or result meaning. 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. +required for the combined measurement. While curve editing is active, only the +managed editable curve is drawn; the inactive static curve returns after the +edit is finished. ## Fit Parameters And Semantics diff --git a/docs/apps/image-measurement/flir-thermal/README.md b/docs/apps/image-measurement/flir-thermal/README.md index ac8f97e08..f09193b68 100644 --- a/docs/apps/image-measurement/flir-thermal/README.md +++ b/docs/apps/image-measurement/flir-thermal/README.md @@ -15,10 +15,12 @@ labkit_FLIRThermal_app ## Inputs And Navigation -Use **Add FLIR files or folder** to load one or more radiometric images. The -selected row is the current image; Previous/Next change selection without -discarding per-image display range or measurement annotations. Source files -are read-only. +Use **Add FLIR files** to select one or more radiometric images. Use the +separate folder buttons for one folder or a recursive folder tree. Candidates +that are not readable radiometric FLIR images are omitted with an aggregate +notice. The selected row is the current image; Previous/Next change selection +without discarding per-image display range or measurement annotations. Source +files are read-only. ## Basic Workflow diff --git a/docs/apps/image-measurement/focus-stack/README.md b/docs/apps/image-measurement/focus-stack/README.md index bf08e8622..bf88b41a1 100644 --- a/docs/apps/image-measurement/focus-stack/README.md +++ b/docs/apps/image-measurement/focus-stack/README.md @@ -13,8 +13,9 @@ labkit_FocusStack_app ## Inputs -Add selected image files or choose a folder. Remove blurred, displaced, or -otherwise invalid frames before running. The first image defines working +Choose focal planes directly, or discover them from a flat or nested image +directory. Remove blurred, displaced, or otherwise invalid frames before running. +The first image defines working geometry; differently sized inputs are resized and the result records how many images required resizing. diff --git a/docs/apps/image-measurement/image-enhance/README.md b/docs/apps/image-measurement/image-enhance/README.md index cd17ced2a..22669d74b 100644 --- a/docs/apps/image-measurement/image-enhance/README.md +++ b/docs/apps/image-measurement/image-enhance/README.md @@ -13,8 +13,9 @@ labkit_ImageEnhance_app ## Inputs And Batch Mode -Add supported image files or a folder. In **Batch shared processing** mode, one -shared step history is applied to every source. When batch mode is off, each +The batch accepts selected image files; directory actions can collect a flat +or recursive image set. In **Batch shared processing** mode, one shared step history is applied +to every source. When batch mode is off, each image keeps a separate history and optional white ROI. Selecting another image updates the preview without recalculating unrelated files. diff --git a/docs/apps/image-measurement/image-match/README.md b/docs/apps/image-measurement/image-match/README.md index 0f1076f2d..9882b5b40 100644 --- a/docs/apps/image-measurement/image-match/README.md +++ b/docs/apps/image-measurement/image-match/README.md @@ -14,8 +14,9 @@ labkit_ImageMatch_app ## Inputs -Choose one reference image and add source images or a source folder. The -reference supplies appearance statistics and is not exported as a matched +Choose one reference image. Sources may be selected individually or discovered +from a flat or nested directory; the reference remains a separate single-file +role. It supplies appearance statistics and is not exported as a matched source. All images are normalized to RGB double data in `[0,1]`; output retains the source height and width. diff --git a/docs/apps/image-measurement/video-marker/README.md b/docs/apps/image-measurement/video-marker/README.md index ec504f577..78e39263b 100644 --- a/docs/apps/image-measurement/video-marker/README.md +++ b/docs/apps/image-measurement/video-marker/README.md @@ -2,7 +2,7 @@ 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. +portable project with a source-adjacent autosave copy. ## Requirements And Launch @@ -22,8 +22,10 @@ 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/