@@ -1558,6 +1558,150 @@ def disconnect(self, cid):
15581558 self ._observers .disconnect (cid )
15591559
15601560
1561+ def _calculate_widget_button_layout (ax , labels , label_props , layout ):
1562+ """
1563+ Calculate positions for button widgets (RadioButtons, CheckButtons).
1564+
1565+ Parameters
1566+ ----------
1567+ ax : `~matplotlib.axes.Axes`
1568+ The Axes to calculate positions for.
1569+ labels : list of str
1570+ The button labels.
1571+ label_props : iterable of dict
1572+ Text properties for each label (from _expand_text_props).
1573+ layout : None, "vertical", "horizontal", or (int, int)
1574+ The layout specification:
1575+ - None: Use legacy vertical layout (fixed positions).
1576+ - "vertical": Arrange buttons in a single column.
1577+ - "horizontal": Arrange buttons in a single row.
1578+ - (rows, cols): Arrange buttons in a grid.
1579+
1580+ Returns
1581+ -------
1582+ button_xs : list of float
1583+ X coordinates for buttons in axes coordinates.
1584+ button_ys : list of float
1585+ Y coordinates for buttons in axes coordinates.
1586+ label_xs : list of float
1587+ X coordinates for labels in axes coordinates.
1588+ label_ys : list of float
1589+ Y coordinates for labels in axes coordinates.
1590+ """
1591+ n_labels = len (labels )
1592+
1593+ if layout is None :
1594+ # Legacy behavior: simple vertical layout with fixed positions
1595+ ys = np .linspace (1 , 0 , n_labels + 2 )[1 :- 1 ]
1596+ button_xs = [0.15 ] * n_labels
1597+ button_ys = list (ys )
1598+ label_xs = [0.25 ] * n_labels
1599+ label_ys = list (ys )
1600+ return button_xs , button_ys , label_xs , label_ys
1601+
1602+ # New layout algorithm with text measurement
1603+ # Parse layout parameter
1604+ bad_layout_raise_msg = \
1605+ "layout must be None, 'vertical', 'horizontal', or a (rows, cols) tuple; " \
1606+ f"got { layout !r} "
1607+ if isinstance (layout , str ):
1608+ if layout == "vertical" :
1609+ n_rows , n_cols = n_labels , 1
1610+ elif layout == "horizontal" :
1611+ n_rows , n_cols = 1 , n_labels
1612+ else :
1613+ raise ValueError (bad_layout_raise_msg )
1614+ elif isinstance (layout , tuple ) and len (layout ) == 2 :
1615+ n_rows , n_cols = layout
1616+ if not (isinstance (n_rows , int ) and isinstance (n_cols , int )):
1617+ raise TypeError (
1618+ f"layout tuple must contain two integers; got { layout !r} "
1619+ )
1620+ if n_rows * n_cols < n_labels :
1621+ raise ValueError (
1622+ f"layout { layout } has { n_rows * n_cols } positions but "
1623+ f"{ n_labels } labels were provided"
1624+ )
1625+ else :
1626+ raise ValueError (bad_layout_raise_msg )
1627+
1628+ # Define spacing in display units (pixels) for consistency
1629+ # across different axes sizes
1630+ axes_width_display = ax .bbox .width
1631+ left_margin_display = 15 # pixels
1632+ button_text_offset_display = 6.5 # pixels
1633+ col_spacing_display = 15 # pixels
1634+
1635+ # Convert to axes coordinates
1636+ left_margin = left_margin_display / axes_width_display
1637+ button_text_offset = button_text_offset_display / axes_width_display
1638+ col_spacing = col_spacing_display / axes_width_display
1639+
1640+ # Create temporary text objects to measure widths
1641+ temp_texts = []
1642+ for label , props in zip (labels , label_props ):
1643+ temp_texts .append (ax .text (
1644+ 0 ,
1645+ 0 ,
1646+ label ,
1647+ transform = ax .transAxes ,
1648+ ** props ,
1649+ ))
1650+ # Force a draw to get accurate text measurements
1651+ ax .figure .canvas .draw ()
1652+
1653+ # Calculate max text width per column (in axes coordinates)
1654+ col_widths = []
1655+ for col_idx in range (n_cols ):
1656+ col_texts = []
1657+ for row_idx in range (n_rows ):
1658+ label_idx = row_idx * n_cols + col_idx
1659+ if label_idx < n_labels :
1660+ col_texts .append (temp_texts [label_idx ])
1661+ if col_texts :
1662+ col_widths .append (
1663+ max (
1664+ text .get_window_extent (
1665+ ax .figure .canvas .get_renderer ()
1666+ ).width
1667+ for text in col_texts
1668+ ) / axes_width_display
1669+ )
1670+ else :
1671+ col_widths .append (0 )
1672+ # Remove temporary text objects
1673+ for text in temp_texts :
1674+ text .remove ()
1675+
1676+ # Center rows vertically in the axes
1677+ ys_per_row = np .linspace (1 , 0 , n_rows + 2 )[1 :- 1 ]
1678+ # Calculate x positions based on text widths
1679+ col_x_positions = [left_margin ] # First column starts at left margin
1680+ for col_idx in range (n_cols - 1 ):
1681+ col_x_positions .append (
1682+ col_x_positions [- 1 ] +
1683+ button_text_offset +
1684+ col_widths [col_idx ] +
1685+ col_spacing
1686+ )
1687+ # Create final positions (left-to-right, top-to-bottom)
1688+ button_xs = []
1689+ button_ys = []
1690+ label_xs = []
1691+ label_ys = []
1692+ for label_idx in range (n_labels ):
1693+ row_idx = label_idx // n_cols
1694+ col_idx = label_idx % n_cols
1695+ x = col_x_positions [col_idx ]
1696+ y = ys_per_row [row_idx ]
1697+ button_xs .append (x )
1698+ button_ys .append (y )
1699+ label_xs .append (x + button_text_offset )
1700+ label_ys .append (y )
1701+
1702+ return button_xs , button_ys , label_xs , label_ys
1703+
1704+
15611705class RadioButtons (AxesWidget ):
15621706 """
15631707 A GUI neutral radio button.
@@ -1582,7 +1726,7 @@ class RadioButtons(AxesWidget):
15821726 """
15831727
15841728 def __init__ (self , ax , labels , active = 0 , activecolor = None , * ,
1585- layout = "vertical" , useblit = True , label_props = None , radio_props = None ):
1729+ layout = None , useblit = True , label_props = None , radio_props = None ):
15861730 """
15871731 Add radio buttons to an `~.axes.Axes`.
15881732
@@ -1597,14 +1741,18 @@ def __init__(self, ax, labels, active=0, activecolor=None, *,
15971741 activecolor : :mpltype:`color`
15981742 The color of the selected button. The default is ``'blue'`` if not
15991743 specified here or in *radio_props*.
1600- layout : {"vertical", "horizontal"} or (int, int), default: "vertical"
1744+ layout : None, {"vertical", "horizontal"} or (int, int), default: None
16011745 The layout of the radio buttons. Options are:
16021746
1603- - ``"vertical"``: Arrange buttons in a single column (default).
1604- - ``"horizontal"``: Arrange buttons in a single row.
1747+ - ``None``: Use legacy vertical layout (default, keeps backward
1748+ compatibility with fixed button and label positions).
1749+ - ``"vertical"``: Arrange buttons in a single column with
1750+ dynamic positioning based on text widths.
1751+ - ``"horizontal"``: Arrange buttons in a single row with
1752+ dynamic positioning based on text widths.
16051753 - ``(rows, cols)`` tuple: Arrange buttons in a grid with the
16061754 specified number of rows and columns. Buttons are placed
1607- left-to-right, top-to-bottom.
1755+ left-to-right, top-to-bottom with dynamic positioning .
16081756
16091757 .. versionadded:: 3.11
16101758 useblit : bool, default: True
@@ -1640,31 +1788,6 @@ def __init__(self, ax, labels, active=0, activecolor=None, *,
16401788 labels = list (labels )
16411789 n_labels = len (labels )
16421790
1643- bad_layout_raise_msg = \
1644- "layout must be 'vertical', 'horizontal', or a (rows, cols) tuple; " \
1645- f"got { layout !r} "
1646- # Parse layout parameter
1647- if isinstance (layout , str ):
1648- if layout == "vertical" :
1649- n_rows , n_cols = n_labels , 1
1650- elif layout == "horizontal" :
1651- n_rows , n_cols = 1 , n_labels
1652- else :
1653- raise ValueError (bad_layout_raise_msg )
1654- elif isinstance (layout , tuple ) and len (layout ) == 2 :
1655- n_rows , n_cols = layout
1656- if not (isinstance (n_rows , int ) and isinstance (n_cols , int )):
1657- raise TypeError (
1658- f"layout tuple must contain two integers; got { layout !r} "
1659- )
1660- if n_rows * n_cols < n_labels :
1661- raise ValueError (
1662- f"layout { layout } has { n_rows * n_cols } positions but "
1663- f"{ n_labels } labels were provided"
1664- )
1665- else :
1666- raise ValueError (bad_layout_raise_msg )
1667-
16681791 radio_props = cbook .normalize_kwargs (radio_props ,
16691792 collections .PathCollection )
16701793 if activecolor is not None :
@@ -1690,81 +1813,16 @@ def __init__(self, ax, labels, active=0, activecolor=None, *,
16901813
16911814 label_props = _expand_text_props (label_props )
16921815
1693- # Define spacing in display units (pixels) for consistency
1694- # across different axes sizes
1695- axes_width_display = ax .bbox .width
1696- left_margin_display = 15 # pixels
1697- button_text_offset_display = 6.5 # pixels
1698- col_spacing_display = 15 # pixels
1699-
1700- # Convert to axes coordinates
1701- left_margin = left_margin_display / axes_width_display
1702- button_text_offset = button_text_offset_display / axes_width_display
1703- col_spacing = col_spacing_display / axes_width_display
1704-
1705- # Create temporary text objects to measure widths
1706- temp_texts = []
1707- for label , props in zip (labels , label_props ):
1708- temp_texts .append (ax .text (
1709- 0 ,
1710- 0 ,
1711- label ,
1712- transform = ax .transAxes ,
1713- ** props ,
1714- ))
1715- # Force a draw to get accurate text measurements
1716- ax .figure .canvas .draw ()
1717-
1718- # Calculate max text width per column (in axes coordinates)
1719- col_widths = []
1720- for col_idx in range (n_cols ):
1721- col_texts = []
1722- for row_idx in range (n_rows ):
1723- label_idx = row_idx * n_cols + col_idx
1724- if label_idx < n_labels :
1725- col_texts .append (temp_texts [label_idx ])
1726- if col_texts :
1727- col_widths .append (
1728- max (
1729- text .get_window_extent (
1730- ax .figure .canvas .get_renderer ()
1731- ).width
1732- for text in col_texts
1733- ) / axes_width_display
1734- )
1735- else :
1736- col_widths .append (0 )
1737- # Remove temporary text objects
1738- for text in temp_texts :
1739- text .remove ()
1740-
1741- # Center rows vertically in the axes
1742- ys_per_row = np .linspace (1 , 0 , n_rows + 2 )[1 :- 1 ]
1743- # Calculate x positions based on text widths
1744- col_x_positions = [left_margin ] # First column starts at left margin
1745- for col_idx in range (n_cols - 1 ):
1746- col_x_positions .append (
1747- col_x_positions [- 1 ] +
1748- button_text_offset +
1749- col_widths [col_idx ] +
1750- col_spacing
1751- )
1752- # Create final positions (left-to-right, top-to-bottom)
1753- xs = []
1754- ys = []
1755- for label_idx in range (n_labels ):
1756- row_idx = label_idx // n_cols
1757- col_idx = label_idx % n_cols
1758- x = col_x_positions [col_idx ]
1759- y = ys_per_row [row_idx ]
1760- xs .append (x )
1761- ys .append (y )
1816+ # Calculate button and label positions
1817+ button_xs , button_ys , label_xs , label_ys = _calculate_widget_button_layout (
1818+ ax , labels , label_props , layout
1819+ )
17621820
17631821 self .labels = [
1764- ax .text (x + button_text_offset , y , label , transform = ax .transAxes ,
1822+ ax .text (x , y , label , transform = ax .transAxes ,
17651823 horizontalalignment = "left" , verticalalignment = "center" ,
17661824 ** props )
1767- for x , y , label , props in zip (xs , ys , labels , label_props )]
1825+ for x , y , label , props in zip (label_xs , label_ys , labels , label_props )]
17681826 text_size = np .array ([text .get_fontsize () for text in self .labels ]) / 2
17691827
17701828 radio_props = {
@@ -1777,7 +1835,7 @@ def __init__(self, ax, labels, active=0, activecolor=None, *,
17771835 radio_props .setdefault ('edgecolor' , radio_props .get ('color' , 'black' ))
17781836 radio_props .setdefault ('facecolor' ,
17791837 radio_props .pop ('color' , activecolor ))
1780- self ._buttons = ax .scatter (xs , ys , ** radio_props )
1838+ self ._buttons = ax .scatter (button_xs , button_ys , ** radio_props )
17811839 # The user may have passed custom colours in radio_props, so we need to
17821840 # create the radios, and modify the visibility after getting whatever
17831841 # the user set.
0 commit comments