Repository: matplotlib/matplotlib

Issue:
[Bug]: Nondeterministic behavior with subplot spacing and constrained layout
### Bug summary

I'm trying to get constrained layout to remove all space between subplots when no artists should be preventing it. In this example I've set all necessary rc params accordingly and changed the axes limits to ensure the x-axis tick labels don't spill beyond the axis extent. The resulting space between the axes is nondeterministic.


### Code for reproduction

```Python
%matplotlib inline

import matplotlib.pyplot as plt
rc = {
    "xtick.direction": "in", 
    "ytick.direction": "in",
    "figure.constrained_layout.use": True,
    "figure.constrained_layout.h_pad": 0,
    "figure.constrained_layout.w_pad": 0,
    "figure.constrained_layout.wspace": 0,
    "figure.constrained_layout.hspace": 0,
    "axes.labelpad": 0,
}

for _ in range(2):
    with plt.style.context(rc):
        fig, axes = plt.subplots(1, 2, figsize=(6, 2), sharey=True)
        
        for ax in axes.flat:
            ax.set_xlim(-0.1, 1.1)
```


### Actual outcome

Example output (ran a handful of times until one of each result showed):
![image](https://github.com/user-attachments/assets/23564beb-f5c1-4a03-a2f5-35cfb4083907)

### Expected outcome

Zero spacing between the axes, deterministically.

### Additional information

I noticed that the results became deterministic (with zero space between panels) when saving the figure (that is, the result that displayed in the notebook, not just the saved figure). Inserting a `fig.canvas.draw()` at the end likewise ensures zero space deterministically.

### Operating system

Rocky Linux

### Matplotlib Version

3.9.1

### Matplotlib Backend

inline

### Python version

3.11.9

### Jupyter version

4.2.3

### Installation

conda


Discussion:
I think this is expected behaviour and I'm not sure if we really can do anything about it.  This happens because when the constraints are first computed the xtick on the right hand side of the axis horizontally overspills the axis bounding box, so constrained layout adds a bit of space.  The axes gets redrawn, the axis is larger, and now the tick label fits.  

I don't think there is anything to directly be done about this.  I think you could take the tick label out of the layout, but that is a bit fiddily.  If you want the axes to always exactly touch, then I'm not sure I'd bother using constrained layout. 
Btw it maybe could be more clear, but this is the case covered by caveat 2 here https://matplotlib.org/stable/users/explain/axes/constrainedlayout_guide.html#other-caveats
> This happens because when the constraints are first computed the xtick on the right hand side of the axis horizontally overspills the axis bounding box, so constrained layout adds a bit of space. The axes gets redrawn, the axis is larger, and now the tick label fits.

I'm not sure I follow why this should be nondeterministic between identical executions. I do think the zero-space result is the correct one (following from what I stated under "additional information"), but even if it isn't I don't see why the result should be nondeterministic.

> I think you could take the tick label out of the layout, but that is a bit fiddily.

As an aside, I would like to know how to do this (using `set_in_layout` on the tick labels' `Text` instances didn't work) - but I asked about it on [discourse](https://discourse.matplotlib.org/t/finer-grained-control-over-constrained-layout/24554) because it's a separate question that I'm not yet sure is a bug.
This is a complicated interplay of our tick system being too customizable, being too lazy, and IMO, some weird cross-ownership of the calculation of tight bounding boxes. In order to calculate the bounding box of the `Axes`, the `Spine` is the one that handles the tick lines (which is odd to me, since the `Axis` owns the `Tick`s):
https://github.com/matplotlib/matplotlib/blob/9b8a8c7246c4a8e78095675f33e7f10c9d815dc4/lib/matplotlib/spines.py#L160-L178
In doing so, it picks a major tick, and a minor tick, and adds padding based on the direction in the tick's `_tickdir` attribute. However, it does this by passing the tick lists through some sets, meaning it will pick a random one depending on the set.

Ticks are generated lazily, and copy properties from the initial tick:
https://github.com/matplotlib/matplotlib/blob/9b8a8c7246c4a8e78095675f33e7f10c9d815dc4/lib/matplotlib/axis.py#L1611-L1619
This copies the properties from sub-Artist to sub-Artist, but _not_ the `_tickdir` attribute (or any others for that matter). So it will be whatever the default rcParam is at the time of the copy. If you run a draw in the context, the copies happen inside, and are all `_tickdir='in'`. If you don't, then some ticks will have the right value, and some won't.

This means that the `Spine` will sometimes calculate a bounding box for a inward tick, and sometimes one for an outward tick, depending on which random tick it used.



Patch:
diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py
index 483c9a3db15f..d1bbd095da87 100644
--- a/lib/matplotlib/axis.py
+++ b/lib/matplotlib/axis.py
@@ -33,12 +33,6 @@
 _gridline_param_names = ['grid_' + name
                          for name in _line_param_names + _line_param_aliases]
 
-_MARKER_DICT = {
-    'out': (mlines.TICKDOWN, mlines.TICKUP),
-    'in': (mlines.TICKUP, mlines.TICKDOWN),
-    'inout': ('|', '|'),
-}
-
 
 class Tick(martist.Artist):
     """
@@ -204,18 +198,21 @@ def _set_labelrotation(self, labelrotation):
         _api.check_in_list(['auto', 'default'], labelrotation=mode)
         self._labelrotation = (mode, angle)
 
+    @property
+    def _pad(self):
+        return self._base_pad + self.get_tick_padding()
+
     def _apply_tickdir(self, tickdir):
         """Set tick direction.  Valid values are 'out', 'in', 'inout'."""
-        # This method is responsible for updating `_pad`, and, in subclasses,
-        # for setting the tick{1,2}line markers as well.  From the user
-        # perspective this should always be called through _apply_params, which
-        # further updates ticklabel positions using the new pads.
+        # This method is responsible for verifying input and, in subclasses, for setting
+        # the tick{1,2}line markers.  From the user perspective this should always be
+        # called through _apply_params, which further updates ticklabel positions using
+        # the new pads.
         if tickdir is None:
             tickdir = mpl.rcParams[f'{self.__name__}.direction']
         else:
             _api.check_in_list(['in', 'out', 'inout'], tickdir=tickdir)
         self._tickdir = tickdir
-        self._pad = self._base_pad + self.get_tick_padding()
 
     def get_tickdir(self):
         return self._tickdir
@@ -425,7 +422,11 @@ def _get_text2_transform(self):
     def _apply_tickdir(self, tickdir):
         # docstring inherited
         super()._apply_tickdir(tickdir)
-        mark1, mark2 = _MARKER_DICT[self._tickdir]
+        mark1, mark2 = {
+            'out': (mlines.TICKDOWN, mlines.TICKUP),
+            'in': (mlines.TICKUP, mlines.TICKDOWN),
+            'inout': ('|', '|'),
+        }[self._tickdir]
         self.tick1line.set_marker(mark1)
         self.tick2line.set_marker(mark2)
 
@@ -1617,6 +1618,14 @@ def _copy_tick_props(self, src, dest):
         dest.tick1line.update_from(src.tick1line)
         dest.tick2line.update_from(src.tick2line)
         dest.gridline.update_from(src.gridline)
+        dest.update_from(src)
+        dest._loc = src._loc
+        dest._size = src._size
+        dest._width = src._width
+        dest._base_pad = src._base_pad
+        dest._labelrotation = src._labelrotation
+        dest._zorder = src._zorder
+        dest._tickdir = src._tickdir
 
     def get_label_text(self):
         """Get the text of the label."""


Test patch:
diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py
index e5ae14c6e66b..2c10a93796fa 100644
--- a/lib/matplotlib/tests/test_axes.py
+++ b/lib/matplotlib/tests/test_axes.py
@@ -5741,6 +5741,28 @@ def test_reset_ticks(fig_test, fig_ref):
         ax.yaxis.reset_ticks()
 
 
+@mpl.style.context('mpl20')
+def test_context_ticks():
+    with plt.rc_context({
+            'xtick.direction': 'in', 'xtick.major.size': 30, 'xtick.major.width': 5,
+            'xtick.color': 'C0', 'xtick.major.pad': 12,
+            'xtick.bottom': True, 'xtick.top': True,
+            'xtick.labelsize': 14, 'xtick.labelcolor': 'C1'}):
+        fig, ax = plt.subplots()
+    # Draw outside the context so that all-but-first tick are generated with the normal
+    # mpl20 style in place.
+    fig.draw_without_rendering()
+
+    first_tick = ax.xaxis.majorTicks[0]
+    for tick in ax.xaxis.majorTicks[1:]:
+        assert tick._size == first_tick._size
+        assert tick._width == first_tick._width
+        assert tick._base_pad == first_tick._base_pad
+        assert tick._labelrotation == first_tick._labelrotation
+        assert tick._zorder == first_tick._zorder
+        assert tick._tickdir == first_tick._tickdir
+
+
 def test_vline_limit():
     fig = plt.figure()
     ax = fig.gca()