Skip to content

Commit b535b4e

Browse files
committed
*Buttons: support more layouts
1 parent f6273a0 commit b535b4e

6 files changed

Lines changed: 300 additions & 15 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
RadioButtons and CheckButtons widgets support flexible layouts
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
The `.widgets.RadioButtons` and `.widgets.CheckButtons` widgets now support
5+
arranging buttons in different layouts via the new *layout* parameter. You can
6+
arrange buttons vertically (default), horizontally, or in a 2D grid by passing
7+
a ``(rows, cols)`` tuple.
8+
9+
See :doc:`/gallery/widgets/radio_buttons_grid` for a ``(rows, cols)`` example.
10+
11+
.. plot::
12+
:include-source: true
13+
:alt: Multiple sine waves with checkboxes to toggle their visibility.
14+
15+
import matplotlib.pyplot as plt
16+
import numpy as np
17+
from matplotlib.widgets import CheckButtons
18+
19+
t = np.arange(0.0, 2.0, 0.01)
20+
s0 = np.sin(2*np.pi*t)
21+
s1 = np.sin(4*np.pi*t)
22+
s2 = np.sin(6*np.pi*t)
23+
s3 = np.sin(8*np.pi*t)
24+
25+
fig, axes = plt.subplot_mosaic(
26+
[['main'], ['buttons']],
27+
height_ratios=[8, 1],
28+
layout="constrained",
29+
)
30+
31+
l0, = axes['main'].plot(t, s0, lw=2, color='red', label='2 Hz')
32+
l1, = axes['main'].plot(t, s1, lw=2, color='green', label='4 Hz')
33+
l2, = axes['main'].plot(t, s2, lw=2, color='blue', label='6 Hz')
34+
l3, = axes['main'].plot(t, s3, lw=2, color='purple', label='8 Hz')
35+
axes['main'].set_xlabel('Time (s)')
36+
axes['main'].set_ylabel('Amplitude')
37+
38+
lines_by_label = {l.get_label(): l for l in [l0, l1, l2, l3]}
39+
40+
axes['buttons'].set_facecolor('0.9')
41+
check = CheckButtons(
42+
axes['buttons'],
43+
labels=lines_by_label.keys(),
44+
actives=[l.get_visible() for l in lines_by_label.values()],
45+
layout='horizontal'
46+
)
47+
48+
def callback(label):
49+
ln = lines_by_label[label]
50+
ln.set_visible(not ln.get_visible())
51+
fig.canvas.draw_idle()
52+
53+
check.on_clicked(callback)
54+
plt.show()
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""
2+
==================
3+
Radio Buttons Grid
4+
==================
5+
6+
Using radio buttons in a 2D grid layout.
7+
8+
Radio buttons can be arranged in a 2D grid by passing a ``(rows, cols)``
9+
tuple to the *layout* parameter. This is useful when you have multiple
10+
related options that are best displayed in a grid format rather than a
11+
vertical list.
12+
13+
In this example, we create a color picker using a 2D grid of radio buttons
14+
to select the line color of a plot.
15+
"""
16+
17+
import matplotlib.pyplot as plt
18+
import numpy as np
19+
20+
from matplotlib.widgets import RadioButtons
21+
22+
# Generate sample data
23+
t = np.arange(0.0, 2.0, 0.01)
24+
s = np.sin(2 * np.pi * t)
25+
26+
fig, (ax_plot, ax_buttons) = plt.subplots(
27+
1,
28+
2,
29+
figsize=(8, 4),
30+
width_ratios=[4, 1.4],
31+
)
32+
33+
# Create initial plot
34+
(line,) = ax_plot.plot(t, s, lw=2, color="red")
35+
ax_plot.set_xlabel("Time (s)")
36+
ax_plot.set_ylabel("Amplitude")
37+
ax_plot.set_title("Sine Wave - Click a color!")
38+
ax_plot.grid(True, alpha=0.3)
39+
40+
# Configure the radio buttons axes
41+
ax_buttons.set_facecolor("0.9")
42+
ax_buttons.set_title("Line Color", fontsize=12, pad=10)
43+
# Create a 2D grid of color options (3 rows x 2 columns)
44+
colors = ["red", "yellow", "green", "purple", "brown", "gray"]
45+
radio = RadioButtons(ax_buttons, colors, layout=(3, 2))
46+
47+
48+
def color_func(label):
49+
"""Update the line color based on selected button."""
50+
line.set_color(label)
51+
fig.canvas.draw()
52+
53+
54+
radio.on_clicked(color_func)
55+
56+
plt.show()
57+
58+
# %%
59+
#
60+
# .. admonition:: References
61+
#
62+
# The use of the following functions, methods, classes and modules is shown
63+
# in this example:
64+
#
65+
# - `matplotlib.widgets.RadioButtons`
66+
#
67+
# .. tags::
68+
#
69+
# styling: color
70+
# styling: conditional
71+
# plot-type: line
72+
# level: intermediate
73+
# purpose: showcase
21.5 KB
Loading

lib/matplotlib/tests/test_widgets.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,29 @@ def test_radio_buttons_props(fig_test, fig_ref):
11641164
cb.set_radio_props({**radio_props, 's': (24 / 2)**2})
11651165

11661166

1167+
@image_comparison(['check_radio_grid_buttons.png'], style='mpl20', remove_text=True)
1168+
def test_radio_grid_buttons():
1169+
fig = plt.figure()
1170+
rb_horizontal = widgets.RadioButtons(
1171+
fig.add_axes((0.1, 0.05, 0.65, 0.05)),
1172+
["tea", "coffee", "chocolate milk", "water", "soda", "coke"],
1173+
layout='horizontal',
1174+
active=4,
1175+
)
1176+
cb_grid = widgets.CheckButtons(
1177+
fig.add_axes((0.1, 0.15, 0.25, 0.05*3)),
1178+
["Chicken", "Salad", "Rice", "Sushi", "Pizza", "Fries"],
1179+
layout=(3, 2),
1180+
actives=[True, True, False, False, False, True],
1181+
)
1182+
rb_vertical = widgets.RadioButtons(
1183+
fig.add_axes((0.1, 0.35, 0.2, 0.05*4)),
1184+
["Trinity Cream", "Cake", "Ice Cream", "Muhallebi"],
1185+
layout='vertical',
1186+
active=3,
1187+
)
1188+
1189+
11671190
def test_radio_button_active_conflict(ax):
11681191
with pytest.warns(UserWarning,
11691192
match=r'Both the \*activecolor\* parameter'):

lib/matplotlib/widgets.py

Lines changed: 148 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,7 +1048,8 @@ class _Buttons(AxesWidget):
10481048
public on the subclasses.
10491049
"""
10501050

