diff --git a/proplot/axes/base.py b/proplot/axes/base.py index 9283206f3..4f05cd8d6 100644 --- a/proplot/axes/base.py +++ b/proplot/axes/base.py @@ -16,6 +16,7 @@ import matplotlib.container as mcontainer import matplotlib.contour as mcontour import matplotlib.legend as mlegend +import matplotlib.offsetbox as moffsetbox import matplotlib.patches as mpatches import matplotlib.projections as mprojections import matplotlib.spines as mspines @@ -41,8 +42,8 @@ dependencies, docstring, guides, + labels, rcsetup, - texts, warnings, ) from ..utils import _fontsize_to_pt, edges, units @@ -161,7 +162,7 @@ `~matplotlib.transforms.Transform` instance or a string representing the `~matplotlib.axes.Axes.transData`, `~matplotlib.axes.Axes.transAxes`, `~matplotlib.figure.Figure.transFigure`, or - `~matplotlib.figure.Figure.transSubfigure`, transforms. + `~matplotlib.figure.Figure.transSubfigure` transforms. """ docstring._snippet_manager['axes.transform'] = _transform_docstring @@ -316,10 +317,10 @@ center above axes ``'center'``, ``'c'`` left above axes ``'left'``, ``'l'`` right above axes ``'right'``, ``'r'`` - lower center inside axes ``'lower center'``, ``'lc'`` upper center inside axes ``'upper center'``, ``'uc'`` - upper right inside axes ``'upper right'``, ``'ur'`` upper left inside axes ``'upper left'``, ``'ul'`` + upper right inside axes ``'upper right'``, ``'ur'`` + lower center inside axes ``'lower center'``, ``'lc'`` lower left inside axes ``'lower left'``, ``'ll'`` lower right inside axes ``'lower right'``, ``'lr'`` ======================== ============================ @@ -328,7 +329,7 @@ Whether to draw a white border around titles and a-b-c labels positioned inside the axes. This can help them stand out on top of artists plotted inside the axes. -abcbbox, titlebbox : bool, default: :rc:`abc.bbox` and :rc:`title.bbox` +abcbbox, titlebbox : bool, default: :rc:`abc.box` and :rc:`title.box` Whether to draw a white bbox around titles and a-b-c labels positioned inside the axes. This can help them stand out on top of artists plotted inside the axes. @@ -646,6 +647,31 @@ docstring._snippet_manager['axes.legend_kwargs'] = _legend_kwargs_docstring +# Location table docstring +_loc_table_docstring = """ + ================== ============================================ + Location Valid keys + ================== ============================================ + outer left ``'left'``, ``'l'`` + outer right ``'right'``, ``'r'`` + outer bottom ``'bottom'``, ``'b'`` + outer top ``'top'``, ``'t'`` + "best" inset ``'best'``, ``'inset'``, ``'i'``, ``0`` + upper right inset ``'upper right'``, ``'ur'``, ``'NE'``, ``1`` + upper left inset ``'upper left'``, ``'ul'``, ``'NW'``, ``2`` + lower left inset ``'lower left'``, ``'ll'``, ``'SW'``, ``3`` + lower right inset ``'lower right'``, ``'lr'``, ``'SE'``, ``4`` + center left inset ``'center left'``, ``'cl'``, ``'W'``, ``6`` + center right inset ``'center right'``, ``'cr'``, ``'E'``, ``7`` + lower center inset ``'lower center'``, ``'lc'``, ``'S'``, ``8`` + upper center inset ``'upper center'``, ``'uc'``, ``'N'``, ``9`` + center inset ``'center'``, ``'c'``, ``'C'``, ``10`` + "filled" ``'fill'`` + ================== ============================================ +""" +docstring._snippet_manager['axes.legend_loc'] = _loc_table_docstring + + def _align_bbox(align, length): """ Return a simple alignment bounding box for intersection calculations. @@ -766,6 +792,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Varous scalar properties + # NOTE: We use '_title_pad' and '_title_above' for both titles + # and a-b-c labels in order to keep them aligned. self._active_cycle = rc['axes.prop_cycle'] self._auto_format = None # manipulated by wrapper functions self._abc_border_kwargs = {} @@ -795,7 +823,7 @@ def __init__(self, *args, **kwargs): self.yaxis.isDefault_minloc = True # Various dictionary properties - # NOTE: Critical to use self.text() so they are patched with _update_text + # NOTE: Critical to use self.text() overrides rather than mtext.Text() self._legend_dict = {} self._colorbar_dict = {} d = self._panel_dict = {} @@ -804,17 +832,13 @@ def __init__(self, *args, **kwargs): d['bottom'] = [] d['top'] = [] d = self._title_dict = {} - kw = {'zorder': 3.5, 'transform': self.transAxes} - d['abc'] = self.text(0, 0, '', **kw) - d['left'] = self._left_title # WARNING: track in case mpl changes this + d['abc'] = self.text('', loc='center', zorder=3.5) + d['left'] = self._left_title d['center'] = self.title d['right'] = self._right_title - d['upper left'] = self.text(0, 0, '', va='top', ha='left', **kw) - d['upper center'] = self.text(0, 0.5, '', va='top', ha='center', **kw) - d['upper right'] = self.text(0, 1, '', va='top', ha='right', **kw) - d['lower left'] = self.text(0, 0, '', va='bottom', ha='left', **kw) - d['lower center'] = self.text(0, 0.5, '', va='bottom', ha='center', **kw) - d['lower right'] = self.text(0, 1, '', va='bottom', ha='right', **kw) + for v, h in itertools.product(('upper', 'lower'), ('left', 'center', 'right')): + loc = ' '.join((v, h)) + d[loc] = self.text('', loc=loc, zorder=3.5) # Subplot-specific settings # NOTE: Default number for any axes is None (i.e., no a-b-c labels allowed) @@ -1036,7 +1060,10 @@ def _add_colorbar( pop = _pop_params(kwargs, cax._parse_colorbar_arg, ignore_internal=True) locator_default = formatter_default = None if pop: - warnings._warn_proplot(f'Input is already a ScalarMappable. Ignoring unused keyword arg(s): {pop}') # noqa: E501 + warnings._warn_proplot( + 'Input is already a ScalarMappable. ' + f'Ignoring unused keyword arg(s): {pop}' + ) else: result = cax._parse_colorbar_arg(mappable, values, **kwargs) mappable, locator_default, formatter_default, kwargs = result @@ -1075,8 +1102,8 @@ def _add_colorbar( ticker.set_axis(axis) if extendsize is not None and extendfrac is not None: warnings._warn_proplot( - f'You cannot specify both an absolute extendsize={extendsize!r} ' - f"and a relative extendfrac={extendfrac!r}. Ignoring 'extendfrac'." + f'Got conflicting absolute extend length extendsize={extendsize!r} and ' + f"relative length extendfrac={extendfrac!r}. Ignoring 'extendfrac'." ) extendfrac = None if extendfrac is None: @@ -1284,18 +1311,23 @@ def _apply_title_above(self): paxs = self._panel_dict['top'] if not paxs: return - pax = paxs[-1] - names = ('left', 'center', 'right') - if self._abc_loc in names: - names += ('abc',) if not self._title_above: return - if pax._panel_hidden and self._title_above == 'panels': - return - pax._title_pad = self._title_pad - pax._abc_title_pad = self._abc_title_pad - for name in names: - texts._transfer_text(self._title_dict[name], pax._title_dict[name]) + keys = ('left', 'center', 'right') + if self._abc_loc in keys: + keys += ('abc',) + for pax, key in itertools.product(paxs[::-1], keys): + if pax._panel_hidden and self._title_above == 'panels': + continue + loc = self._abc_loc if key == 'abc' else key + bbox = _align_bbox(loc, 0) + if not any(bbox.overlaps(b) for b in pax._panel_align.values()): + if pax._panel_align: + self._title_dict[key].set_in_layout(False) + continue + pax._title_pad = self._title_pad + pax._abc_title_pad = self._abc_title_pad + labels._transfer_text(self._title_dict[key], pax._title_dict[key]) def _auto_share(self): """ @@ -2436,11 +2468,11 @@ def _update_abc(self, **kwargs): { 'border': 'abc.border', 'borderwidth': 'abc.borderwidth', - 'bbox': 'abc.bbox', - 'bboxpad': 'abc.bboxpad', - 'bboxcolor': 'abc.bboxcolor', - 'bboxstyle': 'abc.bboxstyle', - 'bboxalpha': 'abc.bboxalpha', + 'box': 'abc.box', + 'boxpad': 'abc.boxpad', + 'boxcolor': 'abc.boxcolor', + 'boxstyle': 'abc.boxstyle', + 'boxalpha': 'abc.boxalpha', }, context=True, ) @@ -2466,7 +2498,7 @@ def _update_abc(self, **kwargs): if loc not in ('left', 'right', 'center'): kw.update(self._abc_border_kwargs) kw.update(kwargs) - self._title_dict['abc'].update(kw) + labels._update_text(self._title_dict['abc'], **kw) def _update_title(self, loc, title=None, **kwargs): """ @@ -2496,11 +2528,11 @@ def _update_title(self, loc, title=None, **kwargs): { 'border': 'title.border', 'borderwidth': 'title.borderwidth', - 'bbox': 'title.bbox', - 'bboxpad': 'title.bboxpad', - 'bboxcolor': 'title.bboxcolor', - 'bboxstyle': 'title.bboxstyle', - 'bboxalpha': 'title.bboxalpha', + 'box': 'title.box', + 'boxpad': 'title.boxpad', + 'boxcolor': 'title.boxcolor', + 'boxstyle': 'title.boxstyle', + 'boxalpha': 'title.boxalpha', }, context=True, ) @@ -2525,7 +2557,7 @@ def _update_title(self, loc, title=None, **kwargs): loc = rc.find('title.loc', context=True) loc = self._title_loc = _translate_loc(loc or self._title_loc, 'text') if loc != old and old is not None: - texts._transfer_text(self._title_dict[old], self._title_dict[loc]) + labels._transfer_text(self._title_dict[old], self._title_dict[loc]) # Update the title text. For outer panels, add text to the panel if # necesssary. For inner panels, use the border and bbox settings. @@ -2534,40 +2566,26 @@ def _update_title(self, loc, title=None, **kwargs): if title is not None: kw['text'] = title kw.update(kwargs) - self._title_dict[loc].update(kw) + labels._update_text(self._title_dict[loc], **kw) def _update_title_position(self, renderer): """ Update the position of inset titles and outer titles. This is called by matplotlib at drawtime. """ - # Update title positions + # Update title box padding # NOTE: Critical to do this every time in case padding changes or # we added or removed an a-b-c label in the same position as a title width, height = self._get_size_inches() - x_pad = self._title_pad / (72 * width) - y_pad = self._title_pad / (72 * height) for loc, obj in self._title_dict.items(): - x, y = (0, 1) - if loc == 'abc': # redirect + if not isinstance(obj, moffsetbox.AnchoredText): + continue + if loc == 'abc': loc = self._abc_loc - if loc == 'left': - x = 0 - elif loc == 'center': - x = 0.5 - elif loc == 'right': - x = 1 - if loc in ('upper center', 'lower center'): - x = 0.5 - elif loc in ('upper left', 'lower left'): - x = x_pad - elif loc in ('upper right', 'lower right'): - x = 1 - x_pad - if loc in ('upper left', 'upper right', 'upper center'): - y = 1 - y_pad - elif loc in ('lower left', 'lower right', 'lower center'): - y = y_pad - obj.set_position((x, y)) + if loc in ('left', 'center', 'right'): # invisible box + obj.borderpad = 0 + else: + obj.borderpad = self._title_pad / _fontsize_to_pt(rc['legend.fontsize']) # Get title padding. Push title above tick marks since matplotlib ignores them. # This is known matplotlib problem but especially annoying with top panels. @@ -2607,12 +2625,12 @@ def _update_title_position(self, renderer): obj.get_window_extent(renderer).transformed(self.transAxes.inverted()) .width for obj in (aobj, tobj) ) - ha = aobj.get_ha() pad = (abcpad / 72) / self._get_size_inches()[0] + halign = aobj.get_ha() aoffset = toffset = 0 - if ha == 'left': + if halign == 'left': toffset = awidth + pad - elif ha == 'right': + elif halign == 'right': aoffset = -(twidth + pad) else: # guaranteed center, there are others toffset = 0.5 * (awidth + pad) @@ -2805,7 +2823,7 @@ def draw(self, renderer=None, *args, **kwargs): self.indicate_inset_zoom() super().draw(renderer, *args, **kwargs) - def get_tightbbox(self, renderer, *args, **kwargs): + def get_tightbbox(self, renderer, *args, use_cache=False, **kwargs): # Perform extra post-processing steps # NOTE: This should be updated alongside draw(). We also cache the resulting # bounding box to speed up tight layout calculations (see _range_tightbbox). @@ -2815,8 +2833,10 @@ def get_tightbbox(self, renderer, *args, **kwargs): self._colorbar_fill.update_ticks(manual_only=True) # only update if needed! if self._inset_parent is not None and self._inset_zoom: self.indicate_inset_zoom() - self._tight_bbox = super().get_tightbbox(renderer, *args, **kwargs) - return self._tight_bbox + bbox = self._tight_bbox + if not use_cache or bbox is None: + bbox = self._tight_bbox = super().get_tightbbox(renderer, *args, **kwargs) + return bbox def get_default_bbox_extra_artists(self): # Further restrict artists to those with disabled clipping @@ -2920,25 +2940,14 @@ def colorbar( Parameters ---------- %(axes.colorbar_args)s - loc, location : int or str, default: :rc:`colorbar.loc` - The colorbar location. Valid location keys are shown in the below table. + loc, location : int, str, or 2-tuple of float, default: :rc:`colorbar.loc` + The colorbar location key or location coordinates, dependent on + `bbox_to_anchor` and `bbox_transform`. Valid location keys are + shown in the below table. .. _colorbar_table: - ================== ======================================= - Location Valid keys - ================== ======================================= - outer left ``'left'``, ``'l'`` - outer right ``'right'``, ``'r'`` - outer bottom ``'bottom'``, ``'b'`` - outer top ``'top'``, ``'t'`` - default inset ``'best'``, ``'inset'``, ``'i'``, ``0`` - upper right inset ``'upper right'``, ``'ur'``, ``1`` - upper left inset ``'upper left'``, ``'ul'``, ``2`` - lower left inset ``'lower left'``, ``'ll'``, ``3`` - lower right inset ``'lower right'``, ``'lr'``, ``4`` - "filled" ``'fill'`` - ================== ======================================= + %(axes.legend_loc)s shrink Alias for `length`. This is included for consistency with @@ -2972,7 +2981,7 @@ def colorbar( loc = _not_none(loc=loc, location=location) loc = _translate_loc(loc, 'colorbar', default=rc['colorbar.loc']) align = _translate_loc(align, 'panel', default='center', c='center', center='center') # noqa: E501 - kwargs = guides._guide_kw_from_obj(mappable, 'colorbar', kwargs) + kwargs = guides._guide_obj_to_kw(mappable, 'colorbar', kwargs) if queue: self._register_guide('colorbar', (mappable, values), (loc, align), **kwargs) else: @@ -2991,30 +3000,14 @@ def legend( Parameters ---------- %(axes.legend_args)s - loc, location : int or str, default: :rc:`legend.loc` - The legend location. Valid location keys are shown in the below table. + loc, location : int, str, or 2-tuple of float, default: :rc:`legend.loc` + The legend location key or location coordinates, dependent on + `bbox_to_anchor` and `bbox_transform`. Valid location keys are + shown in the below table. .. _legend_table: - ================== ======================================= - Location Valid keys - ================== ======================================= - outer left ``'left'``, ``'l'`` - outer right ``'right'``, ``'r'`` - outer bottom ``'bottom'``, ``'b'`` - outer top ``'top'``, ``'t'`` - "best" inset ``'best'``, ``'inset'``, ``'i'``, ``0`` - upper right inset ``'upper right'``, ``'ur'``, ``1`` - upper left inset ``'upper left'``, ``'ul'``, ``2`` - lower left inset ``'lower left'``, ``'ll'``, ``3`` - lower right inset ``'lower right'``, ``'lr'``, ``4`` - center left inset ``'center left'``, ``'cl'``, ``5`` - center right inset ``'center right'``, ``'cr'``, ``6`` - lower center inset ``'lower center'``, ``'lc'``, ``7`` - upper center inset ``'upper center'``, ``'uc'``, ``8`` - center inset ``'center'``, ``'c'``, ``9`` - "filled" ``'fill'`` - ================== ======================================= + %(axes.legend_loc)s width : unit-spec, optional For outer legends only. The space allocated for the legend @@ -3038,7 +3031,7 @@ def legend( loc = _not_none(loc=loc, location=location) loc = _translate_loc(loc, 'legend', default=rc['legend.loc']) align = _translate_loc(align, 'panel', default='center', c='center', center='center') # noqa: E501 - kwargs = guides._guide_kw_from_obj(handles, 'legend', kwargs) + kwargs = guides._guide_obj_to_kw(handles, 'legend', kwargs) if queue: self._register_guide('legend', (handles, labels), (loc, align), **kwargs) else: @@ -3047,28 +3040,48 @@ def legend( @docstring._concatenate_inherited @docstring._snippet_manager - def text( - self, *args, border=False, bbox=False, - bordercolor='w', borderwidth=2, borderinvert=False, borderstyle='miter', - bboxcolor='w', bboxstyle='round', bboxalpha=0.5, bboxpad=None, **kwargs - ): + def text(self, *args, loc=None, **kwargs): """ Add text to the axes. Parameters ---------- - x, y, [z] : float + x, y, [z] : float, optional The coordinates for the text. `~proplot.axes.ThreeAxes` accept an optional third coordinate. If only two are provided this automatically redirects to the `~mpl_toolkits.mplot3d.Axes3D.text2D` method. - s, text : str - The string for the text. + s, text : str, default: '' + The text string. %(axes.transform)s Other parameters ---------------- - border : bool, default: False - Whether to draw border around text. + loc : str, optional + The text location. If passed an `~matplotlib.offsetbox.AnchoredText` + instance is returned instead of a `~matplotlib.text.Text` instance. + This can be used instead of explicit x, y, [z] coordinates with e.g. + ``ax.text('label', loc='upper left')``). Valid location keys are + shown in the below table. + + .. _text_table: + + ============ =================================== + Location Valid keys + ============ =================================== + upper right ``'upper right'``, ``'ur'``, ``1`` + upper left ``'upper left'``, ``'ul'``, ``2`` + lower left ``'lower left'``, ``'ll'``, ``3`` + lower right ``'lower right'``, ``'lr'``, ``4`` + center left ``'center left'``, ``'cl'``, ``6`` + center right ``'center right'``, ``'cr'``, ``7`` + lower center ``'lower center'``, ``'lc'``, ``8`` + upper center ``'upper center'``, ``'uc'``, ``9`` + center ``'center'``, ``'c'``, ``10`` + ============ =================================== + + border : bool or dict, default: False + Whether to draw a border around the text. This can also be a + dictionary of `~matplotlib.patheffects.Stroke` properties. borderwidth : float, default: 2 The width of the text border. bordercolor : color-spec, default: 'w' @@ -3079,21 +3092,27 @@ def text( The `line join style \ `__ used for the border. - bbox : bool, default: False - Whether to draw a bounding box around text. - bboxcolor : color-spec, default: 'w' + box : bool or dict, default: False + Whether to draw a bounding box around the text. This can also be a + dictionary of `~matplotlib.patches.FancyBboxPatch` properties. + boxcolor : color-spec, default: 'w' The color of the text bounding box. - bboxstyle : boxstyle, default: 'round' + boxstyle : boxstyle, default: 'round' The style of the bounding box. - bboxalpha : float, default: 0.5 + boxalpha : float, default: 0.5 The alpha for the bounding box. - bboxpad : float, default: :rc:`title.bboxpad` + boxpad : float, default: :rc:`title.pad` The padding for the bounding box. %(artist.text)s **kwargs Passed to `matplotlib.axes.Axes.text`. + Returns + ------- + `matplotlib.text.Text` or `matplotlib.offsetbox.AnchoredText` + The text object. See `loc` for details. + See also -------- matplotlib.axes.Axes.text @@ -3101,43 +3120,34 @@ def text( # Translate positional args # Audo-redirect to text2D for 3D axes if not enough arguments passed # NOTE: The transform must be passed positionally for 3D axes with 2D coords - keys = 'xy' - func = super().text - if self._name == 'three': - if len(args) >= 4 or 'z' in kwargs: - keys += 'z' - else: - func = self.text2D - keys = (*keys, ('s', 'text'), 'transform') + driver = super().text + coords = 'xy' + if self._name == 'three' and (len(args) > 3 or 'z' in kwargs): + driver = self.text2D + coords += 'z' + keys = (*coords, ('s', 'text'), 'transform') args, kwargs = _kwargs_to_args(keys, *args, **kwargs) - *args, transform = args - if any(arg is None for arg in args): - raise TypeError('Missing required positional argument.') - if transform is None: - transform = self.transData + if all(arg is None for arg in args[2:]): # just 'text' or 'text', 'transform' + args = [None] * len(coords) + args[:2] + *coords, text, transform = args + text = _not_none(text, '') + transform = self._get_transform(transform, default=self.transData) + if loc is not None and all(arg is not None for arg in args[:len(coords)]): + warnings._warn_proplot( + 'Got conflicting explicit Text coordinates and preset ' + "AnchoredText location 'loc'. Ignoring the latter." + ) + if loc is None: + obj = driver(*coords, text, transform=transform) else: - transform = self._get_transform(transform) + pad = self._title_pad / rc['font.size'] # points to em-widths + obj = moffsetbox.AnchoredText('', loc=loc, borderpad=pad) + obj.patch.set_visible(False) # initially toggle this off + self.add_artist(obj) with warnings.catch_warnings(): # ignore duplicates (internal issues?) warnings.simplefilter('ignore', warnings.ProplotWarning) kwargs.update(_pop_props(kwargs, 'text')) - - # Update the text object using a monkey patch - obj = func(*args, transform=transform, **kwargs) - obj.update = texts._update_text.__get__(obj) - obj.update( - { - 'border': border, - 'bordercolor': bordercolor, - 'borderinvert': borderinvert, - 'borderwidth': borderwidth, - 'borderstyle': borderstyle, - 'bbox': bbox, - 'bboxcolor': bboxcolor, - 'bboxstyle': bboxstyle, - 'bboxalpha': bboxalpha, - 'bboxpad': bboxpad, - } - ) + labels._update_text(obj, **kwargs) return obj def _iter_axes(self, hidden=False, children=False, panels=True): diff --git a/proplot/axes/cartesian.py b/proplot/axes/cartesian.py index ecc089922..0d98be651 100644 --- a/proplot/axes/cartesian.py +++ b/proplot/axes/cartesian.py @@ -14,7 +14,7 @@ from .. import ticker as pticker from ..config import rc from ..internals import ic # noqa: F401 -from ..internals import _not_none, _pop_rc, dependencies, docstring, texts, warnings +from ..internals import _not_none, _pop_rc, dependencies, docstring, labels, warnings from . import plot, shared __all__ = ['CartesianAxes'] @@ -371,7 +371,7 @@ def _apply_axis_sharing(self): if self._sharex is not None and axis.get_visible(): level = 3 if self._panel_sharex_group else self.figure._sharex if level > 0: - texts._transfer_text(axis.label, self._sharex.xaxis.label) + labels._transfer_text(axis.label, self._sharex.xaxis.label) axis.label.set_visible(False) if level > 2: # WARNING: Cannot set NullFormatter because shared axes share the @@ -382,7 +382,7 @@ def _apply_axis_sharing(self): if self._sharey is not None and axis.get_visible(): level = 3 if self._panel_sharey_group else self.figure._sharey if level > 0: - texts._transfer_text(axis.label, self._sharey.yaxis.label) + labels._transfer_text(axis.label, self._sharey.yaxis.label) axis.label.set_visible(False) if level > 2: axis.set_tick_params(which='both', labelleft=False, labelright=False) diff --git a/proplot/config.py b/proplot/config.py index 5579e0a8d..52fbfba6c 100644 --- a/proplot/config.py +++ b/proplot/config.py @@ -924,11 +924,11 @@ def _get_params(self, key, value, skip_cycle=False): kw_matplotlib['axes.prop_cycle'] = cycler.cycler('color', cmap.colors) kw_matplotlib['patch.facecolor'] = 'C0' - # Turning bounding box on should turn border off and vice versa - elif contains('abc.bbox', 'title.bbox', 'abc.border', 'title.border'): + # Turning box on should turn border off and vice versa + elif contains('abc.box', 'title.box', 'abc.border', 'title.border'): if value: name, this = key.split('.') - other = 'border' if this == 'bbox' else 'bbox' + other = 'border' if this == 'box' else 'box' kw_proplot[name + '.' + other] = False # Fontsize diff --git a/proplot/figure.py b/proplot/figure.py index c08950a44..6adfaab7a 100644 --- a/proplot/figure.py +++ b/proplot/figure.py @@ -28,7 +28,7 @@ context, dependencies, docstring, - texts, + labels, warnings, ) from .utils import units @@ -1263,7 +1263,7 @@ def _update_axis_label(self, side, axs): # Copy text from central label to spanning label # NOTE: Must use spaces rather than newlines, otherwise tight layout # won't make room. Reason is Text implementation (see Text._get_layout()) - texts._transfer_text(axis.label, label) # text, color, and font properties + labels._transfer_text(axis.label, label) # text, color, and font properties space = '\n'.join(' ' * (1 + label.get_text().count('\n'))) for axis in axislist: # should include original 'axis' axis.label.set_text(space) diff --git a/proplot/internals/__init__.py b/proplot/internals/__init__.py index 7703e60e9..abdc0201d 100644 --- a/proplot/internals/__init__.py +++ b/proplot/internals/__init__.py @@ -51,8 +51,8 @@ def _not_none(*args, default=None, **kwargs): docstring, guides, inputs, + labels, rcsetup, - texts, warnings ) @@ -405,6 +405,8 @@ def _translate_loc(loc, mode, *, default=None, **kwargs): if loc in (None, True): loc = default elif isinstance(loc, (str, Integral)): + if isinstance(loc, str): + loc = loc.lower() try: loc = loc_dict[loc] except KeyError: diff --git a/proplot/internals/guides.py b/proplot/internals/guides.py index 73198a793..96314e276 100644 --- a/proplot/internals/guides.py +++ b/proplot/internals/guides.py @@ -3,16 +3,19 @@ Utilties related to legends and colorbars. """ import matplotlib.artist as martist +import matplotlib.axes as maxes import matplotlib.colorbar as mcolorbar -import matplotlib.legend as mlegend # noqa: F401 +import matplotlib.offsetbox as moffsetbox +import matplotlib.projections as mprojections # noqa: F401 import matplotlib.ticker as mticker +import matplotlib.transforms as mtransforms import numpy as np from . import ic # noqa: F401 from . import warnings -def _fill_guide_kw(kwargs, **pairs): +def _fill_guide_kw(kwargs, overwrite=False, **pairs): """ Add the keyword arguments to the dictionary if not already present. """ @@ -25,42 +28,48 @@ def _fill_guide_kw(kwargs, **pairs): if value is None: continue keys = tuple(a for group in aliases for a in group if key in group) # may be () - if not any(kwargs.get(key) is not None for key in keys): # note any(()) is True + keys_found = tuple(key for key in keys if kwargs.get(key) is not None) + if not keys_found: kwargs[key] = value + elif overwrite: # overwrite existing key + kwargs[keys_found[0]] = value -def _guide_kw_from_obj(obj, name, kwargs): +def _guide_kw_to_arg(name, kwargs, **pairs): """ - Add to the dict from settings stored on the object if there are no conflicts. + Add to the `colorbar_kw` or `legend_kw` dict if there are no conflicts. """ - pairs = getattr(obj, f'_{name}_kw', None) - pairs = pairs or {} # needed for some reason - _fill_guide_kw(kwargs, **pairs) - if isinstance(obj, (tuple, list, np.ndarray)): - for iobj in obj: # possibly iterate over matplotlib tuple/list subclasses - _guide_kw_from_obj(iobj, name, kwargs) - return kwargs + kw = kwargs.setdefault(f'{name}_kw', {}) + _fill_guide_kw(kw, overwrite=True, **pairs) def _guide_kw_to_obj(obj, name, kwargs): """ - Add the guide keyword dict to the objects. + Store settings on the object from the input dict. """ + pairs = getattr(obj, f'_{name}_kw', None) + pairs = pairs or {} + _fill_guide_kw(pairs, overwrite=True, **kwargs) # update with current input try: setattr(obj, f'_{name}_kw', kwargs) except AttributeError: pass if isinstance(obj, (tuple, list, np.ndarray)): - for iobj in obj: - _guide_kw_to_obj(iobj, name, kwargs) + for member in obj: + _guide_kw_to_obj(member, name, kwargs) -def _guide_kw_to_arg(name, kwargs, **pairs): +def _guide_obj_to_kw(obj, name, kwargs): """ - Add to the `colorbar_kw` or `legend_kw` dict if there are no conflicts. + Add to the dict from settings stored on the object if there are no conflicts. """ - kw = kwargs.setdefault(f'{name}_kw', {}) - _fill_guide_kw(kw, **pairs) + pairs = getattr(obj, f'_{name}_kw', None) + pairs = pairs or {} + _fill_guide_kw(kwargs, overwrite=False, **pairs) # update from previous input + if isinstance(obj, (tuple, list, np.ndarray)): + for member in obj: # possibly iterate over matplotlib tuple/list subclasses + _guide_obj_to_kw(member, name, kwargs) + return kwargs def _iter_children(*args): @@ -117,15 +126,93 @@ def _update_ticks(self, manual_only=False): self.minorticks_on() # at least turn them on -class _InsetColorbar(martist.Artist): - """ - Legend-like class for managing inset colorbars. - """ - # TODO: Write this! +class _AnchoredAxes(moffsetbox.AnchoredOffsetbox): + """ + An anchored child axes whose background patch and offset position is determined + by the tight bounding box. Analogous to `~matplotlib.offsetbox.AnchoredText`. + """ + def __init__(self, ax, width, height, **kwargs): + # Note the default bbox_to_anchor will be + # the axes bounding box. + bounds = [0, 0, 1, 1] # arbitrary initial bounds + child = maxes.Axes(ax.figure, bounds, zorder=self.zorder) + # cls = mprojections.get_projection_class('proplot_cartesian') # TODO + # child = cls(ax.figure, bounds, zorder=self.zorder) + super().__init__(child=child, bbox_to_anchor=ax.bbox, **kwargs) + ax.add_artist(self) # sets self.axes to ax and bbox_to_anchor to ax.bbox + self._child = child # ensure private attribute exists + self._width = width + self._height = height + + def draw(self, renderer): + # Just draw the patch (not the axes) + if not self.get_visible(): + return + if hasattr(self, '_update_offset_func'): + self._update_offset_func(renderer) + else: + warnings._warn_proplot( + 'Failed to update _AnchoredAxes offset function due to matplotlib ' + 'private API change. The resulting axes position may be incorrect.' + ) + bbox = self.get_window_extent(renderer) + self._update_patch(renderer, bbox=bbox) + bbox = self.get_child_extent(renderer, offset=True) + self._update_child(bbox) + self.patch.draw(renderer) + self._child.draw(renderer) + + def _update_child(self, bbox): + # Update the child bounding box + trans = getattr(self.figure, 'transSubfigure', self.figure.transFigure) + bbox = mtransforms.TransformedBbox(bbox, trans.inverted()) + getattr(self._child, '_set_position', self._child.set_position)(bbox) + + def _update_patch(self, renderer, bbox): + # Update the patch position + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + self.patch.set_bounds(bbox.x0, bbox.y0, bbox.width, bbox.height) + self.patch.set_mutation_scale(fontsize) + + def get_extent(self, renderer, offset=False): + # Return the extent of the child plus padding + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + pad = self.pad * fontsize + bbox = self._child._tight_bbox = self._child.get_tightbbox(renderer) + # bbox = self._child.get_tightbbox(renderer, use_cache=True) # TODO + width = bbox.width + 2 * pad + height = bbox.height + 2 * pad + xd = yd = pad + if offset: + xd += self._child.bbox.x0 - bbox.x0 + yd += self._child.bbox.y0 - bbox.y0 + return width, height, xd, yd + + def get_child_extent(self, renderer, offset=False): + # Update the child position + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + x0, y0 = self._child.bbox.x0, self._child.bbox.y0 + if offset: # find offset position + self._update_child(self.get_child_extent(renderer)) + width, height, xd, yd = self.get_extent(renderer, offset=True) + x0, y0 = self.get_offset(width, height, xd, yd, renderer) + # bbox = self._child.get_tightbbox(use_cache=True) # TODO + xd += self._child.bbox.x0 - self._child._tight_bbox.x0 + yd += self._child.bbox.y0 - self._child._tight_bbox.y0 + width, height = self._width * fontsize, self._height * fontsize + return mtransforms.Bbox.from_bounds(x0, y0, width, height) + + def get_window_extent(self, renderer): + # Return the window bounding box + self._child.get_tightbbox(renderer) # reset the cache + self._update_child(self.get_child_extent(renderer)) + xi, yi, xd, yd = self.get_extent(renderer, offset=False) + ox, oy = self.get_offset(xi, yi, xd, yd, renderer) + return mtransforms.Bbox.from_bounds(ox - xd, oy - yd, xi, yi) class _CenteredLegend(martist.Artist): """ - Legend-like class for managing centered-row legends. + A legend-like subclass whose handles are grouped into centered rows of + `~matplotlib.offsetbox.HPacker` rather than `~matplotlib.offsetbox.VPacker` columns. """ - # TODO: Write this! diff --git a/proplot/internals/labels.py b/proplot/internals/labels.py new file mode 100755 index 000000000..46825d7e6 --- /dev/null +++ b/proplot/internals/labels.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Utilities related to text labels. +""" +import matplotlib.offsetbox as moffsetbox +import matplotlib.patheffects as mpatheffects +import matplotlib.text as mtext +import matplotlib.transforms as mtransforms +from matplotlib import rcParams as rc_matplotlib + +from . import ic # noqa: F401 +from . import _not_none, warnings + +# Default border and box values +DEFAULT_BORDERCOLOR = 'w' +DEFAULT_BORDERINVERT = False +DEFAULT_BORDERWIDTH = 2 +DEFAULT_BORDERSTYLE = 'miter' +DEFAULT_BOXFACECOLOR = 'w' +DEFAULT_BOXEDGECOLOR = 'k' +DEFAULT_BOXALPHA = 0.5 +DEFAULT_BOXSTYLE = 'round' +DEFAULT_BOXPAD = 0.5 + + +def _transfer_text(src, dest): + """ + Transfer the input text object properties and content to the destination + text object. Then clear the input object text. + """ + if isinstance(src, moffsetbox.AnchoredText): + src = src.txt + elif not isinstance(src, mtext.Text): + raise ValueError('Input must be Text or AnchoredText.') + if isinstance(dest, moffsetbox.AnchoredText): + dest = dest.txt._text + elif not isinstance(dest, mtext.Text): + raise ValueError('Input must be Text or AnchoredText.') + text = src.get_text() + dest.set_color(src.get_color()) # not a font property + dest.set_fontproperties(src.get_fontproperties()) # size, weight, etc. + if not text.strip(): # WARNING: must test strip() (see _align_axis_labels()) + return + dest.set_text(text) + src.set_text('') + + +@warnings._rename_kwargs( + '0.10', + bbox='box', + bboxcolor='boxcolor', + bboxalpha='boxalpha', + bboxstyle='boxstyle', + bboxpad='boxpad', +) +def _update_text( + obj, border=None, box=None, + bordercolor=None, borderwidth=None, borderinvert=None, borderstyle=None, + boxcolor=None, boxalpha=None, boxstyle=None, boxpad=None, **kwargs +): + """ + Update the text and (if applicable) offset box with "border" + and "bbox" properties. This facillitates inset titles. + """ + if isinstance(obj, mtext.Text): + text = obj + patch = obj.get_bbox_patch() # NOTE: this can be None + elif isinstance(obj, moffsetbox.AnchoredText): + text = obj.txt._text + patch = obj.patch + else: + raise ValueError('Input must be Text or AnchoredText.') + text.update(kwargs) + + # Update text border + border_props = {} + if isinstance(border, dict): + border_props.update(border) + border = True + if border is None: + pass + elif border: + textcolor = text.get_color() + patheffects = text.get_path_effects() + if not patheffects: # update with defaults + bordercolor = _not_none(bordercolor, DEFAULT_BORDERCOLOR) + borderinvert = _not_none(borderinvert, DEFAULT_BORDERINVERT) + borderwidth = _not_none(borderwidth, DEFAULT_BORDERWIDTH) + borderstyle = _not_none(borderstyle, DEFAULT_BORDERSTYLE) + if borderinvert: + bordercolor = _not_none(bordercolor, DEFAULT_BORDERCOLOR) + textcolor, bordercolor = bordercolor, textcolor + if borderwidth is not None: + border_props.setdefault('linewidth', borderwidth) + if bordercolor is not None: + border_props.setdefault('foreground', bordercolor) + if borderstyle is not None: + border_props.setdefault('joinstyle', borderstyle) + if textcolor is not None: + text.set_color(textcolor) + if patheffects: # do not update with default + patheffects[0].update(border_props) + else: # instantiate and apply defaults + stroke = mpatheffects.Stroke(**border_props) + text.set_path_effects([stroke, mpatheffects.Normal()]) + elif border is False: + text.set_path_effects(None) + + # Update bounding box + # NOTE: AnchoredOffsetbox padding is relative to rc['legend.fontsize'] + box_props = {} + if isinstance(box, dict): + box_props.update(box) + box = True + if box is None: + pass + elif box: + edgecolor = None + if patch is None or not patch.get_visible(): + edgecolor = DEFAULT_BOXEDGECOLOR + boxcolor = _not_none(boxcolor, DEFAULT_BOXFACECOLOR) + boxalpha = _not_none(boxalpha, DEFAULT_BOXALPHA) + boxstyle = _not_none(boxstyle, DEFAULT_BOXSTYLE) + boxpad = _not_none(boxpad, text.axes._title_pad) + if edgecolor is not None: + box_props.setdefault('edgecolor', edgecolor) + if boxcolor is not None: + box_props.setdefault('facecolor', boxcolor) + if boxstyle is not None: + box_props.setdefault('boxstyle', boxstyle) + if boxalpha is not None: + box_props.setdefault('alpha', boxalpha) + if boxpad is not None: + boxpad /= rc_matplotlib['legend.fontsize'] # convert points to em-widths + box_props.setdefault('pad', boxpad) + if patch is None: # only possible for Text objects + text.set_bbox(box_props) + else: + patch.set_visible(True) + patch.update(box_props) + else: + if isinstance(obj, mtext.Text): + text.set_bbox(None) # disable the bbox + else: + patch.set_visible(False) + return obj + + +class _AnchoredLabel(moffsetbox.AnchoredOffsetbox): + """ + A class for storing anchored text. Embeds text in `~matplotlib.offsetbox.HPacker` + instances for optional a-b-c label and title pair storage, supports adding and + removing text from the packer, permits anchoring along bounding boxes of multiple + axes (for `~proplot.gridspec.SubplotGrid.text` support), and allows automatically + adjusted perpendicular offset and parallel alignment for "outer" locations when + requested. The tight bounding box algorithm will include labels appearing + between columns of the grid. A similar scheme might be used for auto-offsetting + hanging twin axes and legends and removing the "panel" obfuscation. + """ + def __init__(self, *args, loc=None, pad=None, sep=None, textprops=None, **kwargs): + # Accept arbitrarily many texts + from ..config import rc + loc = _not_none(loc, rc['title.loc']) + pad = _not_none(pad, rc['title.pad']) + sep = _not_none(sep, rc['abc.titlepad']) + txts = [moffsetbox.TextArea(arg, textprops=textprops) for arg in args] + pack = moffsetbox.HPacker(pad=pad, sep=sep, children=txts) + super().__init__(child=pack, **kwargs) + self.patch.set_visible(False) + + def _update_offset_func(self, renderer, fontsize=None): + # Update the offset function + # TODO: Override this for only perpendicular offsets + if fontsize is None: + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + + def _offset(w, h, xd, yd, renderer): + bbox = mtransforms.Bbox.from_bounds(0, 0, w, h) + borderpad = self.borderpad * fontsize + bbox_to_anchor = self.get_bbox_to_anchor() + x0, y0 = self._get_anchored_bbox(self.loc, bbox, bbox_to_anchor, borderpad) + self.axes._update_title_position(renderer) + y1 = self.axes.bbox.y0 + _, y2 = self.axes.title.get_position() + _, y2 = self.axes.title.get_transform().transform((0, y2)) + offset = y2 - y1 + return x0 + xd, y0 + yd + offset + + self.set_offset(_offset) + + def get_extent(self, renderer): + # Get the inset extent after adjusting for the title + # position and ignoring horizontal padding. + # TODO: Override this for only perpendicular offsets + w, h, xd, yd = self.get_child().get_extent(renderer) + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + pad = self.pad * fontsize + return w + 2 * pad, h + 2 * pad, xd + pad, yd + pad + + def get_coord(self, renderer): + # Get the title offset coordinate axes + # NOTE: Since proplot adds "twins" as child axes + # they are already covered in the child axes iteration. + title = self._child._children[1] + x, _ = title.get_position() + title.set_position((x, 1.0)) + axs = self._twinned_axes.get_siblings(self) + for ax in self.child_axes: + if ax is None: + continue + locator = ax.get_axes_locator() + if locator: + pos = locator(self, renderer) + ax.apply_aspect(pos) + else: + ax.apply_aspect() + axs = axs + [ax] + # Get the coordinate + # TODO: Align groups of labels by the same baseline. Possibly make label + # groupings similar to twinned axes groupings. And possibly use custom + # logic for "aligned" axis labels rather than using matplotlib logic. + # NOTE: Since AnchoredOffsetbox already adjusts text baseline position by + # its window extent we don't need to use the extra check employed in + # matplotlib _update_title_position. Suggests this way is cleaner. + top = 0 + for ax in axs: + top = max(top, ax.bbox.ymax) + if ax.xaxis.get_visible() and ( + ax.xaxis.get_label_position() == 'top' + or ax.xaxis.get_ticks_position() in ('top', 'unknown') + ): + bb = ax.xaxis.get_tightbbox(renderer) + else: + bb = ax.get_window_extent(renderer) + if bb is None: + continue + ymax = bb.ymax + if ax.xaxis.get_visible() and any( + tick.tick2line.get_visible() and not tick.label2.get_visible() + for tick in ax.xaxis.majorTicks + ): + ymax += ax.xaxis.get_tick_padding() + top = max(top, ymax) + return top diff --git a/proplot/internals/rcsetup.py b/proplot/internals/rcsetup.py index 7275a5c2e..1b4ccb7a2 100644 --- a/proplot/internals/rcsetup.py +++ b/proplot/internals/rcsetup.py @@ -68,17 +68,18 @@ 'fill': 'fill', 'inset': 'best', 'i': 'best', - 0: 'best', + 0: 'best', # offsetbox codes 1: 'upper right', 2: 'upper left', 3: 'lower left', 4: 'lower right', - 5: 'center left', - 6: 'center right', - 7: 'lower center', - 8: 'upper center', - 9: 'center', - 'l': 'left', + 5: 'center right', # weird, but see _get_anchored_bbox in matplotlib/offsetbox.py + 6: 'center left', + 7: 'center right', + 8: 'lower center', + 9: 'upper center', + 10: 'center', + 'l': 'left', # custom shorthands 'r': 'right', 'b': 'bottom', 't': 'top', @@ -87,14 +88,21 @@ 'ul': 'upper left', 'll': 'lower left', 'lr': 'lower right', - 'cr': 'center right', 'cl': 'center left', + 'cr': 'center right', 'uc': 'upper center', 'lc': 'lower center', + 'ne': 'upper right', # BboxBase.anchored shorthands + 'nw': 'upper left', + 'sw': 'lower left', + 'se': 'lower right', + 'w': 'center left', + 'e': 'center right', + 's': 'lower center', + 'n': 'upper center', } -for _loc in tuple(LEGEND_LOCS.values()): - if _loc not in LEGEND_LOCS: - LEGEND_LOCS[_loc] = _loc # identity assignments +for _loc in set(LEGEND_LOCS.values()): + LEGEND_LOCS[_loc] = _loc # identity assignments TEXT_LOCS = { key: value for key, value in LEGEND_LOCS.items() if value in ( 'left', 'center', 'right', @@ -629,23 +637,25 @@ def copy(self): 'lines.linestyle': '-', 'lines.linewidth': 1.5, 'lines.markersize': 6.0, - 'legend.borderaxespad': 0, # looks sleeker flush against edge - 'legend.borderpad': 0.5, # a bit more space - 'legend.columnspacing': 1.5, # more compact + 'legend.borderaxespad': 0, # i.e. flush against edge + 'legend.borderpad': 0.5, # a bit more roomy + 'legend.columnspacing': 1.5, # a bit more compact (see handletextpad) 'legend.edgecolor': BLACK, 'legend.facecolor': WHITE, - 'legend.fancybox': False, # looks modern without curvy box + 'legend.fancybox': False, # i.e. BboxStyle 'square' not 'round' 'legend.fontsize': SMALLSIZE, 'legend.framealpha': FRAMEALPHA, - 'legend.handletextpad': 0.5, + 'legend.handleheight': 1.0, # default is 0.7 + 'legend.handlelength': 2.0, # default is 2.0 + 'legend.handletextpad': 0.5, # a bit more compact (see columnspacing) 'mathtext.fontset': 'custom', 'mathtext.default': 'regular', 'patch.linewidth': LINEWIDTH, - 'savefig.bbox': None, # use custom tight layout - 'savefig.directory': '', # current directory - 'savefig.dpi': 1000, # academic journal recommendations for raster line art - 'savefig.facecolor': WHITE, # different from figure.facecolor - 'savefig.format': 'pdf', # most users use bitmap, but vector graphics are better + 'savefig.bbox': None, # do not use 'tight' + 'savefig.directory': '', # use the working directory + 'savefig.dpi': 1000, # use academic journal recommendation + 'savefig.facecolor': WHITE, # use white instead of 'auto' + 'savefig.format': 'pdf', # use vector graphics 'savefig.transparent': False, 'xtick.color': BLACK, 'xtick.direction': TICKDIR, @@ -710,28 +720,28 @@ def copy(self): _validate_pt, 'Width of the white border around a-b-c labels.' ), - 'abc.bbox': ( + 'abc.box': ( False, _validate_bool, 'Whether to draw semi-transparent bounding boxes around a-b-c labels ' 'when :rcraw:`abc.loc` is inside the axes.' ), - 'abc.bboxcolor': ( + 'abc.boxcolor': ( WHITE, _validate_color, 'a-b-c label bounding box color.' ), - 'abc.bboxstyle': ( - 'square', - _validate_boxstyle, - 'a-b-c label bounding box style.' - ), - 'abc.bboxalpha': ( + 'abc.boxalpha': ( 0.5, _validate_float, 'a-b-c label bounding box opacity.' ), - 'abc.bboxpad': ( + 'abc.boxstyle': ( + 'square', + _validate_boxstyle, + 'a-b-c label bounding box style.' + ), + 'abc.boxpad': ( None, _validate_or_none(_validate_pt), 'Padding for the a-b-c label bounding box. By default this is scaled ' @@ -1688,28 +1698,28 @@ def copy(self): _validate_pt, 'Width of the border around titles.' ), - 'title.bbox': ( + 'title.box': ( False, _validate_bool, 'Whether to draw semi-transparent bounding boxes around titles ' 'when :rcraw:`title.loc` is inside the axes.' ), - 'title.bboxcolor': ( + 'title.boxcolor': ( WHITE, _validate_color, 'Axes title bounding box color.' ), - 'title.bboxstyle': ( - 'square', - _validate_boxstyle, - 'Axes title bounding box style.' - ), - 'title.bboxalpha': ( + 'title.boxalpha': ( 0.5, _validate_float, 'Axes title bounding box opacity.' ), - 'title.bboxpad': ( + 'title.boxstyle': ( + 'square', + _validate_boxstyle, + 'Axes title bounding box style.' + ), + 'title.boxpad': ( None, _validate_or_none(_validate_pt), 'Padding for the title bounding box. By default this is scaled ' @@ -1921,6 +1931,16 @@ def copy(self): 'grid.latinline': ('grid.inlinelabels', '0.8'), 'cmap.edgefix': ('edgefix', '0.9'), 'colorbar.rasterize': ('colorbar.rasterized', '0.10'), + 'title.bbox': ('title.box', '0.10'), + 'title.bboxcolor': ('title.boxcolor', '0.10'), + 'title.bboxalpha': ('title.boxalpha', '0.10'), + 'title.bboxstyle': ('title.boxstyle', '0.10'), + 'title.bboxpad': ('title.boxpad', '0.10'), + 'abc.bbox': ('abc.box', '0.10'), + 'abc.bboxcolor': ('abc.boxcolor', '0.10'), + 'abc.bboxalpha': ('abc.boxalpha', '0.10'), + 'abc.bboxstyle': ('abc.boxstyle', '0.10'), + 'abc.bboxpad': ('abc.boxpad', '0.10'), } for _key_old, (_key_new, _) in _rc_renamed.items(): if _key_new in _rc_children: diff --git a/proplot/internals/texts.py b/proplot/internals/texts.py deleted file mode 100644 index 96131f47f..000000000 --- a/proplot/internals/texts.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env python3 -""" -Utilities related to matplotlib text objects. -""" -import matplotlib.patheffects as mpatheffects -import matplotlib.text as mtext - -from . import ic # noqa: F401 - - -def _transfer_text(src, dest): - """ - Transfer the input text object properties and content to the destination - text object. Then clear the input object text. - """ - text = src.get_text() - dest.set_color(src.get_color()) # not a font property - dest.set_fontproperties(src.get_fontproperties()) # size, weight, etc. - if not text.strip(): # WARNING: must test strip() (see _align_axis_labels()) - return - dest.set_text(text) - src.set_text('') - - -def _update_text(text, props=None, **kwargs): - """ - Add a monkey patch for ``Text.update`` with pseudo "border" and "bbox" - properties without wrapping the entire class. This facillitates inset titles. - """ - props = props or {} - props = props.copy() # shallow copy - props.update(kwargs) - - # Update border - border = props.pop('border', None) - bordercolor = props.pop('bordercolor', 'w') - borderinvert = props.pop('borderinvert', False) - borderwidth = props.pop('borderwidth', 2) - borderstyle = props.pop('borderstyle', 'miter') - if border: - facecolor, bgcolor = text.get_color(), bordercolor - if borderinvert: - facecolor, bgcolor = bgcolor, facecolor - kw = { - 'linewidth': borderwidth, - 'foreground': bgcolor, - 'joinstyle': borderstyle, - } - text.set_color(facecolor) - text.set_path_effects( - [mpatheffects.Stroke(**kw), mpatheffects.Normal()], - ) - elif border is False: - text.set_path_effects(None) - - # Update bounding box - # NOTE: We use '_title_pad' and '_title_above' for both titles and a-b-c - # labels because always want to keep them aligned. - # NOTE: For some reason using pad / 10 results in perfect alignment for - # med-large labels. Tried scaling to be font size relative but never works. - pad = text.axes._title_pad / 10 # default pad - bbox = props.pop('bbox', None) - bboxcolor = props.pop('bboxcolor', 'w') - bboxstyle = props.pop('bboxstyle', 'round') - bboxalpha = props.pop('bboxalpha', 0.5) - bboxpad = props.pop('bboxpad', None) - bboxpad = pad if bboxpad is None else bboxpad - if bbox is None: - pass - elif isinstance(bbox, dict): # *native* matplotlib usage - props['bbox'] = bbox - elif not bbox: - props['bbox'] = None # disable the bbox - else: - props['bbox'] = { - 'edgecolor': 'black', - 'facecolor': bboxcolor, - 'boxstyle': bboxstyle, - 'alpha': bboxalpha, - 'pad': bboxpad, - } - return mtext.Text.update(text, props)