classdef polariReadout < handle
    % Render text in an axes that is movable via mouse interaction.
            
    properties
        % Handle to figure (axes parent)
        Parent = []
        
        % Full tag name will have polari instance index appended to it
        TagName = 'polariReadout'
        
        ContextMenuFcn
        
        % Initial readout position is based on the current polari View
        % property.
        %
        % When ReadoutPositionPriority is 1, the most natural quadrant is
        % selected.  Use 2 or 3 to choose another (generally less-optimal)
        % position for a second or third readout.
        ReadoutPositionPriority = 1
        
        % Relative size of font, in points.
        %
        % This is the difference between the desired font size and the
        % current font size in the parent polari instance.
        FontRelSize = 0
        
        % If empty, FontName is derived from polari parent object
        FontName = ''
    end
    
    properties (Dependent)
        % Text can be a string vector, string matrix, or a cell-string
        % where each entry of the cell-string is interpreted as a new line
        % of text.
        Text
        
        Visible
    end
    
    properties (Access=?internal.polari)
        hText    % handle to text uicontrol
    end
    
    properties
        NormalBackgroundColor = 'w'
        NormalForegroundColor
    end
    
    properties (Access=private)
        PolariObj
        Listeners
        hAnchor  % handle to uicontrol showing background patch
        
        pLastFigSize      % used during resizing
        
        % Readout re-position quadrant
        % Used to reposition readout when figu size changes
        %
        % 1=top right, 2=top left, 3=bottom left, 4=bottom right
        %
        % xxx set equal to theLoc ?
        pCurrentQuadrant
        
        HiliteBackgroundColor
        HiliteForegroundColor
        
        % # pixels in x and y that anchor should protrude from corner of
        % readout
        pAnchorOffset = 1
        
        % Cache of readout extent, used during readout drag to increase
        % performance
        pExtentDuringDrag
    end
    
    properties (Access=private,Constant)
        % Minimum and maximum font size, in points, when resizing
        FontSizeLimits = [6 14]
        
        ReadoutPositionValues = {'first','second','third'}
        
        % Tabulation of initial readout location strings, based on value of
        % polari View and polariReadout ReadoutPositionPriority.
        %
        % We keep in mind the legend which is generally top-right in all
        % views.  The location string is a relative placement in the axes.
        %
        % Location strings returned:
        %
        %   'top-left',    'top',    'top-right'
        %   'left',                  'right'
        %   'bottom-left', 'bottom', 'bottom-right'
        %
        %   polari         polariReadout .ReadoutPositionPriority
        %   .View           1             2               3
        ViewTable = { ...
            'right',       'top-left',    'bottom-left','bottom-right';
            'top-right',   'top-left',    'top',         'right';
            'full',        'top-left',    'top-right',   'bottom-right';
            'top',         'top-left',    'top-right',   'top';
            'left',        'top-left',    'left',        'bottom-left';
            'top-left',    'top-left',    'top',         'left';
            'bottom-left', 'left',        'bottom',      'bottom-left';
            'bottom',      'bottom-left', 'bottom',      'bottom-right';
            'bottom-right','right',       'bottom',       'bottom-right'}
    end
    
    methods
        function t = polariReadout(p)
            if nargin > 0
                t.PolariObj = p;
            end
        end
        
        function delete(t)
            t.Listeners = internal.polariCommon.deleteListenerStruct(t.Listeners);
            
            % Delete widget since it's not parented to the axes and would
            % otherwise remain if axes remains
            delete(t.hText);
            delete(t.hAnchor);
        end
        
        function set.PolariObj(t,p)
            t.PolariObj = p;
            initFromAxes(t);
        end
        
        function set.Text(t,str)
            updateText(t,str);
        end
        
        function str = get.Text(t)
            str = t.hText.String;
        end
        
        function set.FontRelSize(t,val)
            t.FontRelSize = val;
            updateFont(t);
        end
        
        function set.Visible(t,vis)
            t.hText.Visible = vis;
            
            % Never make anchor vis here, only make it invis
            if strcmpi(vis,'off')
                t.hAnchor.Visible = vis;
            end
        end
        
        function val = get.Visible(t)
            val = t.hText.Visible;
        end
        
        function set.NormalBackgroundColor(t,val)
            internal.ColorConversion.validatecolorspec(val, ...
                'polariReadout','NormalBackgroundColor');
            t.NormalBackgroundColor = val;
            
            % Set color into text widget:
            h = t.hText; %#ok<MCSUP>
            if ~isempty(h)
                h.BackgroundColor = val;
            end
        end
        
        function set.NormalForegroundColor(t,val)
            internal.ColorConversion.validatecolorspec(val, ...
                'polariReadout','NormalForegroundColor');
            t.NormalForegroundColor = val;
        end
        
        function set.TagName(t,val)
            t.TagName = val;
            updateTagName(t);
        end
        
        function set.ReadoutPositionPriority(t,val)
            if ~isscalar(val) || ~isnumeric(val) || ...
                    val~=fix(val) || val < 1 || val > 3
                error('ReadoutPositionPriority must be 1, 2 or 3.');
            end
            t.ReadoutPositionPriority = val;
            
            % Side-effect:
            resetPositionBasedOnView(t);
        end
        
        function updatePosition(t,pos)
            % Method-based change in readout position.
            % Counterpart to event-based updatePosition().
            t.hText.Position(1:2) = pos;
            constrainReadoutPosition(t);
        end
        
        function hoverOverReadoutChange(t,action)
            % React to mouse hovering over readout,
            % where it just entered or just left the readout.
            
            % If we're no longer hovering over readout, flush the
            % extent-cache that was used during drag for performance
            isStart = strcmpi(action,'start');
            hiliteReadout(t, isStart);
            if isStart
                % Create extent cache:
                t.pExtentDuringDrag = t.hText.Extent;
            else
                % Dump cache:
                t.pExtentDuringDrag = [];
            end
        end
    end
    
    methods (Access=private)
        function initFromAxes(t)
            % Initialize text object based on a parent axes handle
            
            % Setup before listeners
            hax = t.PolariObj.hAxes;
            t.Parent = ancestor(hax,'figure');
            
            % Cache initial figure size
            t.pLastFigSize = t.Parent.Position;
            
            initListeners(t);
            updateText(t);
            updateColorProps(t);
        end
        
        function updateTagName(t)
            % React to a change in t.TagName
            %
            % Incorporate polari "instance index" so we can sort out which
            % instance a mouse-callback pertains to.
            
            tagStr = sprintf('%s%d',t.TagName,t.PolariObj.pAxesIndex);
            t.hAnchor.Tag = tagStr;
            t.hText.Tag = tagStr;
        end
        
        function updateColorProps(t)
            % Update color property values for foreground and background of
            % readout text region.
            
            p = t.PolariObj;
            
            t.HiliteBackgroundColor = p.GridBackgroundColor;
            t.HiliteForegroundColor = p.MagnitudeTickLabelColor;
            t.NormalForegroundColor = p.MagnitudeTickLabelColor;
            
            % Setting NormalBackgroundColor invokes set-function to
            % automatically update the text readout color:
            
            % Set color into text widget:
            h = t.hText;
            if ~isempty(h)
                h.ForegroundColor = t.NormalForegroundColor;
                h.BackgroundColor = t.NormalBackgroundColor;
            end
        end
        
        function initListeners(t)
            
            p = t.PolariObj;
            
            lis.ViewChanged = addlistener(p, ...
                'ViewChanged', @(~,~)resetPositionBasedOnView(t));
            lis.FontChanged = addlistener(p, ...
                'FontChanged', @(~,~)updateFont(t));
            lis.WindowResize = addlistener(t.Parent, ...
                'SizeChanged', @(~,~)figureSizeChange(t)); % @(~,~)constrainReadoutPosition(t));
            
            t.Listeners = lis;
        end
        
        function hiliteReadout(t,show)
            % Highlight the readout region.
            %
            % Used when hovering over text, to suggest mouse interaction is
            % possible, such as repositioning via mouse-drag.
            
            ht = t.hText;
            if show
                % highlight the readout text
                ht.ForegroundColor = t.HiliteForegroundColor;
                ht.BackgroundColor = t.HiliteBackgroundColor;
                
                updateAnchorPatchPos(t);
                t.hAnchor.Visible = 'on';
            else
                % un-highlight the readout text
                ht.ForegroundColor = t.NormalForegroundColor;
                ht.BackgroundColor = t.NormalBackgroundColor;
                t.hAnchor.Visible = 'off';
            end
        end
        
        function callbackContextMenu(t,h,ev)
            % Callback for readout context menu.
            %
            % If ContextMenuFcn is non-empty, invokes that function handle
            % using ContextMenuFcn(hMenu,eventStruct).
            %
            % If .ContextMenuFcn is empty, do nothing
            cmFcn = t.ContextMenuFcn;
            if ~isempty(cmFcn)
                cmFcn(h,ev);
            end
        end
        
        function createReadoutWidgets(t)
            % Initial creation of widgets in axes.
            
            % Create context menu
            p = t.PolariObj;
            hc = uicontextmenu( ...
                'Parent',t.Parent, ...
                'Callback',@(h,ev)callbackContextMenu(t,h,ev), ...
                'HandleVisibility', 'off');
            
            % Must create "underlying" anchor patch before the text, so
            % stacking order is achieved.  This serves as a black outline
            % border for the text.
            gr = [1 1 1]*0;
            ha = uicontrol( ...
                'Parent',p.Parent, ...
                'Style','frame', ...
                'ForegroundColor',gr, ...
                'BackgroundColor',gr, ...
                'Enable','inactive', ...
                'Units','pixels', ...
                'Visible','off');
            t.hAnchor = ha;
            
            ht = uicontrol( ...
                'Parent',p.Parent, ...
                'Style','text', ...
                'Enable','inactive', ...
                'HorizontalAlignment','left', ...
                'Units','pixels', ...
                'UIContextMenu',hc, ...
                'Visible','off');
            t.hText = ht;
            
            updateTagName(t);
            resetPositionBasedOnView(t);
        end
        
        function resetPositionBasedOnView(t)
            % Reset readout position in polari axes.
            %
            % Position is based on polari View property, and the local
            % ReadoutPositionPriority property.
            %
            % Called at creation.  Does not change visibility.
            
            ht = t.hText;
            if isempty(ht)
                return
            end

            % Get axes position in pixels
            p = t.PolariObj;
            hax = p.hAxes;
            o = hax.Units;
            hax.Units = 'pixels';
            axRef = hax.Position; % [x y dx dy]
            hax.Units = o;
            
            % Derive axPos from axRef, accounting for PlotBoxAspectRatio
            pb = hax.PlotBoxAspectRatio;
            pbAR = pb(1)/pb(2);
            axAR = axRef(3)/axRef(4);
            axPos = axRef;
            if pbAR > axAR
                axPos(4) = axRef(3)/pbAR;
                axPos(2) = axRef(2) + (axRef(4)-axPos(4))/2;
            else
                axPos(3) = axRef(4)*pbAR;
                axPos(1) = axRef(1) + (axRef(3)-axPos(3))/2;
            end
            
            % Desired offset from axes sides, in pixels
            offX = 10;
            offY = 5;
            
            % Size of text region, in pixels
            ht = t.hText;
            if isempty(ht.String)
                % Placeholder region: ~2 lines of text
                tdx = 50;
                tdy = 40;
            else
                ext = ht.Extent;
                tdx = ext(3);
                tdy = ext(4);
            end
            
            % Determine default readout position
            %
            % axPos is bounding box, [x y dx dy]
            %
            theLoc = getDefaultReadoutPosition(t);
            switch theLoc
                case 'top-left'
                    pos = [axPos(1)+offX axPos(2)+axPos(4)-offY-tdy];
                    quad = 2; % top left
                    
                case 'top'
                    pos = [axPos(1)+axPos(3)/2-tdx/2 axPos(2)+axPos(4)-offY-tdy];
                    quad = 1; % manage resize like it's top right
                    
                case 'top-right'
                    pos = [axPos(1)+axPos(3)-offX-tdx axPos(2)+axPos(4)-offY-tdy];
                    quad = 1; % top right
                    
                case 'left'
                    pos = [axPos(1)+offX axPos(2)+axPos(4)/2-tdy/2];
                    quad = 2; % manage resize like it's top left
                    
                case 'right'
                    pos = [axPos(1)+axPos(3)-offX-tdx axPos(2)+axPos(4)/2-tdy/2];
                    quad = 1; % manage resize like it's top right
                    
                case 'bottom-left'
                    pos = [axPos(1)+offX axPos(2)+offY];
                    quad = 3; % bottom left
                    
                case 'bottom'
                    pos = [axPos(1)+axPos(3)/2 axPos(2)+offY];
                    quad = 3; % manage resize like it's bottom left
                    
                case 'bottom-right'
                    pos = [axPos(1)+axPos(3)-tdx+90 axPos(2)+offY];
                    quad = 4; % bottom right
                    
                otherwise
                    error('unhandled readout position: %s', theView);
            end
            t.pCurrentQuadrant = quad; % xxx = theLoc;
            
            % Install readout position
            %
            % - update text
            ht.Position(1:2) = pos;
            % - font name
            fn = t.FontName;
            if isempty(fn)
                fn = p.FontName;
            end
            ht.FontName = fn;
            % - font size
            lim = t.FontSizeLimits;
            ht.FontSize = min(lim(2),max(lim(1),p.FontSize+t.FontRelSize));
            
            updateTextBackgroundSize(t);
            constrainReadoutPosition(t);
        end
        
        function readoutLoc = getDefaultReadoutPosition(t)
            % Return a string that positions the readout in the display in
            % a useful default position, based on the current setting of
            % ReadoutPositionPriority.
            %
            % ReadoutPositionPriority may be 1, 2 or 3, implying a primary,
            % secondary or tertiary readout location.
            %
            % See ViewTable for a table relating View and
            % ReadoutPositionPriority.
            
            % Get row and column coordinates into the table:
            row = find(strcmpi(t.PolariObj.View, t.ViewTable(:,1)));
            assert(~isempty(row));
            
            % For priority 1, 2 or 3, choose table col 2, 3 or 4:
            col = 1 + t.ReadoutPositionPriority;
            
            readoutLoc = t.ViewTable{row,col};
        end
        
        function figureSizeChange(t)
            % React to a change in figure size
            
            opos = t.pLastFigSize;
            pos = t.Parent.Position;
            t.pLastFigSize = pos;
            ht = t.hText;
            
            switch t.pCurrentQuadrant
                case 1 % top right
                    % Maintain distance to right and top
                    %
                    ht.Position(1) = ht.Position(1) + pos(3)-opos(3);
                    ht.Position(2) = ht.Position(2) + pos(4)-opos(4);
                    
                case 2 % top left
                    % Maintain distance to left and top
                    %
                    % Left is automatic, top needs updating
                    %
                    % Ignore change in y (opos(2) and pos(2)), only react
                    % to change in dy (opos(4) and pos(4)).
                    %
                    % This properly handles figure stretch from top or from
                    % bottom.
                    
                    ht.Position(2) = ht.Position(2) + pos(4)-opos(4);
                    
                case 3 % bottom left
                    % Maintain distance to left and bottom
                    %
                    % Nothing to do - this is the natural position
                    % constraint for uicontrols
                    
                otherwise % 4, bottom right
                    % Maintain distance to right and bottom
                    %
                    % Bottom is automatic, right needs updating
                    ht.Position(1) = ht.Position(1) + pos(3)-opos(3);
            end
            
            % No matter what, keep it on-screen:
            %constrainReadoutPosition(t);
        end
        
        function constrainReadoutPosition(t)
            % Keep readout within figure extent
            
            fig = t.Parent;

            % Figure might not be pixels units
            o = fig.Units;
            fig.Units = 'pixels';
            fpos = fig.Position;
            fig.Units = o;
            fig_xmax = fpos(3);
            fig_ymax = fpos(4);
            
            % Axis is data units
            ht = t.hText;
            tpos = ht.Position;
            
            % Try to get extent from cache.
            %
            % The cache is only non-empty during readout position drag, for
            % performance.
            ext = t.pExtentDuringDrag;
            if isempty(ext)
                ext = ht.Extent;
            end
            tx = tpos(1);
            ty = tpos(2);
            tdx = ext(3);
            tdy = ext(4);
            
            % Constraint in x
            dx = 0;
            if tx < 1
                dx = 1 - tx;
            elseif tx+tdx > fig_xmax
                dx = fig_xmax - tx-tdx;
            end
            if dx~=0
                ht.Position(1) = ht.Position(1) + dx;
            end
            
            % Constraint in y
            dy = 0;
            if ty < 1
                dy = 1 - ty;
            elseif ty+tdy > fig_ymax
                dy = fig_ymax - ty-tdy;
            end
            if dy~=0
                ht.Position(2) = ht.Position(2) + dy;
            end
            
            updateReadoutQuadrant(t);
            updateAnchorPatchPos(t);
        end
        
        function updateReadoutQuadrant(t)
            % React to a possible change in readout quadrant.
            % If so, update anchor point.
            
            p = t.PolariObj;
            hax = p.hAxes;
            
            u = hax.Units;
            hax.Units = 'pixels';
            axpos = hax.Position;
            hax.Units = u;
            
            ax_origin = [axpos(1)+axpos(3)/2, axpos(2)+axpos(4)/2];
            
            % Get midpoint of readout
            tpos = t.hText.Position;
            tpos = [tpos(1)+tpos(3)/2 tpos(2)+tpos(4)/2];
            
            if tpos(1) >= ax_origin(1) % right half
                if tpos(2) >= ax_origin(2) % top half
                    quad = 1; % top right quadrant
                else
                    quad = 4; % bottom right
                end
            else
                if tpos(2) >= ax_origin(2) % top half
                    quad = 2; % top left quadrant
                else
                    quad = 3; % bottom left
                end
            end
            
            if quad ~= t.pCurrentQuadrant
                t.pCurrentQuadrant = quad;
                updateTextBackgroundSize(t);
            end
        end
        
        function updateTextBackgroundSize(t)
            % Change anchor point, keeping text readout position the same
            % while making the change.
            %
            % Also set text readout to minimal height/width.
            %
            % Respect figure boundaries by keeping readout fully on-screen
            % at all times.
            
            % Update horiz, and make a tight fit
            ht = t.hText;
            ht.Position(3:4) = ht.Extent(3:4);
            %{
            if any(t.pCurrentQuadrant == [1 4])
                ht.HorizontalAlignment = 'right';
            else
                ht.HorizontalAlignment = 'left';
            end
            %}
            updateAnchorPatchPos(t);
        end
        
        function updateAnchorPatchPos(t)
            % Update anchor patch position
            
            % Full border
            o = t.pAnchorOffset;
            pos = t.hText.Position;
            t.hAnchor.Position = [pos(1)-o pos(2)-o pos(3)+2*o pos(4)+2*o];
        end
        
        function updateFont(t)
            % Update when font changes size or name
            %   .PolariObj.FontSize
            %   .PolariObj.FontName
            
            p = t.PolariObj;
            ht = t.hText;
            
            if strcmpi(ht.Visible,'off')
                oext = zeros(1,4);
            else
                oext = ht.Extent; % [posx posy dx dy]
            end
            
            % - font name:
            fn = t.FontName;
            if isempty(fn)
                fn = p.FontName;
            end
            ht.FontName = fn;
            % - font size;
            lim = t.FontSizeLimits;
            ht.FontSize = min(lim(2),max(lim(1),p.FontSize+t.FontRelSize));
            
            % A change in font size changes the size of the text box.
            %
            % Depending on the anchor point, we may need to shift the text
            % box, which updates the anchor line.
            updateTextPositionBasedOnOrigExtent(t,oext);
        end
        
        function updateText(t,str)
            % Render text to axes.
            
            ht = t.hText;
            if isempty(ht)
                createReadoutWidgets(t);
                updateFont(t);
                ht = t.hText;
                
                % Early return for creation call with no text
                if nargin < 2
                    return
                end
            end
            
            % Get extent prior to change in text.
            % Even if currently invisible, get the extent, since the
            % readout is likely to become visible soon.
            oext = ht.Extent;
            ht.String = str;
            updateTextPositionBasedOnOrigExtent(t,oext);
        end
        
        function updateTextPositionBasedOnOrigExtent(t,origExt)
            % React to change in size due to extent of new text.
            %
            % Keep anchor point constant when changing size.
            
            ht = t.hText;
            ext = ht.Extent;
            
            switch t.pCurrentQuadrant
                case 1
                    % top right:
                    % anchor point is top right
                    ht.Position = [ ...
                        max(1,ht.Position(1) + origExt(3)-ext(3)) ...
                        max(1,ht.Position(2) + origExt(4)-ext(4)) ...
                        ext(3:4)];
                case 2
                    % top left:
                    % anchor point is top left
                    ht.Position = [ ...
                        ht.Position(1) ...
                        max(1,ht.Position(2) + origExt(4)-ext(4)) ...
                        ext(3:4)];
                case 3
                    % bottom left:
                    % anchor point is bottom left
                    %
                    % Nothing to do -- that's the natural anchor point for
                    % uicontrols
                    ht.Position(3:4) = ext(3:4);
                otherwise
                    % bottom right:
                    % anchor point is bottom right
                    ht.Position = [ ...
                        max(1,ht.Position(1) + origExt(3)-ext(3)) ...
                        ht.Position(2) ...
                        ext(3:4)];
            end
            updateAnchorPatchPos(t);
            
            % Ensure it's visible
            %ht.Visible = 'on';
        end
    end
end
