classdef (Sealed) RecentFilesList < handle
%RecentFilesList Maintain a list of recent files.
%   HRF = spcwidgets.RecentFilesList(GRP) instantiates a RecentFilesList
%   object that manages a list of recently opened files or projects.  The
%   list is persistent and stored in a user preference  The entries can be
%   any string, and not just the names of files or projects.
%
%   A group name GRP must be specified during the constructor call; see
%   SETPREF for details on preference group naming conventions. The group
%   name should be specific to the application, and may be the name of a
%   preference group already in use by the application. This preference
%   group is used to store the list of recent files. The name of the file
%   list, used within this preference group, may optionally be specified by
%   a second argument to the constructor,
%         obj = spcwidgets.RecentFilesList(GRP,PRF)
%   By default, PRF='RecentFiles'.  Changing the file list name is useful
%   if more than one file list is utilized within one application.
%
%   Whenever the application opens a file that should be tracked by this
%   list, set the MostRecent property to add the file. If the file is
%   already in the list, it is recalled to the top of the list and is not
%   added again to the list.
%
%   The maximum number of files in the list can be changed, initially or
%   during use, by the Max property.  List entries can be obtained using
%   the AllRecent and MostRecent property.
%
%   The RecentFilesList object can automatically manage a collection of
%   uimenu entries to make the file history more easily available on menus.
%   See the connectMenu method for more details.
    
    %   Copyright 2009-2019 The MathWorks, Inc.

    properties (SetAccess = private)     
        PrefsGroup = 'RecentFilesPrefs';
        PrefsRecentFiles = 'RecentFiles';
    end

    properties (SetAccess = private, Hidden)
        Menus;
    end
    
    properties (Access = private)
        InitialMenu;
        LoadFileObj;
    end
    
    properties
        SelectedItem;
        Max = 10;
        LoadCallback;
        SaveCallback;
        EmptyListMsg = uiscopes.message('TextNoRecentFiles');
        % ---------------------------------------------------
        % Properties for maintaining recently opened file
        % for file browser dialog

        LoadFileTitle = uiscopes.message('TextChooseFile');
        SaveFileTitle = uiscopes.message('TextSaveFile');
        FileFilterSpec = {'*.txt',uiscopes.message('TextTextFiles'); ...
                  '*.*',  uiscopes.message('TextAllFiles')};
    end
    
    properties (Dependent)
        InitialDirectory;
        MostRecent;
    end
    
    properties (SetAccess = private, Dependent)
        AllRecent;
    end        

    methods

        function this = RecentFilesList(grp,prf)

            mlock;
            
            % A preferences group name must be specified
            % during constructor call
            if nargin<1
                error(message('Spcuilib:uimgr:ErrorNoPrefsGroupSpecified'));
            end
            
            this.PrefsGroup = grp;
            if nargin>1
                this.PrefsRecentFiles = prf;
            end
        end
        
        % -----------------------------------------------------------------
        function delete(this)
            %delete Delete this object
            
            % Close all the menu items.
            disconnectMenus(this);
        end
        
        % -----------------------------------------------------------------
        function createMenus(h, mItem) %AllocMenus(h,mItem)
            %creatMenu Creates/reallocates uimenu objects
            %   h.creatMenu(mItem) creates or re-creates uimenu objects corresponding
            %   to recent file list.  Disconnects any menus currently connected to
            %   object.
            
            if nargin > 1
                
                % Check that mItem is a uimenu                
                if ~uiscopes.isHandle(mItem) || ~strcmp(get(mItem,'Type'),'uimenu')
                    error(message('Spcuilib:uimgr:ErrorInvalidMenuItemHandle'));
                end
                
                mParent = get(mItem,'Parent');    % parent of first menu item
                
                % We cannot install the file list into the top-level toolbar
                if ~strcmp(get(mParent,'Type'),'uimenu')
                    error(message('Spcuilib:uimgr:ErrorInvalidMenuLevel'));
                end
                
            else
                mItem = h.InitialMenu;
                if isempty(mItem) || ~ishghandle(mItem)
                    return;
                end
                mParent = get(mItem,'Parent');    % parent of first menu item
                
            end
            
            % Do not attempt disconnection until we're reasonably sure
            % we can successful connect (i.e., put after error-checking above)
            if ~isempty(h.InitialMenu)
                h.disconnectMenus; % disconnect existing menus
            end
            % Cache user's initial menu
            % Non-empty signifies object's control of a current menu system
            h.InitialMenu = mItem;

            firstPos = get(mItem,'Position'); % ordinal of first menu item
            
            % Preallocate space for maximum # of uimenu handles
            N = h.Max;            
            h.Menus = cell(1,N);
            
            % Take over original menu, add additional
            %
            % Create Nmax-1 additional menus, assuming a maximum of Nmax items in list
            % The strategy is to make invisible the unused menu entries
            %
            % If the first menu item in the controlled menu list happens
            % to be 1st in the menu, don't use a separator ... there's
            % nothing to separate it from!
            if firstPos==1
                sep='off';
            else
                sep='on';
            end
            label_fmt = '%d <empty>';
            h.Menus{1} = mItem;
            set(mItem,...
                'Separator',sep, ...
                'Label',sprintf(label_fmt,1), ...
                'Visible','on');
            for i=2:N
                % Create additional menu items
                h.Menus{i} = uimenu('Parent',mParent, ...
                    'Position',firstPos-1+i, ...
                    'Label',sprintf(label_fmt,i), ...
                    'Visible','off');
            end
            
            % Set the callback of the parent menu to update the child menus
            %
            % This should be done as a last step, once the menus have been
            % created.  Now, whenever the parent menu is opened, the child
            % menus are updated to the latest global list.  This way, multiple
            % clients (GUI instances) can coordinate with one global list of
            % recent items.
            %
            oldCB = get(mParent, 'Callback');
            if isempty(oldCB)
                set(mParent,'Callback',makeUpdateMenusCallback(h));
            else
                % If there is already a callback, make sure to call the old
                % callback and the new callback.
                set(mParent,'Callback',makeNestedUpdateMenusCallback(h, oldCB));
            end                
        end
        
        % -----------------------------------------------------------------
        function disconnectMenus(h)
            %disconnectMenus Disconnect object from user menu system.
            
            % InitialMenu property indicates whether we are currently
            % managing any user menus:
            %
            if ~isempty(h.InitialMenu)
                % 1st entry is Menu is a copy of the original (user) menu
                %  - do not delete user's menu instance!
                %  - remove callback, reset label
                %  - make visible, but disable it
                amiHandleObject = false;
                if (ishghandle(h.Menus{1}))
                    amiHandleObject =  true;
                elseif (ishandle(h.Menus{1}))
                    amiHandleObject =  true;
                elseif (isobject(h.Menus{1}) && isvalid(h.Menus{1}))
                    amiHandleObject =  true;
                end
                if amiHandleObject
                    set(h.Menus{1}, ...
                        'Label','<no longer used>', ...
                        'Callback','', ...
                        'Enable','off', ...
                        'Visible','on');
                    
                    % All other menus in list were added by this object
                    %  - delete them
                    delete([h.Menus{2:end}]);
                end
                
                % Reset list of menu handles, removing references to the
                % deleted menus, and to the user-specific menu handle
                h.Menus = [];
                
                % Remove reference to initial menu, signifying that we
                % no longer have an active connection to user menus
                h.InitialMenu = [];
            end
        end
        
        % -----------------------------------------------------------------
        function [fname,cancel] = chooseFile(h)
            %chooseFile Opens file choice dialog, executes file callback
            %  [fname,cancel] = chooseFile(h) opens file chooser dialog.
            %  If not cancelled, file is added to recent file list.
            %
            %  Maintains recently opened file name to have subsequent selections
            %  automatically open the file browser the same directory.
            
            % Setup LoadFile object before opening dialog
            % in case these properties have recently changed
            %
            c = h.LoadFileObj;
            if isempty(c)
                c = spcwidgets.LoadFile;
                h.LoadFileObj = c;
            end
            c.FilterSpec = h.FileFilterSpec;
            c.Title      = h.LoadFileTitle;
            
            c.InitialDir = h.InitialDirectory;
            
            cancel = ~c.select;  % use "choose file" UI
            if cancel
                fname='';
            else
                % Store selected directory as most recent "selected file"
                h.InitialDirectory = c.InitialDir;
                
                % Record file as the most recent item chosen
                fname = c.fullfile;
                h.SelectedItem = fname;
                h.MostRecent = fname;
                
                % Execute callback, with no additional args supplied here, if present
                cbFcn = h.LoadCallback;
                if ~isempty(cbFcn)
                    cbFcn();
                end
            end
        end
        
        % -----------------------------------------------------------------
        function [fname,cancel] = saveFile(h)
            %saveFile Opens file save dialog, executes file callback
            %  [fname,cancel] = saveFile(h) opens file save dialog.
            %  If not cancelled, file is added to recent file list.
            %
            %  Maintains recently saved file name to have subsequent selections
            %  automatically open the file browser the same directory.
            
            % Setup LoadFile object before opening dialog
            % in case these properties have recently changed
            %
            c = h.LoadFileObj;
            if isempty(c)
                c = spcwidgets.LoadFile;
                h.LoadFileObj = c;
            end
            c.FilterSpec = h.FileFilterSpec;
            c.Title      = h.SaveFileTitle;
            
            c.InitialDir = h.InitialDirectory;
            cancel = ~c.select(true);  % use "save file" UI
            if cancel
                fname='';
            else
                % Store selected directory as most recent "selected file"
                h.InitialDirectory = c.InitialDir;  % updated with new path
                
                % Log this as the most recent choice
                fname = c.fullfile;
                h.SelectedItem = fname;  % stash in RecentFilesList object
                
                h.MostRecent = fname;
                
                % Execute callback, with no additional args supplied here, if present
                cbFcn = h.SaveCallback;
                if ~isempty(cbFcn)
                    cbFcn();
                end
            end
        end
        
        % -----------------------------------------------------------------
        function set.Max(this, newMax)
            this.Max = newMax;
            
            createMenus(this)
        end
        
        % -----------------------------------------------------------------
        function items = get.MostRecent(h)
            %get.MostRecent Returns name of most recent items
            %   If no recent item (i.e., list is empty), returns empty
            
            % This is the full list, including empty slots
            items = h.AllRecent;
            if isempty(items)
                items = '';
            else
                items = items{1};
            end
        end
        
        % -----------------------------------------------------------------
        function set.MostRecent(h,item)
            %set.MostRecent Sets most recent item
            %   If new, it adds it to the list
            %   If existing, it moves it to the top of the list
            
            % Note: we do not check for the existence of the item
            %       (presuming it's a file name)
            %       the item is recorded verbatim; the caller
            %       should use "which(file)" to pass a fully qualified
            %       path name if desired
            %
            % There are two supported items:
            %    - char strings, like 'filename' (could be empty strings)
            %    - cells, could be empty cells
            %      if not empty, the first entry of the inner cell must
            %      be a string ... this string is used for comparisons
            %      to determine "same" or "different"
            
            if isempty(item)
                error(message('Spcuilib:uimgr:ErrorAddingEmptyItem'));
            end
            
            % Get all files, including empties, to maintain
            % the list length
            maxItems = h.Max;
            items = h.AllRecent;
            nItems = numel(items);
            
            % Two cases:
            %   - strings
            %   - cells with 1st entry a string
            if iscell(item)
                % item is a cell
                % assume 1st entry in cell is comparison string
                item_name = item{1};
                all_names = getItemNames(items);
                idx = find(strcmp(item_name,all_names));
            else
                % item is a simple string
                idx = find(strcmp(item,items));
            end
            
            if isempty(idx)
                % No match (new file, or empty list)
                % Add to top of list, drop off last/oldest item
                if nItems > maxItems
                    items = [{item}; items(1:maxItems-1)];
                elseif nItems == maxItems
                    items = [{item}; items(1:nItems-1)];
                else
                    items = [{item}; items];
                end
            else
                % Single match - reorder list
                % Setup index vector
                % Example: 4th entry is now first
                %   original: [1 2 3 4 5 6]
                %        new: [4 1 2 3 5 6]
                %
                % If multiple matches, duplicates already exist in list
                % Remove all duplicates, moving just one match to top of list
                all_idx = 1:nItems;
                all_idx(idx) = [];  % remove all dupes in list - usually, one
                all_idx = [idx(1) all_idx];  % add first matching index
                items = items(all_idx);
            end
            h.AllRecent = items;
        end
        
        % -----------------------------------------------------------------
        function set.InitialDirectory(this, initDir)
            % updated with new path
            setpref(this.PrefsGroup,'RecentChooseFile',initDir);
        end
        
        % -----------------------------------------------------------------
        function initDir = get.InitialDirectory(this)

            grp = this.PrefsGroup;
            prf = 'RecentChooseFile';

            % Initialize PrefsRecentChooseFile
            %
            addnewpref = false;
            if ispref(grp,prf)
                % exists - check that it's valid
                initDir = getpref(grp,prf);
                if ~ischar(initDir) && ~(isstring(initDir) && isscalar(initDir))
                    addnewpref = true;
                end
            else
                addnewpref = true;
            end
            if addnewpref
                initDir = fullfile(pwd,filesep);
                setpref(grp,prf,initDir);
            end
        end
        
        % -----------------------------------------------------------------
        function set.PrefsGroup(h, grp)
            % Set the name of the preferences group
            % It should not be changed during use of the class instance
            if ~ischar(grp) && ~(isstring(grp) && isscalar(grp))

                error(message('Spcuilib:uimgr:ErrorPreferenceGroupNotString'));
            end
            h.PrefsGroup = grp;
        end
        
        % -----------------------------------------------------------------
        function set.PrefsRecentFiles(h,prf)
            % Set the name of the file list preference
            % It should not be changed during use of the class instance
            if (~ischar(prf) && ~(isstring(prf) && isscalar(prf)))  && ~(isstring(prf) && isscalar(prf))
                error(message('Spcuilib:uimgr:ErrorFileListPreferenceNotString'));
            end
            h.PrefsRecentFiles = prf;
        end
        
        % -----------------------------------------------------------------
        function items = get.AllRecent(h)
            %get.AllRecent Returns list of all recent items in list.
            
            % This is the full list, including empty slots
            grp = h.PrefsGroup;
            prf = h.PrefsRecentFiles;
            if ispref(grp, prf)
                items = getpref(grp, prf);
                
                % Remove items up to the Max.
                items = items(1:min(end, h.Max));
            else
                items = {};
            end
            
            % Remove from the first empty entry to the end of the list.
            % Empties used to denote prepopulated entries, but we no longer
            % need to prepopulate.
            %
            % Note:
            %   - items can be a simple cell-array of strings, such
            %     as file names, along with unused/empty entries
            %     e.g., {'file1','file2','',''}
            %
            %   - items can be more complex cell-array of cells,
            %     as long as the inner cells have the first entry
            %     as a string.  The string is used to populate the
            %     menus.  If an entry is unused, the inner cell
            %     must be empty so that the 'isempty' test below
            %     properly registers empty entry.
            %     { {'item1',data1}, {'item2',data2}, {}, {} }
            %
            i = find(cellfun('isempty',items),1);
            if ~isempty(i)  % no empty slots - all are being used
                items = items(1:i-1);
            end
        end
        
        % -----------------------------------------------------------------
        function set.AllRecent(h,items)
            %set.AllRecent Set the list back into preferences
            
            % Set the list of files into the preference
            grp = h.PrefsGroup;
            prf = h.PrefsRecentFiles;
            setpref(grp,prf,items(1:min(end, h.Max)));
            
        end
    end
end

function nestedUpdateMenus(h, ev, this, oldCB)

feval(oldCB, h, ev);
updateMenus(this);
end

% -------------------------------------------------------------------------
function updateMenus(h)
%updateMenus - PRIVATE METHOD
% Get list of recent files
% Make visible this many menus,
%   and update labels and callbacks accordingly

if isempty(h.InitialMenu)
    % This is not an error; the object makes calls here
    % from various points, regardless of menu connection state
    %
    % At this point, we know we're not connected to any user menus
    return
end
items = h.AllRecent;
nItems = numel(items);
if nItems > numel(h.Menus)
    error(message('Spcuilib:uimgr:ErrorFileListExceedsMenus'));
end

if nItems==0
    % No items - display one disabled menu
    set(h.Menus{1}, ...
        'Visible','on', ...
        'Enable','off', ...
        'Label',['1 ' h.EmptyListMsg]);
    % Turn off all remaining menus
    set([h.Menus{2:end}],'Visible','off');
else
    itemNames = getItemNames(items);
    for i=1:nItems
        dispName = uiservices.strTruncate(itemNames{i},25,3);
        
        labelStr = sprintf('%d %s', i, dispName);
        % Add menu accelerator char before 1st (numeric) digit,
        % for numbers 1-9.  "10 <name>" starts with "1", and would
        % be ambiguous with "1 <name>", hence the limit.
        if i < 10
            labelStr = ['&' labelStr]; %#ok
        end
        
        set(h.Menus{i}, ...
            'Callback', makeRecentFileCallback(h, i), ...
            'Visible','on', ...
            'Enable','on', ...
            'Label',labelStr);
    end
    % Turn off all remaining menus
    set([h.Menus{nItems+1:end}],'Visible','off');
end
end

% -------------------------------------------------------------------------
function cb = makeRecentFileCallback(h, i)

cb = @(~,~) localRecentFileMenuCb(h,i);
end

% -------------------------------------------------------------------------
function localRecentFileMenuCb(h,idx)
% Execute LoadCallback, if present
%
% NOTE: It is up to the callback to call
%            h.MostRecent = item
%       with item pulled from .SelectedItem

% Get item from index
h.SelectedItem = h.AllRecent{idx};  % stash in RecentFilesList object

% Execute callback, with no additional args supplied here, if present
cbFcn = h.LoadCallback;
if ~isempty(cbFcn)
    cbFcn();
end
end

% -------------------------------------------------------------------------
function itemNames = getItemNames(items)
% Return cell-array of item names
%
% items is a cell-array
% entries are either strings, in which case we return
%   the item list it as-is
% entries can be cells, in which case we return the
%   first entry from each cell, presumably these are
%   string names (as long as the cell is not empty)
%
% Note: even if items contains inner cells, some may
%    be chars ... empty chars.  Empty chars are used
%    sometimes in place of empty cells.  So first,
%    to check it items contains cells itself, we must
%    check for "any" cells at all ... not just if the
%    1st entry is a cell.  Similarly, just because the
%    1st entry may be a char, doesn't mean everything
%    is a char.  Cells and empty chars may be mixed.

itemNames = items;
for i=1:numel(items)
    thisItem = items{i};
    if iscell(thisItem) && ~isempty(thisItem)
        itemNames{i} = thisItem{1};
    end
end
end

% -------------------------------------------------------------------------
function cb = makeUpdateMenusCallback(this)
cb = @(~,~) updateMenus(this);

end

% -------------------------------------------------------------------------
function cb = makeNestedUpdateMenusCallback(this, oldCB)
cb = @(h,ev) nestedUpdateMenus(h, ev, this, oldCB);

end

% [EOF]