1051-
def __init__(self, ax, labels, *, useblit=True, label_props=None, **kwargs):
1051+
def __init__(self, ax, labels, *, useblit=True, label_props=None, layout=None,
1052+
**kwargs):
10521053
super().__init__(ax)
10531054

10541055
ax.set_xticks([])
@@ -1057,7 +1058,7 @@ def __init__(self, ax, labels, *, useblit=True, label_props=None, **kwargs):
10571058

10581059
self._useblit = useblit
10591060

1060-
self._init_layout(labels, label_props)
1061+
self._init_layout(layout, labels, label_props)
10611062
text_size = np.array([text.get_fontsize() for text in self.labels]) / 2
10621063

10631064
self._init_props(text_size, **kwargs)
@@ -1068,17 +1069,101 @@ def __init__(self, ax, labels, *, useblit=True, label_props=None, **kwargs):
10681069

10691070
self._observers = cbook.CallbackRegistry(signals=["clicked"])
10701071

1071-
def _init_layout(self, labels, label_props):
1072-
self._buttons_xs = [0.15] * len(labels)
1073-
self._buttons_ys = np.linspace(1, 0, len(labels)+2)[1:-1]
1072+
def _init_layout(self, layout, labels, label_props):
10741073

10751074
label_props = _expand_text_props(label_props)
10761075

