diff --git a/+dat/delParamProfile.m b/+dat/delParamProfile.m index ee46eeb6..324f9b0c 100644 --- a/+dat/delParamProfile.m +++ b/+dat/delParamProfile.m @@ -15,7 +15,7 @@ function delParamProfile(expType, profileName) %remove the params with the field named by profile profiles = rmfield(profiles, profileName); %wrap in a struct for saving -set.(expType) = profiles; %#ok +set.(expType) = profiles; %save the updated set of profiles to each repos %where files exist already, append diff --git a/+dat/expLogRequest.m b/+dat/expLogRequest.m index 02d7308e..45efbc15 100644 --- a/+dat/expLogRequest.m +++ b/+dat/expLogRequest.m @@ -6,7 +6,7 @@ if nargin < 2 args = struct; -elseif nargin == 2 && isstruct(varargin{1}); +elseif nargin == 2 && isstruct(varargin{1}) else args = varargin2struct(varargin{:}); end diff --git a/+dat/newExp.m b/+dat/newExp.m index 0ff092b8..9cec8849 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -1,6 +1,6 @@ function [expRef, expSeq] = newExp(subject, expDate, expParams) %DAT.NEWEXP Create a new unique experiment in the database -% [ref, seq, url] = DAT.NEWEXP(subject, expDate, expParams[, AlyxInstance]) +% [ref, seq] = DAT.NEWEXP(subject, expDate, expParams) % Create a new experiment by creating the relevant folder tree in the % local and main data repositories in the following format: % diff --git a/+dat/reposPath.m b/+dat/reposPath.m index e2991ebc..ade5d65a 100644 --- a/+dat/reposPath.m +++ b/+dat/reposPath.m @@ -66,7 +66,7 @@ case {'local' 'l'} p = paths.localRepository; otherwise - error('"%s" is not a recognised repository location.', location{1}); + error('"%s" is not a recognised repository location.', location); end end \ No newline at end of file diff --git a/+dat/saveParamProfile.m b/+dat/saveParamProfile.m index e76ffdf9..8a08f3bb 100644 --- a/+dat/saveParamProfile.m +++ b/+dat/saveParamProfile.m @@ -17,7 +17,7 @@ function saveParamProfile(expType, profileName, params) profiles.(profileName) = params; %wrap in a struct for saving set = struct; -set.(expType) = profiles; %#ok +set.(expType) = profiles; %save the updated set of profiles to each repos %where files exist already, append diff --git a/+dat/updateLogEntry.m b/+dat/updateLogEntry.m index 0882ba2a..47678185 100644 --- a/+dat/updateLogEntry.m +++ b/+dat/updateLogEntry.m @@ -13,7 +13,11 @@ function updateLogEntry(subject, id, newEntry) if isfield(newEntry, 'AlyxInstance') % Update session narrative on Alyx if ~isempty(newEntry.comments) && ~strcmp(subject, 'default') - newEntry.comments = newEntry.AlyxInstance.updateNarrative(newEntry.comments); + try + newEntry.comments = newEntry.AlyxInstance.updateNarrative(newEntry.comments); + catch + warning('Alyx:updateNarrative:UploadFailed', 'Failed to update Alyx session narrative'); + end end newEntry = rmfield(newEntry, 'AlyxInstance'); end diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index aade462f..cfd23939 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -76,7 +76,8 @@ 'Toolbar', 'none',... 'NumberTitle', 'off',... 'Units', 'normalized',... - 'OuterPosition', [0.1 0.1 0.4 .4]); + 'OuterPosition', [0.1 0.1 0.4 .4],... + 'DeleteFcn', @(~,~)obj.delete); parent = uiextras.VBox('Parent', f,... 'Visible', 'on'); % subject selector @@ -209,20 +210,27 @@ function delete(obj) delete(obj.LoginTimer) % ... delete it... obj.LoginTimer = []; % ... and remove it end + if ~isempty(obj.WeightTimer) && isvalid(obj.WeightTimer) + stop(obj.WeightTimer) % Stop the timer... + delete(obj.WeightTimer) % ... delete it... + obj.WeightTimer = []; % ... and remove it + end end - function login(obj) + function login(obj, varargin) % Used both to log in and out of Alyx. Logging means to % generate an Alyx token with which to send/request data. % Logging out does not cause the token to expire, instead the % token is simply deleted from this object. + % Temporarily disable the Subject Selector + obj.NewExpSubject.UIControl.Enable = 'off'; % Reset headless flag in case user wishes to retry connection obj.AlyxInstance.Headless = false; % Are we logging in or out? if ~obj.AlyxInstance.IsLoggedIn % logging in % attempt login - obj.AlyxInstance = obj.AlyxInstance.login(); % returns an instance if success, empty if you cancel + obj.AlyxInstance = obj.AlyxInstance.login(varargin{:}); % returns an instance if success, empty if you cancel if obj.AlyxInstance.IsLoggedIn % successful % Start log in timer, to automatically log out after 30 % minutes of 'inactivity' (defined as not calling @@ -282,6 +290,8 @@ function login(obj) notify(obj, 'Disconnected'); % Notify listeners of logout obj.log('Logged out of Alyx'); end + % Reable the Subject Selector + obj.NewExpSubject.UIControl.Enable = 'on'; obj.dispWaterReq() end @@ -314,7 +324,7 @@ function giveFutureWater(obj) 'enter space-separated numbers, i.e. \n',... '[tomorrow, day after that, day after that.. etc] \n\n',... 'Enter "0" to skip a day\nEnter "-1" to indicate training for that day\n']); - amtStr = inputdlg(prompt,'Future Amounts', [1 50]); + amtStr = newid(prompt,'Future Amounts', [1 50]); if isempty(amtStr)||~obj.AlyxInstance.IsLoggedIn return % user pressed 'Close' or 'x' end @@ -346,92 +356,6 @@ function giveFutureWater(obj) end end - function dispWaterReq(obj, src, ~) - % Display the amount of water required by the selected subject - % for it to reach its minimum requirement. This function is - % also used to update the selected subject, for example it is - % this funtion to use as a callback to subject dropdown - % listeners - ai = obj.AlyxInstance; - % Set the selected subject if it is an input - if nargin>1; obj.Subject = src.Selected; end - if ~ai.IsLoggedIn - set(obj.WaterRequiredText, 'ForegroundColor', 'black',... - 'String', 'Log in to see water requirements'); - return - end - % Refresh the timer as the user isn't inactive - stop(obj.LoginTimer); start(obj.LoginTimer) - try - s = ai.getData('water-restricted-subjects'); % struct with data about restricted subjects - idx = strcmp(obj.Subject, {s.nickname}); - if ~any(idx) % Subject not on water restriction - set(obj.WaterRequiredText, 'ForegroundColor', 'black',... - 'String', sprintf('Subject %s not on water restriction', obj.Subject)); - else - % Get information on weight and water given - endpnt = sprintf('water-requirement/%s?start_date=%s&end_date=%s',... - obj.Subject, datestr(now, 'yyyy-mm-dd'),datestr(now, 'yyyy-mm-dd')); - wr = ai.getData(endpnt); % Get today's weight and water record - if ~isempty(wr.records) - record = wr.records(end); - else - record = struct(); - end - weight = iff(isempty(record.weighing_at), NaN, record.weight); % Get today's measured weight - water = getOr(record, 'given_water_liquid', 0); % Get total water given - gel = getOr(record, 'given_water_hydrogel', 0); % Get total gel given - expected_weight = getOr(record, 'expected_weight', NaN); - % Set colour based on weight percentage - weight_pct = (weight-wr.implant_weight)/(expected_weight-wr.implant_weight); - if weight_pct < 0.8 % Mouse below 80% original weight - colour = [0.91, 0.41, 0.17]; % Orange - weight_pct = '< 80%'; - elseif weight_pct < 0.7 % Mouse below 70% original weight - colour = 'red'; - weight_pct = '< 70%'; - else - colour = 'black'; % Mouse above 80% or no weight measured today - weight_pct = '> 80%'; - end - % Round up water remaining to the near 0.01 - remainder = obj.round(s(idx).remaining_water, 'up'); - % Set text - set(obj.WaterRequiredText, 'ForegroundColor', colour, 'String', ... - sprintf(['Subject %s requires %.2f of %.2f today\n\t '... - 'Weight today: %.2f (%s) Water today: %.2f'], obj.Subject, ... - remainder, obj.round(s(idx).expected_water, 'up'), weight, ... - weight_pct, obj.round(sum([water gel]), 'down'))); - % Set WaterRemaining attribute for changeWaterText callback - obj.WaterRemaining = remainder; - end - catch me - d = me.message; %FIXME: JSON no longer returned - if isfield(d, 'detail') && strcmp(d.detail, 'Not found.') - set(obj.WaterRequiredText, 'ForegroundColor', 'black',... - 'String', sprintf('Subject %s not found in alyx', obj.Subject)); - else - rethrow(me) - end - end - end - - function changeWaterText(obj, src, ~) - % Update the panel text to show the amount of water still - % required for the subject to reach its minimum requirement. - % This text is updated before the value in the water text box - % has been posted to Alyx. For example if the user is unsure - % how much gel over the minimum they have weighed out, pressing - % return will display this without posting to Alyx - % - % See also DISPWATERREQ, GIVEWATER - if obj.AlyxInstance.IsLoggedIn && ~isempty(obj.WaterRemaining) - rem = obj.WaterRemaining; - curr = str2double(src.String); - set(obj.WaterRemainingText, 'String', sprintf('(%.2f)', rem-curr)); - end - end - function recordWeight(obj, weight, subject) % Post a subject's weight to Alyx. If no inputs are provided, % create an input dialog for the user to input a weight. If no @@ -446,10 +370,10 @@ function recordWeight(obj, weight, subject) dlgTitle = 'Manual weight logging'; numLines = 1; defaultAns = {'',''}; - weight = inputdlg(prompt, dlgTitle, numLines, defaultAns); + weight = newid(prompt, dlgTitle, numLines, defaultAns); if isempty(weight); return; end end - % inputdlg returns weight as a cell, otherwise it may now be + % newid returns weight as a cell, otherwise it may now be weight = ensureCell(weight); % ensure it's a cell % convert to double if weight is a string weight = iff(ischar(weight{1}), str2double(weight{1}), weight{1}); @@ -467,16 +391,22 @@ function recordWeight(obj, weight, subject) obj.dispWaterReq end - function launchSessionURL(obj) + function [stat, url] = launchSessionURL(obj) % Launch the Webpage for the current base session in the % default Web browser. If no session exists for today's date, % a new base session is created accordingly. % + % Outputs: + % stat (double) - returns the status of the operation: + % 0 if successful, 1 or 2 if unsuccessful. + % url (char) - the url for the subject page + % % See also LAUNCHSUBJECTURL ai = obj.AlyxInstance; % determine whether there is a session for this subj and date thisDate = ai.datestr(now); sessions = ai.getData(['sessions?type=Base&subject=' obj.Subject]); + stat = -1; url = []; % If the date of this latest base session is not the same date % as today, then create a new one for today @@ -496,7 +426,8 @@ function launchSessionURL(obj) d.narrative = 'auto-generated session'; d.start_time = thisDate; d.type = 'Base'; - + d.users = {obj.AlyxInstance.User}; + thisSess = ai.postData('sessions', d); if ~isfield(thisSess,'subject') % fail warning('Submitted base session did not return appropriate values'); @@ -508,11 +439,11 @@ function launchSessionURL(obj) else % success obj.log(['Created new base session in Alyx for ' obj.Subject]); end - case 'No' + otherwise return end else - thisSess = sessions{end}; + thisSess = sessions(end); end % parse the uuid from the url in the session object @@ -520,19 +451,29 @@ function launchSessionURL(obj) uuid = u(find(u=='/', 1, 'last')+1:end); % make the admin url - adminURL = fullfile(ai.BaseURL, 'admin', 'actions', 'session', uuid, 'change'); + url = [ai.BaseURL, '/admin/actions/session/', uuid, '/change']; % launch the website - web(adminURL, '-browser'); + stat = web(url, '-browser'); end - function launchSubjectURL(obj) + function [stat, url] = launchSubjectURL(obj) + % LAUNCHSUBJECTURL Launch the Webpage for the current subject + % Launches Web page in the default Web browser. Note that the + % logged in state of the AlyxPanel is independent of the + % browser cookies, therefore you may need to log in to see the + % subject page. + % + % Outputs: + % stat (double) - returns the status of the operation: + % 0 if successful, 1 or 2 if unsuccessful. + % url (char) - the url for the subject page + % + % See also LAUNCHSESSIONURL ai = obj.AlyxInstance; - if ai.IsLoggedIn - s = ai.getData(ai.makeEndpoint(['subjects/' obj.Subject])); - subjURL = fullfile(ai.BaseURL, 'admin', 'subjects', 'subject', s.id, 'change'); % this is wrong - need uuid - web(subjURL, '-browser'); - end + s = ai.getData(ai.makeEndpoint(['subjects/' obj.Subject])); + url = fullfile(ai.BaseURL, 'admin', 'subjects', 'subject', s.id, 'change'); % this is wrong - need uuid + stat = web(url, '-browser'); end function viewSubjectHistory(obj, ax) @@ -553,8 +494,10 @@ function viewSubjectHistory(obj, ax) obj.log('No weight data found for subject %s', obj.Subject); return end + weights = [records.weight]; + weights(isnan([records.weighing_at])) = nan; expected = [records.expected_weight]; - expected(expected==0|isnan([records.weighing_at])) = nan; + expected(expected==0|isnan(weights)) = nan; dates = cellfun(@(x)datenum(x), {records.date}); % build the figure to show it @@ -567,14 +510,14 @@ function viewSubjectHistory(obj, ax) ax = axes('Parent', plotBox); end - plot(ax, dates, [records.weighing_at], '.-'); + plot(ax, dates, weights, '.-'); hold(ax, 'on'); plot(ax, dates, ((expected-iw)*0.7)+iw, 'r', 'LineWidth', 2.0); plot(ax, dates, ((expected-iw)*0.8)+iw, 'LineWidth', 2.0, 'Color', [244, 191, 66]/255); box(ax, 'off'); % Change the plot x axis limits - maxDate = max(dates([records.is_water_restricted]|~isnan([records.weighing_at]))); - if numel(dates) > 1 && ~isempty(maxDate) + maxDate = max(dates([records.is_water_restricted]|~isnan(weights))); + if numel(dates) > 1 && ~isempty(maxDate) && min(dates) ~= maxDate xlim(ax, [min(dates) maxDate]) else maxDate = now; @@ -589,7 +532,7 @@ function viewSubjectHistory(obj, ax) if nargin==1 ax = axes('Parent', plotBox); - plot(ax, dates, ([records.weighing_at]-iw)./(expected-iw), '.-'); + plot(ax, dates, (weights-iw)./(expected-iw), '.-'); hold(ax, 'on'); plot(ax, dates, 0.7*ones(size(dates)), 'r', 'LineWidth', 2.0); plot(ax, dates, 0.8*ones(size(dates)), 'LineWidth', 2.0, 'Color', [244, 191, 66]/255); @@ -614,12 +557,12 @@ function viewSubjectHistory(obj, ax) histTable = uitable('Parent', histbox,... 'FontName', 'Consolas',... 'RowName', []); - weightsByDate = num2cell([records.weighing_at]); + weightsByDate = num2cell(weights); weightsByDate = cellfun(@(x)sprintf('%.1f', x), weightsByDate, 'uni', false); - weightsByDate(isnan([records.weighing_at])) = {[]}; - weightPctByDate = num2cell(([records.weighing_at]-iw)./(expected-iw)); + weightsByDate(isnan(weights)) = {[]}; + weightPctByDate = num2cell((weights-iw)./(expected-iw)); weightPctByDate = cellfun(@(x)sprintf('%.1f', x*100), weightPctByDate, 'uni', false); - weightPctByDate(isnan([records.weighing_at])|~[records.is_water_restricted]) = {[]}; + weightPctByDate(isnan(weights)|~[records.is_water_restricted]) = {[]}; dat = horzcat(... arrayfun(@(x)datestr(x), dates', 'uni', false), ... @@ -671,6 +614,75 @@ function viewAllSubjects(obj) end end + function dispWaterReq(obj, src, ~) + % Display the amount of water required by the selected subject + % for it to reach its minimum requirement. This function is + % also used to update the selected subject, for example it is + % this funtion to use as a callback to subject dropdown + % listeners + ai = obj.AlyxInstance; + % Set the selected subject if it is an input + if nargin>1; obj.Subject = src.Selected; end + if ~ai.IsLoggedIn + set(obj.WaterRequiredText, 'ForegroundColor', 'black',... + 'String', 'Log in to see water requirements'); + return + end + % Refresh the timer as the user isn't inactive + stop(obj.LoginTimer); start(obj.LoginTimer) + try + s = ai.getData('water-restricted-subjects'); % struct with data about restricted subjects + idx = strcmp(obj.Subject, {s.nickname}); + if ~any(idx) % Subject not on water restriction + set(obj.WaterRequiredText, 'ForegroundColor', 'black',... + 'String', sprintf('Subject %s not on water restriction', obj.Subject)); + else + % Get information on weight and water given + endpnt = sprintf('water-requirement/%s?start_date=%s&end_date=%s',... + obj.Subject, datestr(now, 'yyyy-mm-dd'),datestr(now, 'yyyy-mm-dd')); + wr = ai.getData(endpnt); % Get today's weight and water record + if ~isempty(wr.records) + record = wr.records(end); + else + record = struct(); + end + weight = iff(isempty(record.weighing_at), NaN, record.weight); % Get today's measured weight + water = getOr(record, 'given_water_total', 0); % Get total water given + expected_weight = getOr(record, 'expected_weight', NaN); + % Set colour based on weight percentage + weight_pct = (weight-wr.implant_weight)/(expected_weight-wr.implant_weight); + if weight_pct < 0.7 % Mouse below 70% original weight + colour = 'red'; + weight_pct = '< 70%'; + elseif weight_pct < 0.8 % Mouse below 80% original weight + colour = [0.91, 0.41, 0.17]; % Orange + weight_pct = '< 80%'; + else + colour = 'black'; % Mouse above 80% or no weight measured today + weight_pct = '> 80%'; + end + % Round up water remaining to the near 0.01 + remainder = obj.round(s(idx).remaining_water, 'up'); + % Set text + set(obj.WaterRequiredText, 'ForegroundColor', colour, 'String', ... + sprintf(['Subject %s requires %.2f of %.2f today\n\t '... + 'Weight today: %.2f (%s) Water today: %.2f'], obj.Subject, ... + remainder, obj.round(s(idx).expected_water, 'up'), weight, ... + weight_pct, obj.round(water, 'down'))); + % Set WaterRemaining attribute for changeWaterText callback + obj.WaterRemaining = remainder; + end + catch me + d = me.message; %FIXME: JSON no longer returned + if isfield(d, 'detail') && strcmp(d.detail, 'Not found.') + set(obj.WaterRequiredText, 'ForegroundColor', 'black',... + 'String', sprintf('Subject %s not found in alyx', obj.Subject)); + else + rethrow(me) + end + end + end + function updateWeightButton(obj, src, ~) % Function for changing the text on the weight button to reflect the % current weight value obtained by the scale. This function must be @@ -690,7 +702,27 @@ function updateWeightButton(obj, src, ~) 'StopFcn', @(src,~)delete(src), 'StartDelay', 10); start(obj.WeightTimer) end + + end + + methods (Access = protected) + function changeWaterText(obj, src, ~) + % Update the panel text to show the amount of water still + % required for the subject to reach its minimum requirement. + % This text is updated before the value in the water text box + % has been posted to Alyx. For example if the user is unsure + % how much gel over the minimum they have weighed out, pressing + % return will display this without posting to Alyx + % + % See also DISPWATERREQ, GIVEWATER + if obj.AlyxInstance.IsLoggedIn && ~isempty(obj.WaterRemaining) + rem = obj.WaterRemaining; + curr = str2double(src.String); + set(obj.WaterRemainingText, 'String', sprintf('(%.2f)', rem-curr)); + end + end + function log(obj, varargin) % Function for displaying timestamped information about % occurrences. If the LoggingDisplay property is unset, the @@ -713,17 +745,32 @@ function log(obj, varargin) end methods (Static) - function A = round(a, direction, sigFigures) - if nargin < 3; sigFigures = 2; end - c = 1*10^sigFigures; + function A = round(a, direction, N) + % ROUND Rounds a value a up or down to the nearest N s.f. + % Rounds a value in the specified direction to the nearest N + % significant figures. The default behaviour is the same as + % MATLAB's builtin round function, that is to round to the + % nearest value. + % + % Examples: + % eui.AlyxPanel.round(0.8437, 'up') % 0.85 + % eui.AlyxPanel.round(12.65, 'up', 3) % 12.6 + % eui.AlyxPanel.round(12.6, 'down'), 12); + % + % See also ROUND + if nargin < 2; direction = 'nearest'; end + if nargin < 3; N = 2; end + c = 10.^(N-ceil(log10(a))); + c(c==Inf) = 0; switch direction case 'up' - A = ceil(a*c)/c; + A = ceil(a.*c)./c; case 'down' - A = ceil(a*c)/c; + A = floor(a.*c)./c; otherwise - A = round(a, sigFigures, 'significant'); + A = round(a, N, 'significant'); end + A(a == 0) = 0; end end end diff --git a/+eui/ConditionPanel.m b/+eui/ConditionPanel.m new file mode 100644 index 00000000..ff9ab9b8 --- /dev/null +++ b/+eui/ConditionPanel.m @@ -0,0 +1,337 @@ +classdef ConditionPanel < handle + %CONDITIONPANEL Deals with formatting trial conditions UI table + % Designed to be an element of the EUI.PARAMEDITOR class that manages + % the UI elements associated with all Conditional parameters. + % TODO Add set condition idx + + properties + % Handle to UI Table that represents trial conditions + ConditionTable + % Minimum UI Panel width allowed. See also EUI.PARAMEDITOR/ONRESIZE + MinWidth = 80 + % Handle to parent UI container + UIPanel + % Handle to UI container for buttons + ButtonPanel + % Handles to context menu items + ContextMenus + end + + properties (Access = protected) + % Handle to EUI.PARAMEDITOR object + ParamEditor + % UIControl button for adding a new trial condition (row) to the table + NewConditionButton + % UIControl button for deleting trial conditions (rows) from the table + DeleteConditionButton + % UIControl button for making conditional parameter (column) global + MakeGlobalButton + % UIControl button for setting multiple table cells at once + SetValuesButton + % Indicies of selected table cells as array [row, column;...] of each + % selected cell + SelectedCells + end + + methods + function obj = ConditionPanel(f, ParamEditor, varargin) + % FIELDPANEL Panel UI for Conditional parameters + % Input f may be a figure or other UI container object + % ParamEditor is a handle to an eui.ParamEditor object. + % + % See also EUI.PARAMEDITOR, EUI.FIELDPANEL + obj.ParamEditor = ParamEditor; + obj.UIPanel = uix.VBox('Parent', f); + % Create a child menu for the uiContextMenus. The input arg is the + % figure holding the panel + c = uicontextmenu(ParamEditor.Root); + obj.UIPanel.UIContextMenu = c; + obj.ContextMenus = uimenu(c, 'Label', 'Make Global', ... + 'MenuSelectedFcn', @(~,~)obj.makeGlobal, 'Enable', 'off'); + fcn = @(s,~)obj.ParamEditor.setRandomized(~strcmp(s.Checked, 'on')); + obj.ContextMenus(2) = uimenu(c, 'Label', 'Randomize conditions', ... + 'MenuSelectedFcn', fcn, 'Checked', 'on', 'Tag', 'randomize button'); + obj.ContextMenus(3) = uimenu(c, 'Label', 'Sort by selected column', ... + 'MenuSelectedFcn', @(~,~)obj.sortByColumn, ... + 'Tag', 'sort by', 'Enable', 'off'); + % Create condition table + p = uix.Panel('Parent', obj.UIPanel, 'BorderType', 'none'); + obj.ConditionTable = uitable('Parent', p,... + 'FontName', 'Consolas',... + 'RowName', [],... + 'RearrangeableColumns', 'on',... + 'Units', 'normalized',... + 'Position',[0 0 1 1],... + 'UIContextMenu', c,... + 'CellEditCallback', @obj.onEdit,... + 'CellSelectionCallback', @obj.onSelect); + % Create button panel to hold condition control buttons + obj.ButtonPanel = uix.HBox('Parent', obj.UIPanel); + % Define some common properties + props.Style = 'pushbutton'; + props.Units = 'normalized'; + props.Parent = obj.ButtonPanel; + % Create out four buttons + obj.NewConditionButton = uicontrol(props,... + 'String', 'New condition',... + 'TooltipString', 'Add a new condition',... + 'Callback', @(~, ~) obj.newCondition()); + obj.DeleteConditionButton = uicontrol(props,... + 'String', 'Delete condition',... + 'TooltipString', 'Delete the selected condition',... + 'Enable', 'off',... + 'Callback', @(~, ~) obj.deleteSelectedConditions()); + obj.MakeGlobalButton = uicontrol(props,... + 'String', 'Globalise parameter',... + 'TooltipString', sprintf(['Make the selected condition-specific parameter global (i.e. not vary by trial)\n'... + 'This will move it to the global parameters section']),... + 'Enable', 'off',... + 'Callback', @(~, ~) obj.makeGlobal()); + obj.SetValuesButton = uicontrol(props,... + 'String', 'Set values',... + 'TooltipString', 'Set selected values to specified value, range or function',... + 'Enable', 'off',... + 'Callback', @(~, ~) obj.setSelectedValues()); + obj.ButtonPanel.Widths = [-1 -1 -1 -1]; + obj.UIPanel.Heights = [-1 25]; + end + + function onEdit(obj, src, eventData) + % ONEDIT Callback for edits to condition table + % Updates the underlying parameter struct, changes the UI table + % data. The src object should be the UI Table that has been edited, + % and eventData contains the table indices of the edited cell. + % + % See also FILLCONDITIONTABLE, EUI.PARAMEDITOR/UPDATE + row = eventData.Indices(1); + col = eventData.Indices(2); + assert(all(cellfun(@strcmpi, strrep(obj.ConditionTable.ColumnName, ' ', ''), ... + obj.ParamEditor.Parameters.TrialSpecificNames)), 'Unexpected condition names') + paramName = obj.ParamEditor.Parameters.TrialSpecificNames{col}; + newValue = obj.ParamEditor.update(paramName, eventData.NewData, row); + reformed = obj.ParamEditor.paramValue2Control(newValue); + % If successful update the cell with default formatting + data = get(src, 'Data'); + if iscell(reformed) + % The reformed data type is a cell, this should be a one element + % wrapping cell + if numel(reformed) == 1 + reformed = reformed{1}; + else + error('Cannot handle data reformatted data type'); + end + end + data{row,col} = reformed; + set(src, 'Data', data); + end + + function clear(obj) + % CLEAR Clear all table data + % Clears all trial condition data from UI Table + % + % See also EUI.PARAMEDITOR/BUILDUI, EUI.PARAMEDITOR/CLEAR + set(obj.ConditionTable, 'ColumnName', [], ... + 'Data', [], 'ColumnEditable', false); + end + + function delete(obj) + % DELETE Deletes the UI container + % Called when this object or its parent ParamEditor is deleted + % See also CLEAR + delete(obj.UIPanel); + end + + function onSelect(obj, ~, eventData) + % ONSELECT Callback for when table cells are (de-)selected + % If at least one cell is selected, ensure buttons and menu items + % are enabled, otherwise disable them. + if nargin > 2; obj.SelectedCells = eventData.Indices; end + controls = ... + [obj.MakeGlobalButton, ... + obj.DeleteConditionButton, ... + obj.SetValuesButton, ... + obj.ContextMenus([1,3])]; + set(controls, 'Enable', iff(size(obj.SelectedCells, 1) > 0, 'on', 'off')); + end + + function makeGlobal(obj) + % MAKEGLOBAL Make condition parameter (table column) global + % Find all selected columns are turn into global parameters, using + % the value of the first selected cell as the global parameter + % value. + % + % See also eui.ParamEditor/globaliseParamAtCell + if isempty(obj.SelectedCells) + disp('nothing selected') + return + end + PE = obj.ParamEditor; + [cols, iu] = unique(obj.SelectedCells(:,2)); + names = PE.Parameters.TrialSpecificNames(cols); + rows = num2cell(obj.SelectedCells(iu,1)); %get rows of unique selected cols + cellfun(@PE.globaliseParamAtCell, names, rows); + % If only numRepeats remains, globalise it + if isequal(PE.Parameters.TrialSpecificNames, {'numRepeats'}) + PE.Parameters.Struct.numRepeats(1,1) = sum(PE.Parameters.Struct.numRepeats); + PE.globaliseParamAtCell('numRepeats', 1) + end + end + + function deleteSelectedConditions(obj) + %DELETESELECTEDCONDITIONS Removes the selected conditions from table + % The callback for the 'Delete condition' button. This removes the + % selected conditions from the table and if less than two conditions + % remain, globalizes them. + % + % See also EXP.PARAMETERS, GLOBALISESELECTEDPARAMETERS + rows = unique(obj.SelectedCells(:,1)); + names = obj.ConditionTable.ColumnName; + numConditions = size(obj.ConditionTable.Data,1); + % If the number of remaining conditions is 1 or less... + if numConditions-length(rows) <= 1 + remainingIdx = find(all(1:numConditions~=rows,1)); + if isempty(remainingIdx); remainingIdx = 1; end + % change selected cells to be all fields (except numRepeats which + % is assumed to always be the last column) + obj.SelectedCells =[ones(length(names),1)*remainingIdx, (1:length(names))']; + %... globalize them + obj.makeGlobal; + else % Otherwise delete the selected conditions as usual + obj.ParamEditor.Parameters.removeConditions(rows); + notify(obj.ParamEditor, 'Changed') + end + % Refresh the table of conditions + obj.fillConditionTable(); + end + + function setSelectedValues(obj) + % SETSELECTEDVALUES Set multiple fields in conditional table at once + % Generates an input dialog for setting multiple trial conditions at + % once. Also allows the use of function handles for more complex + % values. + % + % Examples: + % (1:10:100) % Sets selected rows to [1 11 21 31 41 51 61 71 81 91] + % @(~)randi(100) % Assigned random integer to each selected row + % @(a)a*50 % Multiplies each condition value by 50 + % false % Sets all selected rows to false + % + % See also SETNEWVALS, ONEDIT + PE = obj.ParamEditor; + cols = obj.SelectedCells(:,2); % selected columns + uCol = unique(obj.SelectedCells(:,2)); + rows = obj.SelectedCells(:,1); % selected rows + % get current values of selected cells + currVals = arrayfun(@(u)obj.ConditionTable.Data(rows(cols==u),u), uCol, 'UniformOutput', 0); + names = PE.Parameters.TrialSpecificNames(uCol); % selected column names + promt = cellfun(@(a,b) [a ' (' num2str(sum(cols==b)) ')'],... + names, num2cell(uCol), 'UniformOutput', 0); % names of columns & num selected rows + defaultans = cellfun(@(c) c(1), currVals); + answer = inputdlg(promt,'Set values', 1, cellflat(defaultans)); % prompt for input + if isempty(answer) % if user presses cancel + return + end + % set values for each column + cellfun(@(a,b,c) setNewVals(a,b,c), answer, currVals, names, 'UniformOutput', 0); + function newVals = setNewVals(userIn, currVals, paramName) + % check array orientation + currVals = iff(size(currVals,1)>size(currVals,2),currVals',currVals); + if strStartsWith(userIn,'@') % anon function + func_h = str2func(userIn); + % apply function to each cell + currVals = cellfun(@str2double,currVals, 'UniformOutput', 0); % convert from char + newVals = cellfun(func_h, currVals, 'UniformOutput', 0); + elseif any(userIn==':') % array syntax + arr = eval(userIn); + newVals = num2cell(arr); % convert to cell array + elseif any(userIn==','|userIn==';') % 2D arrays + C = strsplit(userIn, ';'); + newVals = cellfun(@(c)textscan(c, '%f',... + 'ReturnOnError', false,... + 'delimiter', {' ', ','}, 'MultipleDelimsAsOne', 1),... + C); + else % single value to copy across all cells + userIn = str2double(userIn); + newVals = num2cell(ones(size(currVals))*userIn); + end + + if length(newVals)>length(currVals) % too many new values + newVals = newVals(1:length(currVals)); % truncate new array + elseif length(newVals) 5; w = 0.5; else; w = 0.1 * w; end +% obj.UI(2).Position = [1-w 0 w 1]; +% obj.UI(1).Position = [0 0 1-w 1]; + + %%% general coordinates + pos = getpixelposition(obj.UIPanel); + borderwidth = obj.Margin; + bounds = [pos(3) pos(4)] - 2*borderwidth; + n = numel(obj.Labels); + vspace = obj.RowSpacing; + hspace = obj.ColSpacing; + rowHeight = obj.MinRowHeight + 2*vspace; + rowsPerCol = floor(bounds(2)/rowHeight); + cols = ceil((1:n)/rowsPerCol)'; + ncols = cols(end); + rows = mod(0:n - 1, rowsPerCol)' + 1; + labelColWidth = max(obj.LabelWidths) + 2*hspace; + ctrlWidthAvail = bounds(1)/ncols - labelColWidth; + ctrlColWidth = max(obj.MinCtrlWidth, min(ctrlWidthAvail, obj.MaxCtrlWidth)); + fullColWidth = labelColWidth + ctrlColWidth; + + %%% coordinates of labels + by = bounds(2) - rows*rowHeight + vspace + 1 + borderwidth; + labelPos = [vspace + (cols - 1)*fullColWidth + 1 + borderwidth... + by... + obj.LabelWidths... + repmat(rowHeight - 2*vspace, n, 1)]; + + %%% coordinates of edits + editPos = [labelColWidth + hspace + (cols - 1)*fullColWidth + 1 + borderwidth ... + by... + repmat(ctrlColWidth - 2*hspace, n, 1)... + repmat(rowHeight - 2*vspace, n, 1)]; + set(obj.Labels, {'Position'}, num2cell(labelPos, 2)); + set(obj.Controls, {'Position'}, num2cell(editPos, 2)); + + end + end + +end + diff --git a/+eui/MControl.m b/+eui/MControl.m index 5b25075d..4f4ff1df 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -20,6 +20,7 @@ end properties (SetAccess = private) + AlyxPanel % holds the AlyxPanel object (see buildUI(), eui.AlyxPanel()) LogSubject % Subject selector control NewExpSubject % Experiment selector control NewExpType % Experiment type selector control @@ -33,7 +34,6 @@ properties (Access = private) ParamEditor ParamPanel - AlyxPanel % holds the AlyxPanel object (see buildUI(), eui.AlyxPanel()) BeginExpButton % The 'Start' button that begins an experiment RigOptionsButton % The 'Options' button that opens the rig options dialog NewExpFactory % A struct containing all availiable experiment types and function handles to constructors for their default parameters @@ -247,10 +247,10 @@ function saveParamProfile(obj) % Called by 'Save...' button press, save a new pa end function loadParamProfile(obj, profile) + set(obj.ParamProfileLabel, 'String', 'loading...', 'ForegroundColor', [1 0 0]); % Red 'Loading...' while new set loads if ~isempty(obj.ParamEditor) - %delete existing parameters control - delete(obj.ParamEditor); - set(obj.ParamProfileLabel, 'String', 'loading...', 'ForegroundColor', [1 0 0]); % Red 'Loading...' while new set loads + % Clear existing parameters control + clear(obj.ParamEditor) end factory = obj.NewExpFactory; % Find which 'world' we are in @@ -305,16 +305,23 @@ function loadParamProfile(obj, profile) paramStruct = rmfield(paramStruct, 'services'); end obj.Parameters.Struct = paramStruct; - if ~isempty(paramStruct) % Now parameters are loaded, pass to ParamEditor for display, etc. + if isempty(paramStruct); return; end + % Now parameters are loaded, pass to ParamEditor for display, etc. + if isempty(obj.ParamEditor) obj.ParamEditor = eui.ParamEditor(obj.Parameters, obj.ParamPanel); % Build parameter list in Global panel by calling eui.ParamEditor - obj.ParamEditor.addlistener('Changed', @(src,~) obj.paramChanged); - if strcmp(obj.RemoteRigs.Selected.Status, 'idle') - set(obj.BeginExpButton, 'Enable', 'on') % Re-enable start button - end + else + obj.ParamEditor.buildUI(obj.Parameters); + end + obj.ParamEditor.addlistener('Changed', @(src,~) obj.paramChanged); + if strcmp(obj.RemoteRigs.Selected.Status, 'idle') + set(obj.BeginExpButton, 'Enable', 'on') % Re-enable start button end end function paramChanged(obj) + % PARAMCHANGED Indicate to user that parameters have been updated + % Changes the label above the ParamEditor indicating that the + % parameters have been edited s = get(obj.ParamProfileLabel, 'String'); if ~strEndsWith(s, '[EDITED]') set(obj.ParamProfileLabel, 'String', [s ' ' '[EDITED]'], 'ForegroundColor', [1 0 0]); @@ -375,7 +382,7 @@ function rigConnected(obj, rig, ~) % See also REMOTERIGCHANGED, SRV.STIMULUSCONTROL, EUI.EXPPANEL % If rig is connected check no experiments are running... - expRef = rig.ExpRunnning; % returns expRef if running + expRef = rig.ExpRunning; % returns expRef if running if expRef % error('Experiment %s already running of %s', expDef, rig.Name) choice = questdlg(['Attention: An experiment is already running on ', rig.Name], ... @@ -545,6 +552,10 @@ function beginExp(obj) % See also SRV.STIMULUSCONTROL, EUI.EXPPANEL, EUI.ALYXPANEL set([obj.BeginExpButton obj.RigOptionsButton], 'Enable', 'off'); % Grey out buttons rig = obj.RemoteRigs.Selected; % Find which rig is selected + if strcmpi(rig.Status, 'running') + obj.log('Failed because another experiment is running'); + return; + end % Save the current instance of Alyx so that eui.ExpPanel can register water to the correct account if ~obj.AlyxPanel.AlyxInstance.IsLoggedIn &&... ~strcmp(obj.NewExpSubject.Selected,'default') &&... @@ -752,7 +763,8 @@ function buildUI(obj, parent) % Parent here is the MC window (figure) leftSideBox.Heights = [55 22]; % Create the Alyx panel - obj.AlyxPanel = eui.AlyxPanel(headerBox); + url = char(getOr(dat.paths, 'databaseURL', '')); + obj.AlyxPanel = eui.AlyxPanel(headerBox, isempty(url)); addlistener(obj.NewExpSubject, 'SelectionChanged', @(src, evt)obj.AlyxPanel.dispWaterReq(src, evt)); addlistener(obj.LogSubject, 'SelectionChanged', @(src, evt)obj.AlyxPanel.dispWaterReq(src, evt)); diff --git a/+eui/ParamEditor.m b/+eui/ParamEditor.m index 75aca882..b7c7d6d1 100644 --- a/+eui/ParamEditor.m +++ b/+eui/ParamEditor.m @@ -1,157 +1,184 @@ classdef ParamEditor < handle - %EUI.PARAMEDITOR UI control for configuring experiment parameters - % TODO. See also EXP.PARAMETERS. + %PARAMEDITOR GUI for visualizing and editing experiment parameters + % ParamEditor deals with setting the paramters via a GUI. In general + % this class is involved in constructing a UI comprising a Global UI + % panel, handled by the EUI.FIELDPANEL class, and a Condition table, + % handled by the EUI.CONDITIONPANEL class. It is also responsible for + % updating the underlying EXP.PARAMETERS object and notifying + % downstream listeners of parameter changes. % - % Part of Rigbox - - % 2012-11 CB created - % 2017-03 MW/NS Made global panel scrollable & improved performance of - % buildGlobalUI. - % 2017-03 MW Added set values button + % See also EUI.FIELDPANEL, EUI.CONDITIONPANEL properties - GlobalVSpacing = 20 + % An exp.Parameters object, which keeps track of all parameter changes Parameters end - properties (Dependent) - Enable + properties (Access = {?eui.ConditionPanel, ?eui.FieldPanel}) + % Handle to the EUI.FIELDPANEL object, which manages the display of the + % Global parameters + GlobalUI + % Handle to the EUI.CONDITIONPANEL object, which manages the display of + % the trial conditions within a ui table + ConditionalUI + % Handle to the parent container for the ParamEditor. If constructor + % called with no parent input, then this will be a figure handle, the + % same as Root + Parent + % Handle to the figure within which the ParamEditor is displayed + Root + % A listener for changes to the figure size. See also ONRESIZE + Listener end - properties (Access = private) - Root - GlobalGrid - ConditionTable - TableColumnParamNames = {} - NewConditionButton - DeleteConditionButton - MakeGlobalButton - SetValuesButton - SelectedCells %[row, column;...] of each selected cell - GlobalControls + properties (Dependent) + % Flag for making editor read only by disabling all UI controls + Enable end events + % Event notified each time a user makes an edit to a parameter Changed end methods - function obj = ParamEditor(params, parent) - if nargin < 2 % Can call this function to display parameters is new window + function obj = ParamEditor(pars, parent) + % PARAMEDITOR GUI for visualizing and editing experiment parameters + % The input pars is expected to be an instance of the exp.Parameters + % class. Parent is a handle to a parent figure or UI Panel. If no + % parent is given, the editor is created in a new figure. + % + % See also EUI.FIELDPANEL, EUI.CONDITIONPANEL + if nargin == 0; pars = []; end + if nargin < 2 parent = figure('Name', 'Parameters', 'NumberTitle', 'off',... - 'Toolbar', 'none', 'Menubar', 'none'); + 'Toolbar', 'none', 'Menubar', 'none', 'DeleteFcn', @(~,~)obj.delete); end - obj.Parameters = params; - obj.build(parent); + obj.Root = ancestor(parent, 'Figure'); +% obj.Listener = event.listener(parent, 'SizeChanged', @(~,~)obj.onResize); + obj.Parent = uix.HBox('Parent', parent); + obj.GlobalUI = eui.FieldPanel(obj.Parent, obj); + if nargin == 2; obj.GlobalUI.Margin = 14; end % FIXME Add as generic input name-value pair + obj.ConditionalUI = eui.ConditionPanel(obj.Parent, obj); + obj.buildUI(pars); + % FIXME Current hack for drawing params first time + pos = obj.Root.Position; + obj.Root.Position = pos+0.01; + obj.Root.Position = pos; end - function delete(obj) - disp('ParamEditor destructor called'); - if obj.Root.isvalid - obj.Root.delete(); - end + function selected = getSelected(obj) + % GETSELECTED Return the object currently in focus + % Returns handle to the object currently in focus in the figure, + % that is, the object last clicked on by the user. This is used by + % the FieldPanel context menu to determine which parameter was + % selected. + % + % See also EUI.FIELDPANEL + selected = obj.Root.CurrentObject; end - function value = get.Enable(obj) - value = obj.Root.Enable; + function delete(obj) + % DELETE Deletes all panels + % Called when the ParamEditor object is deleted or its parent figure + % is closed. Deletes all UI elements and data. + % See also CLEAR + delete(obj.GlobalUI); + delete(obj.ConditionalUI); end - + function set.Enable(obj, value) - obj.Root.Enable = value; + % Disable all UI elements + % Render the GUI view-only by disabling all UI elements. Used for + % viewing parameters during an active experiment when the parameters + % can no longer be adjusted. + % See also EUI.EXPPANEL, EUI.CONDITIONPANEL/ONSELECT + cUI = obj.ConditionalUI; + contextMenus = [cUI.ContextMenus obj.GlobalUI.ContextMenu.Children]'; + parent = obj.Parent; % FIXME: use tags instead? + if value == true + arrayfun(@(prop) set(prop, 'Enable', 'on'), ... + [contextMenus; findobj(parent,'Enable','off')]); + cUI.onSelect() % Re-disable buttons if no cells were selected + else + arrayfun(@(prop) set(prop, 'Enable', 'off'), ... + [contextMenus; findobj(parent,'Enable','on')]); + end end - end - - methods %(Access = protected) - function build(obj, parent) % Build parameters panel - obj.Root = uiextras.HBox('Parent', parent, 'Padding', 5, 'Spacing', 5); % Add horizontal container for Global and Conditional panels -% globalPanel = uiextras.Panel('Parent', obj.Root,... % Make 'Global' parameters panel -% 'Title', 'Global', 'Padding', 5); - globPanel = uiextras.Panel('Parent', obj.Root,... % Make 'Global' parameters panel - 'Title', 'Global', 'Padding', 5); - globalPanel = uix.ScrollingPanel('Parent', globPanel,... % Make 'Global' scroll panel - 'Padding', 5); - - obj.GlobalGrid = uiextras.Grid('Parent', globalPanel, 'Padding', 4); % Make grid for parameter fields - obj.buildGlobalUI; % Populate Global panel - globalPanel.Heights = sum(obj.GlobalGrid.RowSizes)+45; - - conditionPanel = uiextras.Panel('Parent', obj.Root,... - 'Title', 'Conditional', 'Padding', 5); % Make 'Conditional' parameters panel - conditionVBox = uiextras.VBox('Parent', conditionPanel); - obj.ConditionTable = uitable('Parent', conditionVBox,... - 'FontName', 'Consolas',... - 'RowName', [],... - 'CellEditCallback', @obj.cellEditCallback,... - 'CellSelectionCallback', @obj.cellSelectionCallback); - obj.fillConditionTable(); - conditionButtonBox = uiextras.HBox('Parent', conditionVBox); - conditionVBox.Sizes = [-1 25]; - obj.NewConditionButton = uicontrol('Parent', conditionButtonBox,... - 'Style', 'pushbutton',... - 'String', 'New condition',... - 'TooltipString', 'Add a new condition',... - 'Callback', @(~, ~) obj.newCondition()); - obj.DeleteConditionButton = uicontrol('Parent', conditionButtonBox,... - 'Style', 'pushbutton',... - 'String', 'Delete condition',... - 'TooltipString', 'Delete the selected condition',... - 'Enable', 'off',... - 'Callback', @(~, ~) obj.deleteSelectedConditions()); - obj.MakeGlobalButton = uicontrol('Parent', conditionButtonBox,... - 'Style', 'pushbutton',... - 'String', 'Globalise parameter',... - 'TooltipString', sprintf(['Make the selected condition-specific parameter global (i.e. not vary by trial)\n'... - 'This will move it to the global parameters section']),... - 'Enable', 'off',... - 'Callback', @(~, ~) obj.globaliseSelectedParameters()); - obj.SetValuesButton = uicontrol('Parent', conditionButtonBox,... - 'Style', 'pushbutton',... - 'String', 'Set values',... - 'TooltipString', 'Set selected values to specified value, range or function',... - 'Enable', 'off',... - 'Callback', @(~, ~) obj.setSelectedValues()); - - obj.Root.Sizes = [sum(obj.GlobalGrid.ColumnSizes) + 32, -1]; + + function clear(obj) + % CLEAR Clear the Global and Condition panels + % Deletes all UI fields (labels and control elements) and clears the + % Condition Table data. + % + % See also BUILDUI + clear(obj.GlobalUI); + clear(obj.ConditionalUI); end - function buildGlobalUI(obj) % Function to essemble global parameters - globalParamNames = fieldnames(obj.Parameters.assortForExperiment); % assortForExperiment divides params into global and trial-specific parameter structures - obj.GlobalControls = gobjects(length(globalParamNames),3); % Initialize object array (faster than assigning to end of array which results in two calls to constructor) - for i=1:length(globalParamNames) % using for loop (sorry Chris!) to populate object array 2017-02-14 MW - [obj.GlobalControls(i,1), obj.GlobalControls(i,2), obj.GlobalControls(i,3)]... % [editors, labels, buttons] - = obj.addParamUI(globalParamNames{i}); + function buildUI(obj, pars) + % BUILDUI Populate Global and Condition UI panels with paramter set + % Clears any existing fields and the condition table, then loads new + % UI controls and labels for the Global parameters, and fills the + % condition table. The input pars must be an instance of the + % exp.Parameters class. + % + % RandomiseConditions is not added as a field but instead is + % represented as a context menu item + % + % See also EXP.PARAMETERS, EUI.FIELDPANEL/ADDFIELD, + % EUI.CONDITIONPANEL/FILLCONDITIONTABLE + obj.Parameters = pars; + obj.clear() % Clear the current parameter UI elements + if isempty(pars); return; end % Nothing to build + c = obj.GlobalUI; % Handle to FieldPanel object + names = pars.GlobalNames; % Names of the global parameters + for nm = names' + % RandomiseConditions is a special parameter represented in the + % context menu, don't create global param field + if strcmp(nm, 'randomiseConditions'); continue; end + if islogical(pars.Struct.(nm{:})) % If parameter is logical, make checkbox + [~, ctrl] = addField(c, nm{:}, 'checkbox'); + ctrl.Value = pars.Struct.(nm{:}); + else % Otherwise create the default field; a text box + [~, ctrl] = addField(c, nm{:}); + ctrl.String = obj.paramValue2Control(pars.Struct.(nm{:})); + end end - % Above code replaces the following as after 2014a, MATLAB doesn't no - % longer uses numrical handles but instead uses object arrays -% [editors, labels, buttons] = cellfun(... -% @(n) obj.addParamUI(n), fieldnames(globalParams), 'UniformOutput', false); -% editors = cell2mat(editors); -% labels = cell2mat(labels); -% buttons = cell2mat(buttons); -% obj.GlobalControls = [labels, editors, buttons]; -% obj.GlobalGrid.Children = obj.GlobalControls(:); - -% obj.GlobalGrid.Children = -% blah = cat(1,obj.GlobalControls(:,1),obj.GlobalControls(:,2),obj.GlobalControls(:,3)); -% Doesn't work for some reason - MW 2017-02-15 - - child_handles = allchild(obj.GlobalGrid); % Get child handles for GlobalGrid - child_handles = [child_handles(end-1:-3:1); child_handles(end:-3:1); child_handles(end-2:-3:1)]; % Reorder them so all labels come first, then ctrls, then buttons -% child_handles = [child_handles(2:3:end); child_handles(3:3:end); child_handles(1:3:end)]; % Reorder them so all labels come first, then ctrls, then buttons - obj.GlobalGrid.Contents = child_handles; % Set children to new order - % uistack - - obj.GlobalGrid.ColumnSizes = [180, 200, 40]; % Set column sizes - obj.GlobalGrid.Spacing = 1; - obj.GlobalGrid.RowSizes = repmat(obj.GlobalVSpacing, 1, size(obj.GlobalControls, 1)); + % Populate the trial conditions table + obj.ConditionalUI.fillConditionTable(); + %%% Special parameters + if ismember('randomiseConditions', obj.Parameters.Names) && ~pars.Struct.randomiseConditions + obj.ConditionalUI.ConditionTable.RowName = 'numbered'; + set(obj.ConditionalUI.ContextMenus(2), 'Checked', 'off'); + end + obj.GlobalUI.onResize(); end -% function swapConditions(obj, idx1, idx2) % Function started, never -% finished - MW 2017-02-15 -% % params = obj.Parameters.trial -% end - + function setRandomized(obj, value) + % If randomiseConditions doesn't exist and new value is false, add + % the parameter and set it to false + if ~ismember('randomiseConditions', obj.Parameters.Names) && value == false + description = 'Whether to randomise the conditional paramters or present them in order'; + obj.Parameters.set('randomiseConditions', false, description, 'logical') + elseif ismember('randomiseConditions', obj.Parameters.Names) + obj.update('randomiseConditions', logical(value)); + end + menu = obj.ConditionalUI.ContextMenus(2); + if value == false + obj.ConditionalUI.ConditionTable.RowName = 'numbered'; + menu.Checked = 'off'; + else + obj.ConditionalUI.ConditionTable.RowName = []; + menu.Checked = 'on'; + end + end + function addEmptyConditionToParam(obj, name) + % Add a new trial specific condition to the table + % Adds a new trial condition to each trial specific parameter. That + % is, adds a new column to each parameter. + % See also EUI.CONDITIONPANEL/NEWCONDITION assert(obj.Parameters.isTrialSpecific(name),... 'Tried to add a new condition to global parameter ''%s''', name); % work out what the right 'empty' is for the parameter @@ -183,221 +210,161 @@ function addEmptyConditionToParam(obj, name) obj.Parameters.Struct.(name) = cat(2, obj.Parameters.Struct.(name), newValue); end - function cellSelectionCallback(obj, src, eventData) - obj.SelectedCells = eventData.Indices; - if size(eventData.Indices, 1) > 0 - %cells selected, enable buttons - set(obj.MakeGlobalButton, 'Enable', 'on'); - set(obj.DeleteConditionButton, 'Enable', 'on'); - set(obj.SetValuesButton, 'Enable', 'on'); + function newValue = update(obj, name, value, row) + % UPDATE Updates the exp.Parameters object with new value + % Called when either the Condition table or Global param fields are + % updated by the user. Updated the underlying paramters structure + % and notifies listeners of the change via the Changed event. + % + % See also EUI.FIELDPANEL/ONEDIT, EUI.CONDITIONPANEL/ONEDIT + if nargin < 4; row = 1; end + currValue = obj.Parameters.Struct.(name)(:,row); + if iscell(currValue) + % cell holders are allowed to be different types of value + newValue = obj.controlValue2Param(currValue{1}, value, true); + obj.Parameters.Struct.(name){:,row} = newValue; else - %nothing selected, disable buttons - set(obj.MakeGlobalButton, 'Enable', 'off'); - set(obj.DeleteConditionButton, 'Enable', 'off'); - set(obj.SetValuesButton, 'Enable', 'off'); + newValue = obj.controlValue2Param(currValue, value); + if ischar(newValue) + obj.Parameters.Struct.(name) = newValue; + else + obj.Parameters.Struct.(name)(:,row) = newValue; + end end + notify(obj, 'Changed'); end - function newCondition(obj) - disp('adding new condition row'); - cellfun(@obj.addEmptyConditionToParam, obj.Parameters.TrialSpecificNames); - obj.fillConditionTable(); - end - - function deleteSelectedConditions(obj) - %DELETESELECTEDCONDITIONS Removes the selected conditions from table - % The callback for the 'Delete condition' button. This removes the - % selected conditions from the table and if less than two conditions - % remain, globalizes them. - % TODO: comment function better, index in a clearer fashion + function globaliseParamAtCell(obj, name, row) + % Make parameter 'name' a global parameter and set it's value to be + % that of the specified row. % - % See also EXP.PARAMETERS, GLOBALISESELECTEDPARAMETERS - rows = unique(obj.SelectedCells(:,1)); - % If the number of remaining conditions is 1 or less... - names = obj.Parameters.TrialSpecificNames; - numConditions = size(obj.Parameters.Struct.(names{1}),2); - if numConditions-length(rows) <= 1 - remainingIdx = find(all(1:numConditions~=rows,1)); - if isempty(remainingIdx); remainingIdx = 1; end - % change selected cells to be all fields (except numRepeats which - % is assumed to always be the last column) - obj.SelectedCells =[ones(length(names)-1,1)*remainingIdx, (1:length(names)-1)']; - %... globalize them - obj.globaliseSelectedParameters; - obj.Parameters.removeConditions(rows) -% for i = 1:numel(names) -% newValue = iff(any(remainingIdx), obj.Struct.(names{i})(:,remainingIdx), obj.Struct.(names{i})(1)); -% % If the parameter is Num repeats, set the value -% if strcmp(names{i}, 'numRepeats') -% obj.Struct.(names{i}) = newValue; -% else -% obj.makeGlobal(names{i}, newValue); -% end -% end - else % Otherwise delete the selected conditions as usual - obj.Parameters.removeConditions(rows); - end - obj.fillConditionTable(); %refresh the table of conditions - end - - function globaliseSelectedParameters(obj) - [cols, iu] = unique(obj.SelectedCells(:,2)); - names = obj.TableColumnParamNames(cols); - rows = obj.SelectedCells(iu,1); %get rows of unique selected cols - arrayfun(@obj.globaliseParamAtCell, rows, cols); - obj.fillConditionTable(); %refresh the table of conditions - %now add global controls for parameters - newGlobals = gobjects(length(names),3); % Initialize object array (faster than assigning to end of array which results in two calls to constructor) - for i=length(names):-1:1 % using for loop (sorry Chris!) to initialize and populate object array 2017-02-15 MW - [newGlobals(i,1), newGlobals(i,2), newGlobals(i,3)]... % [editors, labels, buttons] - = obj.addParamUI(names{i}); - end - -% [editors, labels, buttons] = arrayfun(@obj.addParamUI, names); % -% 2017-02-15 MW can no longer use arrayfun with object outputs - idx = size(obj.GlobalControls, 1); % Calculate number of current Global params - new = numel(newGlobals); - obj.GlobalControls = [obj.GlobalControls; newGlobals]; % Add new globals to object - ggHandles = obj.GlobalGrid.Contents; - ggHandles = [ggHandles(1:idx); ggHandles((end-new+2):3:end);... - ggHandles(idx+1:idx*2); ggHandles((end-new+1):3:end);... - ggHandles(idx*2+1:idx*3); ggHandles((end-new+3):3:end)]; % Reorder them so all labels come first, then ctrls, then buttons - obj.GlobalGrid.Contents = ggHandles; % Set children to new order - - % Reset sizes - obj.GlobalGrid.RowSizes = repmat(obj.GlobalVSpacing, 1, size(obj.GlobalControls, 1)); - set(get(obj.GlobalGrid, 'Parent'),... - 'Heights', sum(obj.GlobalGrid.RowSizes)+45); % Reset height of globalPanel - obj.GlobalGrid.ColumnSizes = [180, 200, 40]; - obj.GlobalGrid.Spacing = 1; - end - - function globaliseParamAtCell(obj, row, col) - name = obj.TableColumnParamNames{col}; + % See also EXP.PARAMETERS/MAKEGLOBAL, UI.CONDITIONPANEL/MAKEGLOBAL value = obj.Parameters.Struct.(name)(:,row); obj.Parameters.makeGlobal(name, value); - end - - function setSelectedValues(obj) % Set multiple fields in conditional table - disp('updating table cells'); - cols = obj.SelectedCells(:,2); % selected columns - uCol = unique(obj.SelectedCells(:,2)); - rows = obj.SelectedCells(:,1); % selected rows - % get current values of selected cells - currVals = arrayfun(@(u)obj.ConditionTable.Data(rows(cols==u),u), uCol, 'UniformOutput', 0); - names = obj.TableColumnParamNames(uCol); % selected column names - promt = cellfun(@(a,b) [a ' (' num2str(sum(cols==b)) ')'],... - names, num2cell(uCol), 'UniformOutput', 0); % names of columns & num selected rows - defaultans = cellfun(@(c) c(1), currVals); - answer = inputdlg(promt,'Set values', 1, cellflat(defaultans)); % prompt for input - if isempty(answer) % if user presses cancel - return - end - % set values for each column - cellfun(@(a,b,c) setNewVals(a,b,c), answer, currVals, names, 'UniformOutput', 0); - function newVals = setNewVals(userIn, currVals, paramName) - % check array orientation - currVals = iff(size(currVals,1)>size(currVals,2),currVals',currVals); - if strStartsWith(userIn,'@') % anon function - func_h = str2func(userIn); - % apply function to each cell - currVals = cellfun(@str2double,currVals, 'UniformOutput', 0); % convert from char - newVals = cellfun(func_h, currVals, 'UniformOutput', 0); - elseif any(userIn==':') % array syntax - arr = eval(userIn); - newVals = num2cell(arr); % convert to cell array - elseif any(userIn==','|userIn==';') % 2D arrays - C = strsplit(userIn, ';'); - newVals = cellfun(@(c)textscan(c, '%f',... - 'ReturnOnError', false,... - 'delimiter', {' ', ','}, 'MultipleDelimsAsOne', 1),... - C); - else % single value to copy across all cells - userIn = str2double(userIn); - newVals = num2cell(ones(size(currVals))*userIn); - end - - if length(newVals)>length(currVals) % too many new values - newVals = newVals(1:length(currVals)); % truncate new array - elseif length(newVals)