classdef ContextMenus
    
    % Context menu support
    %
    methods (Static)
        function hMenu = createContext(opts)
            % Helper function to append additional context menus
            % createContext({parent,'label',@callback, ...
            %               param,value,...})
            
            args = {'Parent',opts{1}, ...
                'Tag',opts{2}, ...
                'Label',opts{2}, ...
                'Callback',opts{3:end}};
            hMenu = uimenu(args{:});
        end
        
        function menuNChoices(targetObj,ht,str,strs,prop,val)
            % obj: target object that receives update to property value
            % ht: vector of menu handles to each child uimenu in submenu;
            %      must have same length as strs
            % str: new string enum choice for this selection; used as the
            %      new property value unless val is passed
            % strs: cell-string of all enums, in same order as ht
            % prop: property whose value will be updated; several formats
            %      are available to guide how the property is updated.
            % val: if passed, this value is assigned to the property, not
            %      str itself.  Can be a numeric or string value.
            %
            % Several formats for property assignment are available,
            % based on what is passed for "prop", and whether "val" is
            % passed.
            %
            %  - empty string
            %    No assignment of value to an object property occurs; only
            %    updating of check-mark on child menu is performed.
            %
            %  - string: name of property on targetObject
            %    Assigns value into a property of specified object.
            %        obj.(prop) = <new value>
            %    Data type of the current value is examined; if it's
            %    numeric and the new value is a string, numeric conversion
            %    is automatically applied.
            %
            %  - 1x2 cell: {prop,cellIdx}
            %    Assigns value into a cell-string property of a specified
            %    object:
            %        obj.(prop){cellIdx} = <new value>
            
            % Target property:
            if iscell(prop)
                targetProp = prop{1};
                targetIdx = prop{2};
            else
                targetProp = prop;
                targetIdx = [];
            end
            if nargin > 5
                targetVal = val;
            else
                targetVal = str;
            end
            
            % Both str and strs are "visual display strings", not
            % necessarily actual property enums if val is passed.
            sel = strcmpi(str,strs);
            assert(any(sel))
            set(ht,'Checked','off');
            set(ht(sel),'Checked','on'); % should just be one entry
            
            if ~isempty(prop)
                if isempty(targetIdx)
                    targetObj.(targetProp) = targetVal;
                else
                    tp = targetObj.(targetProp);
                    if iscell(tp)
                        % Property holds a cell-array: assign to cell
                        targetObj.(targetProp){targetIdx} = targetVal;
                    elseif ischar(tp) || (isstring(tp) && isscalar(tp))
                        % Property holds a string. Promote to cell if
                        % targetIdx > 1.
                        if targetIdx > 1
                            % Replicate current string into
                            % {1:targetIdx-1}, and put targetVal into
                            % {targetIdx}:
                            tv = repmat({tp},1,targetIdx-1);
                            tv{targetIdx} = targetVal;
                            targetObj.(targetProp) = tv;
                        else
                            targetObj.(targetProp) = targetVal;
                        end
                    else
                        % Property holds a numeric value.
                        targetObj.(targetProp)(targetIdx) = targetVal;
                    end
                end
            end
        end
        
        function menuChecked(p,h,prop)
            isOff = strcmpi(h.Checked,'off');
            if isOff
                h.Checked = 'on';
                p.(prop) = true;
            else
                h.Checked = 'off';
                p.(prop) = false;
            end
        end
        
        function menuAuto(p,h,prop)
            isOff = strcmpi(h.Checked,'off');
            if isOff
                h.Checked = 'on';
                p.(prop) = 'auto';
            else
                h.Checked = 'off';
                p.(prop) = 'manual';
            end
        end
        
        function [hm,hp] = createContextSubmenu(targetObj, ...
                make,addSeparator,hc, ...
                menuLabel,enumStrs,prop,enumVals)
            % Create a submenu context menu for an enumeration list,
            % associated with a property.
            %
            % make: logical scalar, true if we are creating new menu
            %    entries, false if just updating state of check marks.
            %
            % addSeparator: true if adding a separator to parent menuLabel
            % entry; if menuLabel empty, first entry in enums is given a
            % separator.
            %
            % hc: parent context menu
            %
            % menuLabel: parent menu label; several formats are supported.
            %   - empty: the set of enumerations will go directly into
            %             parent, and not into a submenu
            %   - string: label for the parent menu, choices go under this
            %   - 1x2 cell: {parentLabel,childLabel} specifies a label
            %             at the top of the submenu list.  parentLabel may
            %             still be empty.
            %
            % enumStrs: cell-string with all enumeration strings,
            %           can be just for display if enumVals passed.
            %
            % prop: property whose value will be updated; several formats
            %      are available to guide how the property is updated.
            %
            % Several formats for property assignment are available, based
            % on what is passed for "prop", and whether "enumVals" is
            % passed:
            %  - empty string
            %    No assignment of value to an object property occurs;
            %    no updating of check-mark on child menu is performed.
            %
            %  - string: name of property on targetObject
            %    Assigns value into a property of specified object.
            %        obj.(prop) = <new value>
            %
            %  - 1x2 cell: {prop,cellIdx}
            %    Assigns value into a cell-string property of a specified
            %    object:
            %        obj.(prop){cellIdx} = <new value>
            %
            %  - fcn handle
            %    Calls function with args:
            %       fcn(targetObj,hm,str,strs)
            %       fcn(targetObj,hm,str,strs,enumVals)
            %    idx is the selected context menu entry within enumStrs.
            %
            % enumVals: optional vector of values corresponding to
            %    each of the enumeration strings in enums; these values are
            %    set into propName.  Can be a (homogeneous) numeric vector
            %    or a cell-vector of alternative strings.
            %
            %    The latter is useful when displaying one string but using
            %    another string for the actual enum property (perhaps a
            %    string not worthy of display!).
            
            % First step: assign action callback
            %
            haveEnumVals = nargin > 7;
            if iscell(menuLabel)
                parentLabel = menuLabel{1};
                childLabel = menuLabel{2};
            else
                parentLabel = menuLabel;
                childLabel = '';
            end
            
            % "noSubmenu" means the child items are rendered in the parent
            % menu itself; this is triggered by specifying an empty label
            % for the parent menu.
            noSubmenu = isempty(parentLabel);
            
            % propName
            %  string: name of property on targetObj
            %  1x2 cell describing target property cell-string,
            %         {targetProp,targetIdx}
            if iscell(prop)
                targetProp = prop{1};
                targetIdx = prop{2}; % empty will work too
            else
                targetProp = prop;
                targetIdx = [];
            end
            
            if make
                if noSubmenu
                    % no submenu desired; render directly into parent
                    hp = hc;
                else
                    opts = {hc,parentLabel,[]};
                    if addSeparator
                        opts = [opts 'separator' 'on'];
                    end
                    hp = internal.ContextMenus.createContext(opts);
                end
                if ~isempty(childLabel)
                    opts = {hp,childLabel,[]};
                    if addSeparator && noSubmenu
                        opts = [opts 'separator' 'on'];
                        addSeparator = false; % don't do it twice!
                        addSepToFirstChild = false;
                    else
                        addSepToFirstChild = true;
                    end
                    hchildtitle = internal.ContextMenus.createContext(opts);
                    hchildtitle.Enable = 'off';
                else
                    addSepToFirstChild = false;
                end
                N = numel(enumStrs);
                clear hm
                for i = 1:N
                    opts = {hp,enumStrs{i},''};
                    if i==1 && (noSubmenu && addSeparator || ~noSubmenu && addSepToFirstChild)
                        % We would have added a separator at top, but there
                        % was no submenu parent entry - so we add separator
                        % here:
                        opts = [opts 'separator' 'on']; %#ok<AGROW>
                    end
                    hm(i) = internal.ContextMenus.createContext(opts); %#ok<AGROW>
                end
                hm(1).UserData = hm;
                if haveEnumVals
                    createContextSubmenu_installCB(targetObj,hm,enumStrs,prop,enumVals);
                else
                    createContextSubmenu_installCB(targetObj,hm,enumStrs,prop);
                end
            else
                % Not making new menus on this call, just updating
                %
                hp = []; % for return
                if noSubmenu
                    ht = hc;
                else
                    ht = findobj(hc.Children,'flat','Label',parentLabel);
                end
                htc = findobj(ht.Children,'flat','Label',enumStrs{1});
                assert(~isempty(htc));
                hm = htc.UserData;
                
                % Reinstall callbacks every time (even now, when
                % make=false) in case they contain context-dependent
                % variables. Those variables are possibly the 2nd arg
                % of prop, if that 2nd arg is passed.
                %
                if haveEnumVals
                    createContextSubmenu_installCB(targetObj,hm,enumStrs,prop,enumVals);
                else
                    createContextSubmenu_installCB(targetObj,hm,enumStrs,prop);
                end
            end
            
            % Second step: initialize menu (no menu selection by user)
            %
            % Update checkmark on menu items (don't assign to property!)
            %
            if ~isempty(targetProp)
                if ischar(targetProp) || (isstring(targetProp) && isscalar(targetProp))
                    % Initialization for a named property
                    %
                    % xxx shouldn't this just be a call to menuNChoices() ?
                    %     it would be, if it handled enumVals and
                    %     suppresses property changes
                    
                    val = targetObj.(targetProp);
                    if ~isempty(targetIdx) && ~ischar(val) && ~(isstring(val) && isscalar(val))
                        % If targetIdx too high, allow drop back to 1
                        idx = 1+rem(targetIdx-1,numel(val));
                        if iscell(val)
                            val = val{idx};
                        else
                            val = val(idx);
                        end
                    end
                    if haveEnumVals
                        if ischar(val) || (isstring(val) && isscalar(val))
                            sel = strcmpi(val,enumVals);
                        else
                            % support integer value comparisons
                            sel = val == enumVals;
                        end
                    else
                        % Could be a string, or a cell-string.
                        % If a cell-string, multiple selections can occur.
                        if iscellstr(val) || isstring(val)
                            [~,idx] = ismember(val,enumStrs);
                            sel = false(size(enumStrs)); % assume: numel(hm) == numel(enumStrs)
                            sel(idx) = true;
                        else
                            sel = strcmpi(val,enumStrs);
                        end
                    end
                    set(hm(sel),'Checked','on');
                    set(hm(~sel),'Checked','off');
                else
                    % Initialization for a fcn handle.
                    %
                    % (empty string means initialize, no assignment)
                    if haveEnumVals
                        feval(prop,targetObj,hm,'',enumStrs,enumVals);
                    else
                        feval(prop,targetObj,hm,'',enumStrs);
                    end
                end
            end
        end
        
        function ht = createContextMenuChecked(p,make,addSeparator, ...
                hc,menuLabel,prop)
            % Helper method that creates one context menu entry using
            % "menuChecked".  Caller must specify the name of a property
            % that takes a boolean value.
            %
            % propName can be a string to indicate a property name of
            % object p.  Alternatively, prop can be a cell with
            % {targetObj,targetProp}, so that a different object can be the
            % target of the menu choice.
            
            if ~isvalid(hc) % can happen during parent-destroy
                return % EARLY EXIT
            end
            
            if iscell(prop) % prop = {targetObj, targetProp}
                targetObj = prop{1};
                targetProp = prop{2};
            else
                targetObj = p;
                targetProp = prop;
            end
            
            if make
                opts = {hc,menuLabel, ...
                    @(h,~)internal.ContextMenus.menuChecked(targetObj,h,targetProp)};
                if addSeparator
                    opts = [opts 'separator' 'on'];
                end
                ht = internal.ContextMenus.createContext(opts);
            else
                ht = findobj(hc.Children,'flat','Label',menuLabel);
            end
            set(ht,'Checked',internal.LogicalToOnOff(targetObj.(targetProp)));
        end
        
        function createContextMenuAuto(p,make,addSeparator, ...
                hc,menuLabel,propName)
            % Helper method that creates one context menu entry using
            % "menuAuto".
            
            if make
                opts = {hc,menuLabel,@(h,~)internal.ContextMenus.menuAuto(p,h,propName)};
                if addSeparator
                    opts = [opts 'separator' 'on'];
                end
                ht = internal.ContextMenus.createContext(opts);
            else
                ht = findobj(hc.Children,'flat','Label',menuLabel);
            end
            ht.Checked = internal.LogicalToOnOff(strcmpi(p.(propName),'auto'));
        end
    end
end

%
% Private helper functions
%

function createContextSubmenu_installCB(targetObj,hm,strs,prop,enumVals)
% Install submenu selection/action callbacks for createContextSubmenu.

N = numel(hm);
haveEnumVals = nargin > 4;

if isa(prop,'function_handle')
    % Install function handle as callback
    %
    % Calls: fcn(targetObj,hm,str,strs)
    %    or: fcn(targetObj,hm,str,strs,enumVals)
    %
    for i = 1:N
        if haveEnumVals
            hm(i).Callback = @(~,~)feval(prop,targetObj,hm,strs{i},strs,enumVals);
        else
            hm(i).Callback = @(~,~)feval(prop,targetObj,hm,strs{i},strs);
        end
    end
else
    % Install menuNChoices callback based on property name
    %
    if haveEnumVals
        for i = 1:N
            % support both cell-string and vector for enumVals
            if iscell(enumVals)
                enumVals_i = enumVals{i};
            else
                enumVals_i = enumVals(i);
            end
            hm(i).Callback = @(~,~)internal.ContextMenus.menuNChoices(targetObj, ...
                hm,strs{i},strs,prop,enumVals_i);
        end
    else
        for i = 1:N
            hm(i).Callback = @(~,~)internal.ContextMenus.menuNChoices(targetObj, ...
                hm,strs{i},strs,prop);
        end
    end
end

end
