Code covered by the BSD License  

Highlights from
Tooltip Waitbar

image thumbnail
from Tooltip Waitbar by Geoffrey Akien
Displays a tooltip-sized waitbar beneath uicontrols.

tooltipwaitbar(hObject, progress, message, useContextMenu, usePercentLabel, abortAction)
function toolTipWaitBarHandle = tooltipwaitbar(hObject, progress, message, useContextMenu, usePercentLabel, abortAction)
% TOOLTIPWAITBAR displays a waitbar below a uicontrol.
%
%   toolTipWaitBarHandle = tooltipwaitbar(hObject)
%   Generates a waitbar below a uicontrol (hObject). Returns a 4 element
%   array for the static text boxes which make up the waitbar.  (1) is the
%   progress bar itself, (2) is the background to the waitbar, (3) is the
%   border, and (4) is a UIContextMenu (right-click menu) that can be used
%   which can hide the waitbar, and also gives information on the steps
%   going through (it logs changes of the message using
%   updatetooltipwaitbar). All can be modified using standard set/get
%   notation.  For example, change the colours with:
%       Progress bar: set(toolTipWaitBarHandle(1), 'BackgroundColor',
%       'red')
%       Progress background: set(toolTipWaitBarHandle(2),
%       'BackgroundColor', 'white')
%       Border: set(toolTipWaitBarHandle(3), 'BackgroundColor', 'black')
%
%   ... = tooltipwaitbar(hObject, progress)
%   Pre-defines a progress level of the waitbar, where the progress is any
%   number from 0 to 1, or, from 0 to 100 (whichever it is is dynamically
%   selected.  If left empty, the default is 0.
%
%   ... = tooltipwaitbar(hObject, progress, message)
%   Also supplies a message which appears in the ToolTip for the text box.
%   This can be empty (the default), or a string.
%
%   ... = tooltipwaitbar(hObject, progress, message, useContextMenu)
%   If useContextMenu evaluates to false (the default is true), the context
%   menu is disabled.  If useContextMenu is the handle to a uicontext menu
%   object, then this can be used instead.  CAUTION:  This will error if
%   you accidentally supply the number to a uicontrol that is not a
%   uicontextmenu.  It is recommended this is converted to a logical
%   beforehand if possible.
%
%   ... = tooltipwaitbar(hObject, progress, message, useContextMenu, usePercentLabel)
%   If usePercentLabel evaluates to true (the default is false), the
%   percent label is used.  This is a % label at the right alignment of the
%   progress bar, in white.
%
%   ... = tooltipwaitbar(hObject, progress, message, useContextMenu,
%   usePercentLabel, abortAction)
%   AbortAction specifies what to do when the user clicks on abort.
%   Because of the way it works, the process can only be aborted the next
%   time a callback tries to update the waitbar (e.g. in a loop).  It can
%   be true, 'error', or false.  If true, an abort status flag (retrieved
%   later, see how to update the waitbar) is changed.  If 'error', then the
%   waitbar will deliberately error after the waitbar has been redrawn as
%   normal.  If false (the default), nothing happens.
%
%   NOTE: As transparency is not supported by uicontrols, this looks very
%   bad for low progress stages, because the percent value is larger than
%   the text box, and text wrapping is on by default.  MATLAB appears to
%   have no tools for determining the size of text too, so finding this
%   out, then perhaps moving the percent label to the right of the progress
%   bar at low values does not appear to be possible.
%   
%   To change the label colour:
%       set(toolTipWaitBarHandle(1), 'ForegroundColor', 'yellow')
%
%   To change the label alignment:
%       set(toolTipWaitBarHandle(1), 'HorizontalAlignment', 'left')
%
%   To change the font size (8 is the default in MATLAB):
%       set(toolTipWaitBarHandle(1), 'FontSize', 10)
%
%   tooltipwaitbar(toolTipWaitBarHandle, progress)
%   abortStatus = tooltipwaitbar(toolTipWaitBarHandle, progress);
%   Updates the progress of the waitbar generated using tooltipwaitbar.  If
%   an output argument is supplied, the current abort status is given.
%   This will always be false if the abortAction was not specified when the
%   waitbar was created.
%
%   tooltipwaitbar(toolTipWaitBarHandle, progress, message)
%   Updates the waitbar, as well as the status message in the UIToolTip,
%   and if the context menu is on, updates that too.  If you only want to
%   change the message, progress can also be empty ([]).
%
%   tooltipwaitbar(toolTipWaitBarHandle)
%   Removes the waitbar from the figure.
%
%   More than one tooltip can be used on the same uicontrol without issue,
%   as long the handles are stored properly (the most recent one will be on
%   top of the stack.  No other data is stored. The waitbars stack rather
%   nicely too when a tooltipwaitbar is slaved to another tooltipwaitbar,
%   which makes for some quite interesting visual effects.  The
%   tooltipwaitbar is implemented as a series of overlapping text boxes
%   rather than axes/patch objects because axes are always on the bottom of
%   the UI element stack; if there are any other uicontrols nearby, the
%   text object is obscured.  The context menu to hide the waitbar merely
%   makes it invisible; it may cause problems later otherwise when other
%   functions attempt to clear up.
%
%   Code could apparently be cleaned up by splitting it into sub-functions,
%   but for me it is not immediately obvious where they could be split up.
%   I also considered adding a button to the right of the waitbar to abort
%   it, but it seems like that way it would be too easy to abort it by
%   accident.
%
%   The test GUI I use for new features is also bundled.
%
%
%   An example...
%
% % create a dummy GUI
% hObject = uicontrol('Style', 'pushbutton');
% 
% % create waitbar
% ttwb = tooltipwaitbar(hObject, 0, 'Starting...');
% 
% % calculation
% n = 1;
% x = 3;
% 
% % simple loop
% for m = 1:10 ^ x
%     % calculation
%     n = n + m;
%     pause(0.005)
%     
%     % update waitbar
%     tooltipwaitbar(ttwb, m / 10 ^ x);
% end
% 
% % finish at end
% tooltipwaitbar(ttwb)
% 
% % clear up
% clear('ttwb', 'n', 'm', 'x', 'hObject')


% checks the number of arguments
error(nargchk(1, 6, nargin))

% error checking for if the hObject is the tooltipwaitbar itself.
if numel(hObject) == 3 || numel(hObject) == 4
    % if it might be...
    if any(~ishandle(hObject)) || any(cellfun('isempty', strfind(lower(get(hObject, 'Tag')), 'tooltip')))
        % one of them doesn't have the string "tooltip" (case-insensitive)
        % in the tag, or isn't a handle object
        error('If a 3 or 4 element array is supplied, they must all be valid tooltipwaitbar handles.')
    end
    
else
    % check if for being a valid uicontrol instead
    if ~isscalar(hObject) || ~strcmp(get(hObject, 'Type'), 'uicontrol')
        % handle object must be a single uicontrol - although theoretically you
        % could use a uimenu, it would get a bit weird
        error('Handle graphics object must be a single uicontrol.')
    end
end
        
% check the progress
if nargin >= 2
    % check it
    if ~isempty(progress) && (~isnumeric(progress) || ~isreal(progress) || ~isscalar(progress) || isnan(progress) || progress < 0 || progress > 100)
        % if supplied, the progress must be a scalar from 0 to 1, or 100.
        error('Progress must be a real number from 0 to 1, or 0 to 100.')
    end

    % if the progress is empty, set it to 0
    if isempty(progress) || ~progress
        % set it to very small (can't do it as 0 or we get divide by zero
        % warnings)
        progress = eps;

    elseif progress > 1
        % normalise the progress from 0 to 100, to 0 to 1.
        progress = progress / 100;
    end
else
    % default
    progress = eps;
end

% the the message
if nargin >= 3
    % checks it
    if ~ischar(message) || size(message, 1) > 1
        % the message must be a string
        error('The message must be a string.')
    end

else
    % use a default
    message = '';
end
    
% preliminary checking of the context menu if its a handle object (we've
% already checked if its a positive scalar or not)
if nargin >= 4 && ~isempty(useContextMenu)
    % if it doesn't evaluate as true/false
    if nargin >= 4 && (~isscalar(useContextMenu) || (~isnumeric(useContextMenu) && ~islogical(useContextMenu)) || isnan(useContextMenu))
        % must be an expression which can evaluate as a logical
        error('UseContextMenu must be a valid logical scalar or the handle to a uicontextmenu.')
    end

    % if might be a context menu...
    if useContextMenu && ishandle(useContextMenu) && strcmp(get(useContextMenu, 'Type'), 'uicontrol')
        % check it some more
        if ~strcmp(get(useContextMenu, 'Style'), 'uicontextmenu')
            % error - it must be a valid context menu if it is the handle to a
            % uicontrol
            error('If useContextMenu is a valid uicontrol, it must be the handle to a uicontextmenu.')

        else
            % it is
            useContextMenuAsHandle = true;
        end

    else
        % its not
        useContextMenuAsHandle = false;
    end
    
else
    % define it as true by default
    useContextMenu = true;
    useContextMenuAsHandle = false;
end

%  checks the usePercentLabel flag
if nargin >= 5 && (~isscalar(usePercentLabel) || (~isnumeric(usePercentLabel) && ~islogical(usePercentLabel)) || isnan(usePercentLabel))
    % must be an expression which can evaluate as a logical
    error('UsePercentLabel must be a valid logical scalar.')
    
elseif nargin < 5
    % default
    usePercentLabel = false;
end

% and whether we are applying abort
if nargin >= 6 && ~isequal(abortAction, true) && ~isequal(abortAction, false) && ~isequal(abortAction, 'error')
    % must be valid
    error('The abort action must be true, false, or ''error''.')
    
elseif nargin < 6
    % default
    abortAction = false;
end

% shortcutting for updating and deleting the tooltip
if numel(hObject) == 3 || numel(hObject) == 4
    % if no additional arguments, then delete it
    if nargin <= 1
        % run the delete sub-function
        deletetooltipwaitbar(hObject)

    elseif nargin <= 2
        % depends in abort is on...
        if any(getappdata(hObject(1), 'abortAction'))
            % run the update routine, returning the info
            toolTipWaitBarHandle = updatetooltipwaitbar(hObject, progress);

        else
            % don't return it
            updatetooltipwaitbar(hObject, progress)
        end

    elseif nargin <= 3
        % same
        if any(getappdata(hObject(1), 'abortAction'))
            % run the update routine, return the info
            toolTipWaitBarHandle = updatetooltipwaitbar(hObject, progress, message);
    
        else
            % don't return it
            updatetooltipwaitbar(hObject, progress, message)
        end
        
    else
        % error
        error('Too many arguments to update the tooltip waitbar.')
    end
    
    % then return to the calling function
    return
end

% gets the parent figure/uipanel
parent = get(hObject, 'Parent');

% gets the old units of the uicontrol
oldUnits = get(hObject, 'Units');

% changes them to pixels so we can get them in those units
set(hObject, 'Units', 'pixels')

% get the size and position of the supplied uicontrol in pixels
position = get(hObject, 'Position');

% changes them back (would it be any quicker to do a quick strcmp before we
% change them back?)
set(hObject, 'Units', oldUnits)

% calculates the position of the tooltip starting in the bottom corner of
% the uicontrol - making it 75% of the height of the uicontrol so its clear
% that it is a subordinate GUI element
toolTipPosition = [position(1:3), 0.75 * position(4)];

% shifts it down 1 pixel to allow for the border of the tooltip waitbar
toolTipPosition(2) = toolTipPosition(2) - 1;

% need to adjust it a little if it is for a button
if strcmp(get(hObject, 'Style'), 'pushbutton')
    % shifts it forward a pixel and shrinks it (not sure exactly why this
    % is necessary, but it is)
    toolTipPosition(1) = toolTipPosition(1) + 1;
    toolTipPosition(3) = toolTipPosition(3) - 2;
end

% pre-allocates the handles - the uicontrols are created in reverse order
% so that everything is stacked correctly, and that way we don't have to
% reorder everything later
toolTipWaitBarHandle = zeros(3, 1);

% generates a text box one pixel wider to act as a border
toolTipWaitBarHandle(3) = uicontrol(    'Style', 'text',...
                                        'BackgroundColor', 'black',...
                                        'Parent', parent,...
                                        'Units', 'pixels',...
                                        'Position', [toolTipPosition(1:2) - 1, toolTipPosition(3:4) + 2],...
                                        'Visible', 'off',...
                                        'ToolTipString', message,...
                                        'Tag', 'toolTipWaitBarBorder');

% generates a static text uicontrol which will be the background to the
% progress bar, uses the default background colour
toolTipWaitBarHandle(2) = uicontrol(    'Style', 'text',...
                                        'Parent', parent,...
                                        'Units', 'pixels',...
                                        'Position', toolTipPosition,...
                                        'Visible', 'off',...
                                        'ToolTipString', message,...
                                        'Tag', 'toolTipWaitBarBackground');

% creates another text box to act as the progress bar
toolTipWaitBarHandle(1) = uicontrol(    'Style', 'text',...
                                        'BackgroundColor', 'blue',...
                                        'Parent', parent,...
                                        'Units', 'pixels',...
                                        'Position', [   toolTipPosition(1:2),....
                                                        toolTipPosition(3) * progress,...
                                                        toolTipPosition(4)],...
                                        'Visible', 'off',...
                                        'ToolTipString', message,...
                                        'Tag', 'toolTipWaitBarProgress');
                                    
% store the abort status
setappdata(toolTipWaitBarHandle(1), 'abortAction', abortAction)
setappdata(toolTipWaitBarHandle(1), 'abortStatus', false)

% if the percent label is going to be used, apply it
if nargin >= 5 && usePercentLabel
    % apply it - padding the end of the sprintf doesn't work ffs
    set(toolTipWaitBarHandle(1),    'HorizontalAlignment', 'right',...
                                    'ForegroundColor', 'white',...
                                    'String', sprintf('%.0f%%', progress * 100))
end

% if the context menu is to be used
if useContextMenu
    % if a valid uicontextmenu was supplied, use that
    if useContextMenuAsHandle
        % use that
        set(toolTipWaitBarHandle, 'UIContextMenu', useContextMenu)
        
        % also store it in toolTipWaitBarHandle for convenience
        toolTipWaitBarHandle(4) = useContextMenu;

    else
        % loops round until it finds a figure or if it was the root (to stop
        % loops) - we can't make the parents of UIContextMenus uipanels
        while ~strcmp(get(parent, 'Type'), 'figure') && parent
            % find the parent again
            parent = get(parent, 'Parent');
        end

        % only go futher if the parent figure was located, and not the root
        if parent
            % store the menu in the toolTipWaitBarHandle so it gets deleted
            % properly
            toolTipWaitBarHandle(4) = uicontextmenu('Parent', parent,...
                                                    'Tag', 'toolTipWaitBarContextMenu');

            % providing there was a message,...
            if ~isempty(message)
                % now generate UI menus with the current task on it (don't
                % need to store these, since they're in the children of the
                % uicontextmenu)
                uimenu( 'Parent', toolTipWaitBarHandle(4),...
                        'Label', message,...
                        'Enable', 'off',...
                        'Tag', 'toolTipWaitBarMenuItem')

                % define whether we want the separator or not (looks ugly
                % if there is only one menu item and a separator
                separator = 'on';

            else
                % turn it off
                separator = 'off';
            end
            
            % if the abort action is on...
            if abortAction
                % add an item for this
                uimenu( 'Parent', toolTipWaitBarHandle(4),...
                        'Label', 'Abort',...
                        'Callback', {@abortCallback, toolTipWaitBarHandle},...
                        'Separator', separator,...
                        'Tag', 'toolTipWaitBarAbortMenu')

                % then turn the separator off for the next part, so we
                % don't get two separators
                separator = 'off';
            end
                
            % generate the callback at the bottom to hide the waitbar
            uimenu( 'Parent', toolTipWaitBarHandle(4),...
                    'Label', 'Hide Progress Bar',...
                    'Callback', {@hideToolTipWaitBar, toolTipWaitBarHandle},...
                    'Separator', separator,...
                    'Tag', 'toolTipWaitBarHideMenu')

            % add the context menu to the tooltips
            set(toolTipWaitBarHandle(1:3), 'UIContextMenu', toolTipWaitBarHandle(4))
        end
    end
end

% animation parts - do in approximately 3 pixel steps - the MATLAB internal
% rounding will take care of things
animationSteps = round(toolTipPosition(4) / 3);
animationTime = 0.04;
animationStepTime = animationTime / animationSteps;

% defines the height (a little redundant, but good for pedagogic purposes)
height = toolTipPosition(4);

% loops to animate it
for m = 1:animationSteps
    % short pause if its not the first one
    if m ~= 1
        % short pause
        pause(animationStepTime)
    end
    
    % generates a new position
    newToolTipPosition = [  toolTipPosition(1),...
                            toolTipPosition(2) - (height * (m / animationSteps)),...
                            toolTipPosition(3),...
                            height * (m / animationSteps)];
                        
    % moves the border first
    set(toolTipWaitBarHandle(3), 'Position', [  newToolTipPosition(1:2) - 1,...
                                                newToolTipPosition(3:4) + 2])

    % change the position of the background
    set(toolTipWaitBarHandle(2), 'Position', newToolTipPosition)
    
    % the progress bar too
    set(toolTipWaitBarHandle(1), 'Position', [  newToolTipPosition(1:2),...
                                                newToolTipPosition(3) * progress,...
                                                newToolTipPosition(4)])
    
    % if its the first one, make them all visible too
    if m == 1
        % make it visible
        set(toolTipWaitBarHandle, 'Visible', 'on')
    end
end



% callback for hiding the waitbar if requested
function hideToolTipWaitBar(hObject, eventdata, toolTipWaitBarHandle)

% hides it (including itself)
set(toolTipWaitBarHandle, 'Visible', 'off')



% callback for aborting the action
function abortCallback(hObject, eventdata, toolTipWaitBarHandle)

% gets the tick status (this is not changed automatically - you need to do
% this yourself), and converts it to what it should be now
checkStatus = get(hObject, 'Checked');

% depends
if strcmp(checkStatus, 'on')
    % turn it off
    checkStatus = 'off';
    
    % revert
    message = 'Abort';
    
    % its not checked
    checkLogical = false;
    
else
    % turn it on
    checkStatus = 'on';
    
    % change
    message = 'Aborting...';
    
    % it is
    checkLogical = true;
end

% changes the tick status to the opposite of what it was, and change the
% message
set(hObject,    'Checked', checkStatus,...
                'Label', message)

% set the appdata to show that it errored
setappdata(toolTipWaitBarHandle(1), 'abortStatus', checkLogical)


function varargout = updatetooltipwaitbar(toolTipWaitBarHandle, progress, message)
% UPDATETOOLTIPWAITBAR changes the tooltip waitbar progress and message.

% gets the old units
oldUnits = get(toolTipWaitBarHandle(1:3), 'Units');

% changes them to pixels (I shouldn't HAVE to do this, but you never know)
set(toolTipWaitBarHandle(1:3), 'Units', 'pixels')

% gets the position of the tooltip (the background part)
toolTipPosition = get(toolTipWaitBarHandle(2), 'Position');

% and the progress bar
toolTipProgressPosition = get(toolTipWaitBarHandle(1), 'Position');

% finds the progress
currentProgress = toolTipProgressPosition(3) / toolTipPosition(3);

% defines the change
progressChange = progress - currentProgress;

% only do something if it has changed significantly (to reduce CPU load in
% intensive calculations)
if abs(progressChange) >= 0.005
    % change the label if necessary - uses the existence of a string in the
    % progress bar text box as proof that it needs to be changed.
    if ~isempty(get(toolTipWaitBarHandle(1), 'String'))
        % change it
        set(toolTipWaitBarHandle(1), 'String', sprintf('%.0f%%', progress * 100))
    end
    
    % animates the change
    animationSteps = abs(round(progressChange * 20));
    animationTime = 0.04;
    animationStepTime = animationTime / animationSteps;

    % for each step
    for m = 1:abs(animationSteps)
        % short pause if its not the first one
        if m ~= 1
            % short pause
            pause(animationStepTime)
        end

        % applies the new position
        set(toolTipWaitBarHandle(1), 'Position', [  toolTipPosition(1:2),...
                                                    toolTipProgressPosition(3) + (progressChange * toolTipPosition(3) * (m / animationSteps)),...
                                                    toolTipPosition(4)]);
    end
end

% restores the units
set(toolTipWaitBarHandle(1:3), {'Units'}, oldUnits)

% find out if their is an abort action
abortAction = getappdata(toolTipWaitBarHandle(1), 'abortAction');

% convert the message first if its there
if nargin >= 3
    % if there is a message, update the text object
    if ~isempty(message)
        % update the tooltip
        set(toolTipWaitBarHandle(1:3), 'ToolTipString', message)
        
        % gets the UI context menu
        contextMenu = get(toolTipWaitBarHandle(1), 'UIContextMenu');

        % if there is a context menu, start manipulating it
        if ~isempty(contextMenu)
            % generate a new item
            uimenu( 'Parent', contextMenu,...
                    'Label', message,...
                    'Enable', 'off')

            % get the children - they are stacked in REVERSE order
            menuItems = get(contextMenu, 'Children');

            % slightly different if abort is on
            if abortAction
                % the new one is on the top, and we want "Hide" to be the first one (so
                % last in the menu), then abort next
                menuItems(1:3) = menuItems([2:3, 1]);

                % save it back
                set(contextMenu, 'Children', menuItems)

                % then check the most recent item, only if it exists
                if numel(menuItems) > 3
                    % check it
                    set(menuItems(4), 'Checked', 'on')
                end

                % if there are now 3, then we need to enable the separator
                % on the last one (it appears ABOVE the menu item)
                if numel(menuItems) == 3
                    % enable it
                    set(menuItems(2), 'Separator', 'on')
                end

            else
                % the new one is on the top, and we want "Hide" to be the first one (so
                % last in the menu)
                menuItems(1:2) = menuItems(2:-1:1);

                % save it back
                set(contextMenu, 'Children', menuItems)

                % then check the most recent item, only if it exists
                if numel(menuItems) > 2
                    % check it
                    set(menuItems(3), 'Checked', 'on')
                end

                % if there are now 2, then we need to enable the separator
                if numel(menuItems) == 2
                    % enable it
                    set(menuItems(1), 'Separator', 'on')
                end
            end
        end
    end
end

% retrives the abort status
abortStatus = getappdata(toolTipWaitBarHandle(1), 'abortStatus');

% now the actual abort section
if isequal(abortAction, 'error') && abortStatus
    % then deliberately error
    error('Task was aborted.')
    
elseif any(abortAction)
    % return it if the abortAction is enabled
    varargout{1} = abortStatus;
end


function deletetooltipwaitbar(toolTipWaitBarHandle)
% DELETETOOLTIPWAITBAR clears the tooltip waitbar (animated).
%   If it errors at any point, it reverts to using DELETE to tidy up for
%   robustness.

% try-catched to always cleanup
try
    % sets the units back to pixels (don't need to be tidy this time)
    set(toolTipWaitBarHandle(1:3), 'Units', 'pixels')

    % gets the position of the background part
    toolTipPosition = get(toolTipWaitBarHandle(2), 'Position');

    % gets the position of the progress bar
    toolTipProgressPosition = get(toolTipWaitBarHandle(1), 'Position');

    % defines the progress
    progress = toolTipProgressPosition(3) / toolTipPosition(3);

    % defines the height
    height = toolTipPosition(4);

    % defines some default animation steps
    animationSteps = round(toolTipPosition(4) / 3);
    animationTime = 0.04;
    animationStepTime = animationTime / animationSteps;

    % loops to animate it (rolls it up)
    for m = 1:animationSteps - 1
        % short pause
        pause(animationStepTime)

        % defines the new position
        newToolTipPosition = [  toolTipPosition(1),...
                                toolTipPosition(2) + (height * (m / animationSteps)),...
                                toolTipPosition(3),...
                                toolTipPosition(4) - toolTipPosition(4) * (m / animationSteps)];

        % change the position
        set(toolTipWaitBarHandle(1), 'Position', [  newToolTipPosition(1:2),...
                                                    newToolTipPosition(3) * progress,...
                                                    newToolTipPosition(4)])
        set(toolTipWaitBarHandle(2), 'Position', newToolTipPosition)
        set(toolTipWaitBarHandle(3), 'Position', [  newToolTipPosition(1:2) - 1,...
                                                    newToolTipPosition(3:4) + 2])
    end

catch
    % give a warning
    warning('deleteToolTipWaitBar:cleanUpError', 'Tooltip may not have been cleaned up properly.')
end

% deletes them all (that aren't 0's) - done using a double not, because
% LOGICAL always gives a warning when values aren't 0 or 1
delete(toolTipWaitBarHandle(~~toolTipWaitBarHandle))

Contact us at files@mathworks.com