1076+
if layout is None:
1077+
self._buttons_xs = [0.15] * len(labels)
1078+
self._buttons_ys = np.linspace(1, 0, len(labels)+2)[1:-1]
1079+
self.labels = [
1080+
self.ax.text(0.25, y, label, transform=self.ax.transAxes,
1081+
horizontalalignment="left", verticalalignment="center",
1082+
**props)
1083+
for y, label, props in zip(self._buttons_ys, labels, label_props)]
1084+
return
1085+
1086+
# New layout algorithm with text measurement
1087+
# Parse layout parameter
1088+
n_labels = len(labels)
1089+
match layout:
1090+
case "vertical":
1091+
n_rows, n_cols = n_labels, 1
1092+
case "horizontal":
1093+
n_rows, n_cols = 1, n_labels
1094+
case (int() as n_rows, int() as n_cols):
1095+
if n_rows * n_cols < n_labels:
1096+
raise ValueError(
1097+
f"layout {layout} has {n_rows * n_cols} positions but "
1098+
f"{n_labels} labels were provided"
1099+
)
1100+
case _:
1101+
raise ValueError(
1102+
"layout must be None, 'vertical', 'horizontal', or a (rows, cols) "
1103+
f"tuple; got {layout!r}")
1104+
1105+
# Define spacing in points for DPI-independent sizing
1106+
fig = self.ax.get_figure(root=False)
1107+
axes_width_display = 72 * self.ax.bbox.transformed(
1108+
fig.dpi_scale_trans.inverted()
1109+
).width
1110+
left_margin_display = 11 # points
1111+
button_text_offset_display = 5.5 # points
1112+
col_spacing_display = 11 # points
1113+
1114+
# Convert to axes coordinates
1115+
left_margin = left_margin_display / axes_width_display
1116+
button_text_offset = button_text_offset_display / axes_width_display
1117+
col_spacing = col_spacing_display / axes_width_display
1118+
1119+
# Create text objects to measure widths.
1120+
# We create Text objects directly rather than using ax.text() since we're
1121+
# only measuring them and only later add them to the axes.
10771122
self.labels = [
1078-
self.ax.text(0.25, y, label, transform=self.ax.transAxes,
1079-
horizontalalignment="left", verticalalignment="center",
1080-
**props)
1081-
for y, label, props in zip(self._buttons_ys, labels, label_props)]
1123+
mtext.Text(0, 0, text=label, transform=self.ax.transAxes,
1124+
horizontalalignment="left", verticalalignment="center",
1125+
**props)
1126+
for label, props in zip(labels, label_props)
1127+
]
1128+
# Set figure reference so Text objects can access figure properties
1129+
for text in self.labels:
1130+
text.set_figure(fig)
1131+
# Calculate max text width per column (in axes coordinates)
1132+
col_widths = [
1133+
max(
1134+
(
1135+
text.get_window_extent(
1136+
self.ax.figure.canvas.get_renderer(),
1137+
).transformed(
1138+
fig.dpi_scale_trans.inverted()
1139+
).width * 72
1140+
for text in self.labels[col_idx::n_cols]
1141+
),
1142+
default=0,
1143+
)
1144+
/ axes_width_display
1145+
for col_idx in range(n_cols)
1146+
]
1147+
1148+
# Center rows vertically in the axes
1149+
ys_per_row = np.linspace(1, 0, n_rows + 2)[1:-1]
1150+
# Calculate x positions based on text widths
1151+
col_x_positions = [left_margin] # First column starts at left margin
1152+
for col_idx in range(n_cols - 1):
1153+
col_x_positions.append(
1154+
col_x_positions[-1] +
1155+
button_text_offset +
1156+
col_widths[col_idx] +
1157+
col_spacing
1158+
)
1159+
label_idx = np.arange(n_labels)
1160+
self._buttons_xs = np.take(col_x_positions, label_idx % n_cols)
1161+
self._buttons_ys = ys_per_row[label_idx // n_cols]
1162+
for text,x,y in zip(self.labels, self._buttons_xs + button_text_offset,
1163+
self._buttons_ys):
1164+
text.set_x(x)
1165+
text.set_y(y)
1166+
self.ax.add_artist(text)
10821167

10831168
def _init_props(self, text_size, **kwargs):
10841169
raise NotImplementedError("This method should be defined in subclasses")
@@ -1165,7 +1250,7 @@ class CheckButtons(_Buttons):
11651250
The text label objects of the check buttons.
11661251
"""
11671252

1168-
def __init__(self, ax, labels, actives=None, *, useblit=True,
1253+
def __init__(self, ax, labels, actives=None, *, layout=None, useblit=True,
11691254
label_props=None, frame_props=None, check_props=None):
11701255
"""
11711256
Add check buttons to `~.axes.Axes` instance *ax*.
@@ -1179,6 +1264,30 @@ def __init__(self, ax, labels, actives=None, *, useblit=True,
11791264
actives : list of bool, optional
11801265
The initial check states of the buttons. The list must have the
11811266
same length as *labels*. If not given, all buttons are unchecked.
1267+
layout : None or "vertical" or "horizontal" or (int, int), default: None
1268+
The layout of the check buttons. Options are:
1269+
1270+
- ``None``: Use legacy vertical layout (default).
1271+
- ``"vertical"``: Arrange buttons in a single column with
1272+
dynamic positioning based on text widths.
1273+
- ``"horizontal"``: Arrange buttons in a single row with
1274+
dynamic positioning based on text widths.
1275+
- ``(rows, cols)`` tuple: Arrange buttons in a grid with the
1276+
specified number of rows and columns. Buttons are placed
1277+
left-to-right, top-to-bottom with dynamic positioning.
1278+
1279+
The layout options "vertical", "horizontal" and ``(rows, cols)``
1280+
create ``mtext.Text`` objects to determine exact text sizes, and
1281+
then they are added axes. This is usually ok, but may cause
1282+
side-effects and has a slight performance impact. Therefore the
1283+
default ``None`` value avoids this.
1284+
1285+
.. admonition:: Provisional
1286+
The new layout options are provisional. Their algorithmic
1287+
behavior, including the exact positions of buttons and labels
1288+
may still change without prior warning.
1289+
1290+
.. versionadded:: 3.11
11821291
useblit : bool, default: True
11831292
Use blitting for faster drawing if supported by the backend.
11841293
See the tutorial :ref:`blitting` for details.
@@ -1208,9 +1317,9 @@ def __init__(self, ax, labels, actives=None, *, useblit=True,
12081317
_api.check_isinstance((dict, None), label_props=label_props,
12091318
frame_props=frame_props, check_props=check_props)
12101319

1211-
super().__init__(ax, labels, useblit=useblit, label_props=label_props,
1212-
actives=actives, frame_props=frame_props,
1213-
check_props=check_props)
1320+
super().__init__(ax, labels, layout=layout, useblit=useblit,
1321+
label_props=label_props, actives=actives,
1322+
frame_props=frame_props, check_props=check_props)
12141323

12151324
def _init_props(self, text_size, actives, frame_props, check_props):
12161325
frame_props = {
@@ -1671,7 +1780,7 @@ class RadioButtons(_Buttons):
16711780
The index of the selected button.
16721781
"""
16731782

1674-
def __init__(self, ax, labels, active=0, activecolor=None, *,
1783+
def __init__(self, ax, labels, active=0, activecolor=None, *, layout=None,
16751784
useblit=True, label_props=None, radio_props=None):
16761785
"""
16771786
Add radio buttons to an `~.axes.Axes`.
@@ -1687,6 +1796,30 @@ def __init__(self, ax, labels, active=0, activecolor=None, *,
16871796
activecolor : :mpltype:`color`
16881797
The color of the selected button. The default is ``'blue'`` if not
16891798
specified here or in *radio_props*.
1799+
layout : None or "vertical" or "horizontal" or (int, int), default: None
1800+
The layout of the radio buttons. Options are:
1801+
1802+
- ``None``: Use legacy vertical layout (default).
1803+
- ``"vertical"``: Arrange buttons in a single column with
1804+
dynamic positioning based on text widths.
1805+
- ``"horizontal"``: Arrange buttons in a single row with
1806+
dynamic positioning based on text widths.
1807+
- ``(rows, cols)`` tuple: Arrange buttons in a grid with the
1808+
specified number of rows and columns. Buttons are placed
1809+
left-to-right, top-to-bottom with dynamic positioning.
1810+
1811+
The layout options "vertical", "horizontal" and ``(rows, cols)``
1812+
create ``mtext.Text`` objects to determine exact text sizes, and
1813+
then they are added axes. This is usually ok, but may cause
1814+
side-effects and has a slight performance impact. Therefore the
1815+
default ``None`` value avoids this.
1816+
1817+
.. admonition:: Provisional
1818+
The new layout options are provisional. Their algorithmic
1819+
behavior, including the exact positions of buttons and labels
1820+
may still change without prior warning.
1821+
1822+
.. versionadded:: 3.11
16901823
useblit : bool, default: True
16911824
Use blitting for faster drawing if supported by the backend.
16921825
See the tutorial :ref:`blitting` for details.
@@ -1726,7 +1859,7 @@ def __init__(self, ax, labels, active=0, activecolor=None, *,
17261859
else:
17271860
activecolor = 'blue' # Default.
17281861
super().__init__(ax, labels, useblit=useblit, label_props=label_props,
1729-
active=active, activecolor=activecolor,
1862+
active=active, layout=layout, activecolor=activecolor,
17301863
radio_props=radio_props)
17311864

17321865
self._activecolor = activecolor

0 commit comments

Comments
 (0)