classdef BannerMessage < handle
    % Puts a banner message at the top or bottom of a figure window.
    %
    % Supports time duration to display message.
    %
    % Only one BannerMessage object is created per Parent figure;
    % subsequent attempts to create a BannerMessage object with the same
    % Parent will return a reference to the existing BannerMessage object.
    
    properties (AbortSet)
        BackgroundColor = [253 231 65]./255
        FontSize = 10
        ForegroundColor = [82 31 22]./255
        HighlightColor = 'k'
        Location = 'bottom'
        Parent
        RemainFor = 5   % String gets hidden after RemainFor seconds
        String = 'Message';
    end
    
    properties (Access = private)
        hTipText
        hTipPanel
        hTipClose
        pExtent
        pLoc % cached value of location currently in use
        hListeners
        hRemainForTimer
        
        % States:
        %   0=stopped/invis
        %   1=Vis, RemainFor timer running
        pState = 0
    end
    
    methods
        function obj = BannerMessage(parent)
            
            if nargin < 1
                parent = gcf;
            end
            obj.Parent = parent;
            obj = init(obj); % Return instance from figure
        end
        
        function release(obj)
            % Remove a client reference to BannerMessage.
            %
            %  - We keep a single instance per figure, so clients should
            %    NOT call delete; clients should call release(obj)
            %  - We stop any active banner displays
            %
            %  - Ideally, BannerMessage instances should be
            %    reference-counted to know when all clients are done with
            %    the BannerMessage instance in the figure; we do NOT do
            %    this currently
            %
            %  - Alternative (what we do today): don't remove BannerMessage
            %    instance from figure; since this was needed once in a
            %    figure, it's likely to be needed again
            
            % Stop any active banner displays.  They might NOT be from this
            % client, but there's no way to tell.
            stop(obj);
        end
        
        function delete(obj)
            % Remove the single instance from the figure
            
            destroyInstance(obj);
        end
        
        function start(obj,str)
            % Show string after ShowAfter delay.
            % Optional input sets new string.
            % Repeated calls restart the timer.

            % User or timer called us.
            % Stop RemainFor timer, if it's running.
            stop(obj.hRemainForTimer);
            
            % Configure timer once stopped
            obj.hRemainForTimer.Duration = obj.RemainFor;
            
            % Update location if it changed
            if ~strcmpi(obj.pLoc,obj.Location)
                updateBannerWidgetPos(obj);
            end
            
            if nargin > 1
                obj.String = str;
            else
                str = obj.String;
            end
            if isempty(str)
                % Skip show/hide cycle.
                obj.pState = 0; % invis
                hideTip(obj);
            else
                obj.pState = 1; % RemainFor running
                showTip(obj);
                start(obj.hRemainForTimer);
            end
        end
        
        function stop(obj)
            % Make String invisible immediately.
            
            stopAllTimers(obj);
            hideTip(obj);
            obj.pState = 0; % Enter Stopped state
        end
        
        function set.String(obj,val)
            if ~iscellstr(val) && ~isstring(val)
                validateattributes(val,{'char', 'string'},{}, ...
                    'BannerMessage','String');
            end
            obj.String = val;
            updateString(obj); % side-effect
        end
        
        function set.FontSize(obj,val)
            validateattributes(val,{'double'}, ...
                {'real','scalar','>',0}, 'BannerMessage','FontSize');
            obj.FontSize = val;
            updateFontSize(obj);
        end
        
        function set.Location(obj,val)
            obj.Location = validatestring(val,{'Top','Bottom'}, ...
                'BannerMessage','Location');
        end
        
        function set.RemainFor(obj,val)
            validateattributes(val,{'double'}, ...
                {'real','scalar','nonnegative'}, ...
                'BannerMessage','RemainFor');
            obj.RemainFor = val;
        end
        
        function set.ForegroundColor(obj,val)
            if ischar(val)
                validateattributes(val,{'char'},{'row'}, ...
                    'BannerMessage','ForegroundColor');
            else
                validateattributes(val,{'numeric'}, ...
                    {'real','size',[1 3]}, ...
                    'BannerMessage','ForegroundColor');
            end
            obj.ForegroundColor = val;
            updateColor(obj);
        end
        
        function set.HighlightColor(obj,val)
            if ischar(val)
                validateattributes(val,{'char'},{'row'}, ...
                    'BannerMessage','HighlightColor');
            else
                validateattributes(val,{'numeric'}, ...
                    {'real','size',[1 3]}, ...
                    'BannerMessage','HighlightColor');
            end
            obj.HighlightColor = val;
            updateColor(obj);
        end
        
        function set.BackgroundColor(obj,val)
            if ischar(val)
                validateattributes(val,{'char'},{'row'}, ...
                    'BannerMessage','BackgroundColor');
            else
                validateattributes(val,{'numeric'}, ...
                    {'real','size',[1 3]}, ...
                    'BannerMessage','BackgroundColor');
            end
            obj.BackgroundColor = val;
            updateColor(obj);
        end
    end
    
    methods (Access = private)
        function retObj = init(obj)
            % Initialize for new client.
            %
            % If a ToolTip object is already associated with the parent
            % figure, reuse that object - only one BannerMessage is needed
            % per figure.  Do NOT reinitalize or change state in this case.
            
            name = 'internal_BannerMessage';
            p = obj.Parent;
            retObj = getappdata(p,name);
            if isempty(retObj) || ~isvalid(retObj)
                setappdata(p,name,obj);
                
                initWidgets(obj);
                initTimers(obj);
                initListeners(obj);
                
                obj.pState = 0; % in Stopped/Disabled state
                resize(obj);
                retObj = obj;
            end
        end
        
        function removeFromFigure(obj)
            % Remove instance from figure, and destroy instance.
            
            destroyInstance(obj);
        end
        
        function destroyInstance(obj)
            % Delete this instance including all resources.
            %
            % Called from delete when ref-counting, etc, are considered.
            %
            % Removes instance from figure.
            
            deleteListeners(obj);
            deleteWidgets(obj);
            deleteTimers(obj);
        end
        
        function hideTip(obj)
            if ~isempty(obj.hTipPanel) && ishghandle(obj.hTipPanel)
                obj.hTipPanel.Visible = 'off';
            end
        end
        
        function showTip(obj)
            if ~isempty(obj.String) ...
                    && ~isempty(obj.hTipPanel) && ishghandle(obj.hTipPanel)
                obj.hTipPanel.Visible = 'on';
            end
        end
        
        function deleteTimers(obj)
            % Destroy timers
            stopAllTimers(obj);
            delete(obj.hRemainForTimer);
        end
        
        function stopAllTimers(obj)
            % Stops all timers.
            % Returns state prior to stop.
            
            % Shut down the ShowAfter timer first, as we don't want it to
            % complete and "flash up" the text if it's not already shown.
            if ~isempty(obj.hRemainForTimer) && isvalid(obj.hRemainForTimer)
                stopAndWait(obj.hRemainForTimer);
            end
        end
        
        function initTimers(obj)
            obj.hRemainForTimer = internal.TimeoutTimer( ...
                @(~,~)remainForTimeoutFcn(obj), obj.RemainFor);
        end
        
        function remainForTimeoutFcn(obj)
            % RemainFor timer expired.
            % Hide coaching tip.
            % Start RemindAfter timer.
            
            hideTip(obj);
            obj.pState = 0; % banner hidden, RemainFor finished
        end
        
        function deleteWidgets(obj)
            
            h = obj.hTipPanel;
            if ~isempty(h) && ishghandle(h)
                delete(h);
                obj.hTipPanel = [];
                obj.hTipText = []; % gets deleted with parent
                obj.hTipClose = [];
            end
        end
        
        function initWidgets(obj)
            obj.hTipPanel = uipanel( ...
                'Parent',obj.Parent, ...
                'HandleVisibility','off', ...
                'BorderType','line', ...
                'Units','pixels', ...
                'Position',[1 1 1 1], ...
                'BackgroundColor',obj.BackgroundColor, ...
                'HighlightColor',obj.HighlightColor, ...
                'Visible','off');
            
            % key point: set the (x,y) start to (1,1) for hTipText, as it
            % doesn't get set/touched anywhere else.  However dx and dy
            % don't matter.
            obj.hTipText = uicontrol( ...
                'Parent',obj.hTipPanel, ...
                'HandleVisibility','off', ...
                'Style','text', ...
                'BackgroundColor',obj.BackgroundColor, ...
                'ForegroundColor',obj.ForegroundColor, ...
                'HorizontalAlignment','left', ...
                'FontSize',obj.FontSize, ...
                'Units','pixels', ...
                'Position',[1 1 1 1], ...
                'Visible','on');
            
            ico = load('internal/BannerMessageIcons');
            obj.hTipClose = uicontrol( ...
                'Parent',obj.hTipPanel, ...
                'HandleVisibility','off', ...
                'Style','checkbox', ...
                'Callback',@(~,~)userCloseBanner(obj), ...
                'BackgroundColor',obj.BackgroundColor, ...
                'CData',ico.x16x16, ...
                'Units','pixels', ...
                'Position',[1 1 1 1], ...
                'TooltipString','Close', ...
                'String','');
            
            updateFontSize(obj,false);
            updateString(obj); % formats the string
        end
        
        function userCloseBanner(obj)
            stop(obj);
        end
        
        function initListeners(obj)
            lis.FigDestroy = addlistener(obj.Parent,'ObjectBeingDestroyed',@(~,~)removeFromFigure(obj));
            lis.Resize = addlistener(obj.Parent,'SizeChanged',@(~,~)resize(obj));
            obj.hListeners = lis;
        end
        
        function deleteListeners(p)
            % Delete all listeners
            try %#ok<TRYNC>
                % can't use "if isvalid(p)", since we're likely within a
                % delete() call and isvalid() is false by definition.
                p.hListeners = internal.polariCommon.deleteListenerStruct(p.hListeners);
            end
        end
        
        function resize(obj)
            
            obj.pExtent = [];
            obj.hTipText.String = obj.String;
            
            updateBannerWidgetPos(obj);
            updateWidgetEllipsis(obj);
        end
        
        function updateColor(obj)
            obj.hTipText.ForegroundColor  = obj.ForegroundColor;
            obj.hTipText.BackgroundColor  = obj.BackgroundColor;
            obj.hTipPanel.BackgroundColor = obj.BackgroundColor;
            obj.hTipPanel.HighlightColor  = obj.HighlightColor;
        end
        
        function ext = setExtent(obj)
            % Returns ext = [dx dy] ... NOT [x y dx dy].
            %
            % This is an expensive operation in terms of execution time due
            % to the time for extent retrieval by HG.
            
            % Cell-string and char matrix need fixups for extent. Use
            % processed string, which is in hTipText.String, which may have
            % turned multi-line into single-line
            
            ht = obj.hTipText;
            s = ht.String; % updated string is here
            if iscellstr(s) || isstring(s)
                % Maximum dx, sum of dy
                dx = 0;
                dy = 0;
                Nlines = numel(s); % one line of text per entry
                for i = 1:Nlines
                    ht.String = s{i};
                    e_i = ht.Extent;
                    dx = max(dx,e_i(3));
                    
                    % Correction in dy is 6 pixels per row
                    % (3 pix above, 3 pix below)
                    dy = dy + e_i(4) - 6;
                end
                ext = [dx dy];
                ht.String = s; % restore
            else
                Nlines = size(s,1); % one line per row of matrix
                if Nlines == 1
                    % Simple one-row string
                    ext = ht.Extent(3:4) - [0 3];
                else
                    % Matrix - maximum dx, sum of dy
                    dx = 0;
                    dy = 0;
                    for i = 1:Nlines
                        ht.String = strtrim(s(i,:));
                        e_i = ht.Extent;
                        dx = max(dx,e_i(3));
                        dy = dy + e_i(4) - 6;
                    end
                    ext = [dx dy];
                    ht.String = s; % restore
                end
            end
            obj.pExtent = ext;
        end
        
        function ext = getExtent(obj)
            % Returns [dx dy], NOT [x y dx dy]
            ext = obj.pExtent;
            if isempty(ext)
                ext = setExtent(obj);
            end
        end
        
        function updateFontSize(obj,updateExtent)
            
            h = obj.hTipText;
            h.FontSize = obj.FontSize;
            if nargin < 2 || updateExtent
                setExtent(obj);
                resize(obj); % Update widget height
            end
        end
        
        function updateString(obj)
            % Update widget string
            
            one_line = true; % xxx
            
            % Prepend space to make it look good
            str = obj.String;
            if iscellstr(str) || isstring(str)
                if one_line
                    % Banner style requires single line
                    %
                    % Concatenate text lines with spaces
                    Nlines = numel(str);
                    str = [str(:)';repmat({' '},1,Nlines)];
                    str = cat(2,str{:});
                    str = str(1:end-1); % truncate trailing space
                end
            elseif ~isvector(str) % char matrix
                if one_line
                    str = [str repmat(' ',size(str,1),1)];
                    str = str';
                    str = str(:)';
                    str = str(1:end-1); % truncate trailing space
                end
            end
            obj.hTipText.String = str; % widget, not public
            
            % Force extent update
            setExtent(obj); % force extent update, [dx dy]
            resize(obj);
            % Banners ('top','bottom')
            updateWidgetEllipsis(obj);
        end
        
        function updateBannerWidgetPos(obj)
            % Adjust panel location top/bottom
            %        panel height
            %        text height and width
            %        close button position
            
            ext = getExtent(obj);  % [dx dy]
            hText = ext(2);
            
            % Get figure size in pixels
            p = obj.Parent;
            orig = p.Units;
            p.Units = 'pixels';
            fpos = p.Position; % figure position
            p.Units = orig;
            
            % Public obj.Location value changes are deferred until next
            % start call, which ultimately executes this code.
            %
            % Update location cache to reflect actual location shown:
            obj.pLoc = obj.Location;
            
            % Text gets offset automatically past border of parent panel,
            % so (1,1) is up and right of lower-left border line.
            %
            % Panel must be:
            %   - full width of figure, regardless of text width
            %   - 2 pixels taller than text, to allow for border line
            % 
            hPanel = hText+2;
            switch lower(obj.Location)
                case 'top'
                    ppos = [1 fpos(4)+1-hPanel fpos(3) hPanel];
                case 'bottom'
                    ppos = [1 1 fpos(3) hPanel];
            end
            obj.hTipPanel.Position = ppos;
            
            % The widest we can go with the text is fig width - 2:
            obj.hTipText.Position  = [1 1 fpos(3)-2 hText];
            
            % Update position of close button:
            hc = obj.hTipClose;
            gap = 5; % pixels
            [cdata_h,cdata_w,~] = size(hc.CData);
            cpos = [fpos(3)-1-cdata_w-gap 1+floor((hPanel-cdata_h)/2) cdata_w cdata_h];
            hc.Position = cpos;
        end
        
        function updateWidgetEllipsis(obj)
            % Analyze widget width and string length.
            % Shrink string and append '...' to make it fit
            
            % Reset string in case it was shortened previously
            %updateString(obj);
            h = obj.hTipText;
            %h.String = obj.String;
            
            % check text width vs widget width
            ext = getExtent(obj); % [dx dy]
            dx_text = ext(1);
            dx_widget = h.Position(3);
            if dx_text >= dx_widget
                str = h.String;
                Ns = numel(str);
                if Ns > 6
                    % remove 3 chars to be replaced by '...', may not shorten
                    % it but now we can add the suppression chars
                    str = [str(1:end-3) '...'];
                    while numel(str) > 5 % '...' plus 3 chars to remove
                        % remove 3 chars each time
                        str(end-5:end-3) = '';
                        h.String = str;  % update widget
                        ext = setExtent(obj);
                        if ext(1) < dx_widget
                            return
                        end
                    end
                end
                
                % Can't shrink further - clear the text:
                h.String = ''; % clear widget
            end
        end
    end
end
