streamlit-nightly 1.38.1.dev20240918__py2.py3-none-any.whl → 1.38.1.dev20240920__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. streamlit/__init__.py +1 -0
  2. streamlit/elements/deck_gl_json_chart.py +26 -2
  3. streamlit/elements/map.py +27 -1
  4. streamlit/elements/widgets/button_group.py +269 -68
  5. streamlit/navigation/page.py +10 -6
  6. streamlit/proto/ButtonGroup_pb2.py +12 -9
  7. streamlit/proto/ButtonGroup_pb2.pyi +50 -3
  8. streamlit/proto/DeckGlJsonChart_pb2.py +3 -3
  9. streamlit/proto/DeckGlJsonChart_pb2.pyi +9 -1
  10. streamlit/static/asset-manifest.json +9 -8
  11. streamlit/static/index.html +1 -1
  12. streamlit/static/static/js/2055.bca43613.chunk.js +1 -0
  13. streamlit/static/static/js/5625.fe6c22ad.chunk.js +1 -0
  14. streamlit/static/static/js/{7077.e21833ae.chunk.js → 6789.f8dde736.chunk.js} +2 -2
  15. streamlit/static/static/js/8485.81bdf474.chunk.js +1 -0
  16. streamlit/static/static/js/9943.d18fdff1.chunk.js +1 -0
  17. streamlit/static/static/js/main.829dd23b.js +28 -0
  18. streamlit/web/server/routes.py +6 -0
  19. streamlit/web/server/server.py +9 -1
  20. streamlit/web/server/server_util.py +10 -3
  21. {streamlit_nightly-1.38.1.dev20240918.dist-info → streamlit_nightly-1.38.1.dev20240920.dist-info}/METADATA +1 -1
  22. {streamlit_nightly-1.38.1.dev20240918.dist-info → streamlit_nightly-1.38.1.dev20240920.dist-info}/RECORD +30 -29
  23. streamlit/static/static/js/3156.002c6ee0.chunk.js +0 -1
  24. streamlit/static/static/js/5625.3a8dc81f.chunk.js +0 -1
  25. streamlit/static/static/js/7493.95e79b96.chunk.js +0 -1
  26. streamlit/static/static/js/main.e8447cae.js +0 -28
  27. /streamlit/static/static/css/{7077.81b3d18f.chunk.css → 6789.81b3d18f.chunk.css} +0 -0
  28. /streamlit/static/static/css/{3156.93909c7e.chunk.css → 9943.93909c7e.chunk.css} +0 -0
  29. /streamlit/static/static/js/{7077.e21833ae.chunk.js.LICENSE.txt → 6789.f8dde736.chunk.js.LICENSE.txt} +0 -0
  30. /streamlit/static/static/js/{main.e8447cae.js.LICENSE.txt → main.829dd23b.js.LICENSE.txt} +0 -0
  31. {streamlit_nightly-1.38.1.dev20240918.data → streamlit_nightly-1.38.1.dev20240920.data}/scripts/streamlit.cmd +0 -0
  32. {streamlit_nightly-1.38.1.dev20240918.dist-info → streamlit_nightly-1.38.1.dev20240920.dist-info}/WHEEL +0 -0
  33. {streamlit_nightly-1.38.1.dev20240918.dist-info → streamlit_nightly-1.38.1.dev20240920.dist-info}/entry_points.txt +0 -0
  34. {streamlit_nightly-1.38.1.dev20240918.dist-info → streamlit_nightly-1.38.1.dev20240920.dist-info}/top_level.txt +0 -0
streamlit/__init__.py CHANGED
@@ -204,6 +204,7 @@ metric = _main.metric
204
204
  multiselect = _main.multiselect
205
205
  number_input = _main.number_input
206
206
  page_link = _main.page_link
207
+ pills = _main.pills
207
208
  plotly_chart = _main.plotly_chart
208
209
  popover = _main.popover
209
210
  progress = _main.progress
@@ -39,6 +39,8 @@ class PydeckMixin:
39
39
  self,
40
40
  pydeck_obj: Deck | None = None,
41
41
  use_container_width: bool = False,
42
+ width: int | None = None,
43
+ height: int | None = None,
42
44
  ) -> DeltaGenerator:
43
45
  """Draw a chart using the PyDeck library.
44
46
 
@@ -68,7 +70,7 @@ class PydeckMixin:
68
70
 
69
71
  Parameters
70
72
  ----------
71
- pydeck_obj: pydeck.Deck or None
73
+ pydeck_obj : pydeck.Deck or None
72
74
  Object specifying the PyDeck chart to draw.
73
75
  use_container_width : bool
74
76
  Whether to override the figure's native width with the width of
@@ -77,6 +79,19 @@ class PydeckMixin:
77
79
  according to the plotting library, up to the width of the parent
78
80
  container. If ``use_container_width`` is ``True``, Streamlit sets
79
81
  the width of the figure to match the width of the parent container.
82
+ width : int or None
83
+ Desired width of the chart expressed in pixels. If ``width`` is
84
+ ``None`` (default), Streamlit sets the width of the chart to fit
85
+ its contents according to the plotting library, up to the width of
86
+ the parent container. If ``width`` is greater than the width of the
87
+ parent container, Streamlit sets the chart width to match the width
88
+ of the parent container.
89
+
90
+ To use ``width``, you must set ``use_container_width=False``.
91
+ height : int or None
92
+ Desired height of the chart expressed in pixels. If ``height`` is
93
+ ``None`` (default), Streamlit sets the height of the chart to fit
94
+ its contents according to the plotting library.
80
95
 
81
96
  Example
82
97
  -------
@@ -134,7 +149,9 @@ class PydeckMixin:
134
149
 
135
150
  """
136
151
  pydeck_proto = PydeckProto()
137
- marshall(pydeck_proto, pydeck_obj, use_container_width)
152
+ marshall(
153
+ pydeck_proto, pydeck_obj, use_container_width, width=width, height=height
154
+ )
138
155
  return self.dg._enqueue("deck_gl_json_chart", pydeck_proto)
139
156
 
140
157
  @property
@@ -165,6 +182,8 @@ def marshall(
165
182
  pydeck_proto: PydeckProto,
166
183
  pydeck_obj: Deck | None,
167
184
  use_container_width: bool,
185
+ width: int | None = None,
186
+ height: int | None = None,
168
187
  ) -> None:
169
188
  if pydeck_obj is None:
170
189
  spec = json.dumps(EMPTY_MAP)
@@ -174,6 +193,11 @@ def marshall(
174
193
  pydeck_proto.json = spec
175
194
  pydeck_proto.use_container_width = use_container_width
176
195
 
196
+ if width:
197
+ pydeck_proto.width = width
198
+ if height:
199
+ pydeck_proto.height = height
200
+
177
201
  pydeck_proto.id = ""
178
202
 
179
203
  tooltip = _get_pydeck_tooltip(pydeck_obj)
streamlit/elements/map.py CHANGED
@@ -84,6 +84,8 @@ class MapMixin:
84
84
  size: None | str | float = None,
85
85
  zoom: int | None = None,
86
86
  use_container_width: bool = True,
87
+ width: int | None = None,
88
+ height: int | None = None,
87
89
  ) -> DeltaGenerator:
88
90
  """Display a map with a scatterplot overlaid onto it.
89
91
 
@@ -162,6 +164,21 @@ class MapMixin:
162
164
  Streamlit sets the width of the chart to fit its contents according
163
165
  to the plotting library, up to the width of the parent container.
164
166
 
167
+ width : int or None
168
+ Desired width of the chart expressed in pixels. If ``width`` is
169
+ ``None`` (default), Streamlit sets the width of the chart to fit
170
+ its contents according to the plotting library, up to the width of
171
+ the parent container. If ``width`` is greater than the width of the
172
+ parent container, Streamlit sets the chart width to match the width
173
+ of the parent container.
174
+
175
+ To use ``width``, you must set ``use_container_width=False``.
176
+
177
+ height : int or None
178
+ Desired height of the chart expressed in pixels. If ``height`` is
179
+ ``None`` (default), Streamlit sets the height of the chart to fit
180
+ its contents according to the plotting library.
181
+
165
182
  Examples
166
183
  --------
167
184
  >>> import streamlit as st
@@ -223,7 +240,9 @@ class MapMixin:
223
240
  deck_gl_json = to_deckgl_json(
224
241
  data, latitude, longitude, size, color, map_style, zoom
225
242
  )
226
- marshall(map_proto, deck_gl_json, use_container_width)
243
+ marshall(
244
+ map_proto, deck_gl_json, use_container_width, width=width, height=height
245
+ )
227
246
  return self.dg._enqueue("deck_gl_json_chart", map_proto)
228
247
 
229
248
  @property
@@ -471,8 +490,15 @@ def marshall(
471
490
  pydeck_proto: DeckGlJsonChartProto,
472
491
  pydeck_json: str,
473
492
  use_container_width: bool,
493
+ height: int | None = None,
494
+ width: int | None = None,
474
495
  ) -> None:
475
496
  pydeck_proto.json = pydeck_json
476
497
  pydeck_proto.use_container_width = use_container_width
477
498
 
499
+ if width:
500
+ pydeck_proto.width = width
501
+ if height:
502
+ pydeck_proto.height = height
503
+
478
504
  pydeck_proto.id = ""
@@ -19,6 +19,7 @@ from typing import (
19
19
  Any,
20
20
  Callable,
21
21
  Final,
22
+ Generic,
22
23
  Literal,
23
24
  Sequence,
24
25
  TypeVar,
@@ -26,16 +27,22 @@ from typing import (
26
27
  overload,
27
28
  )
28
29
 
30
+ from typing_extensions import TypeAlias
31
+
29
32
  from streamlit.elements.lib.form_utils import current_form_id
30
33
  from streamlit.elements.lib.options_selector_utils import (
31
34
  convert_to_sequence_and_check_comparable,
32
35
  get_default_indices,
33
- maybe_coerce_enum_sequence,
34
36
  )
35
- from streamlit.elements.lib.policies import check_widget_policies
37
+ from streamlit.elements.lib.policies import (
38
+ check_widget_policies,
39
+ maybe_raise_label_warnings,
40
+ )
36
41
  from streamlit.elements.lib.utils import (
37
42
  Key,
43
+ LabelVisibility,
38
44
  compute_and_register_element_id,
45
+ get_label_visibility_proto_value,
39
46
  save_for_app_testing,
40
47
  to_key,
41
48
  )
@@ -45,6 +52,8 @@ from streamlit.proto.ButtonGroup_pb2 import ButtonGroup as ButtonGroupProto
45
52
  from streamlit.runtime.metrics_util import gather_metrics
46
53
  from streamlit.runtime.scriptrunner_utils.script_run_context import get_script_run_ctx
47
54
  from streamlit.runtime.state import register_widget
55
+ from streamlit.string_util import validate_material_icon
56
+ from streamlit.type_util import T
48
57
 
49
58
  if TYPE_CHECKING:
50
59
  from streamlit.dataframe_util import OptionSequence
@@ -59,7 +68,6 @@ if TYPE_CHECKING:
59
68
  WidgetDeserializer,
60
69
  WidgetSerializer,
61
70
  )
62
- from streamlit.type_util import T
63
71
 
64
72
 
65
73
  V = TypeVar("V")
@@ -78,29 +86,35 @@ _STAR_ICON: Final = ":material/star:"
78
86
  # in base64 format and send it over the wire as an image.
79
87
  _SELECTED_STAR_ICON: Final = ":material/star_filled:"
80
88
 
89
+ SelectionMode: TypeAlias = Literal["single", "multiple"]
90
+
81
91
 
82
- class FeedbackSerde:
92
+ class SingleSelectSerde(Generic[T]):
83
93
  """Uses the MultiSelectSerde under-the-hood, but accepts a single index value
84
- and deserializes to a single index value. This is because for feedback, we always
85
- allow just a single selection.
94
+ and deserializes to a single index value.
95
+ This is because button_group can be single and multi select, but we use the same
96
+ proto for both and, thus, map single values to a list of values and a receiving
97
+ value wrapped in a list to a single value.
86
98
 
87
- When a sentiment_mapping is provided, the sentiment corresponding to the index is
88
- serialized/deserialized. Otherwise, the index is used as the sentiment.
99
+ When a default_value is provided is provided, the option corresponding to the
100
+ index is serialized/deserialized.
89
101
  """
90
102
 
91
- def __init__(self, option_indices: list[int]) -> None:
92
- """Initialize the FeedbackSerde with a list of sentimets."""
93
- self.multiselect_serde: MultiSelectSerde[int] = MultiSelectSerde(option_indices)
103
+ def __init__(
104
+ self,
105
+ option_indices: Sequence[T],
106
+ default_value: list[int] | None = None,
107
+ ) -> None:
108
+ # see docstring about why we use MultiSelectSerde here
109
+ self.multiselect_serde: MultiSelectSerde[T] = MultiSelectSerde(
110
+ option_indices, default_value if default_value is not None else []
111
+ )
94
112
 
95
- def serialize(self, value: int | None) -> list[int]:
96
- """Serialize the passed sentiment option into its corresponding index
97
- (wrapped in a list).
98
- """
113
+ def serialize(self, value: T | None) -> list[int]:
99
114
  _value = [value] if value is not None else []
100
115
  return self.multiselect_serde.serialize(_value)
101
116
 
102
- def deserialize(self, ui_value: list[int], widget_id: str = "") -> int | None:
103
- """Receive a list of indices and return the corresponding sentiments."""
117
+ def deserialize(self, ui_value: list[int] | None, widget_id: str = "") -> T | None:
104
118
  deserialized = self.multiselect_serde.deserialize(ui_value, widget_id)
105
119
 
106
120
  if len(deserialized) == 0:
@@ -109,6 +123,40 @@ class FeedbackSerde:
109
123
  return deserialized[0]
110
124
 
111
125
 
126
+ class SingleOrMultiSelectSerde(Generic[T]):
127
+ """A serde that can handle both single and multi select options.
128
+
129
+ It uses the same proto to wire the data, so that we can send and receive
130
+ single values via a list. We have different serdes for both cases though so
131
+ that when setting / getting the value via session_state, it is mapped correctly.
132
+ So for single select, the value will be a single value and for multi select, it will
133
+ be a list of values.
134
+ """
135
+
136
+ def __init__(
137
+ self,
138
+ options: Sequence[T],
139
+ default_values: list[int],
140
+ type: Literal["single", "multiple"],
141
+ ):
142
+ self.options = options
143
+ self.default_values = default_values
144
+ self.type = type
145
+ self.serde: SingleSelectSerde[T] | MultiSelectSerde[T] = (
146
+ SingleSelectSerde(options, default_value=default_values)
147
+ if type == "single"
148
+ else MultiSelectSerde(options, default_values)
149
+ )
150
+
151
+ def serialize(self, value: T | list[T] | None) -> list[int]:
152
+ return self.serde.serialize(cast(Any, value))
153
+
154
+ def deserialize(
155
+ self, ui_value: list[int] | None, widget_id: str = ""
156
+ ) -> list[T] | T | None:
157
+ return self.serde.deserialize(ui_value, widget_id)
158
+
159
+
112
160
  def get_mapped_options(
113
161
  feedback_option: Literal["thumbs", "faces", "stars"],
114
162
  ) -> tuple[list[ButtonGroupProto.Option], list[int]]:
@@ -122,16 +170,16 @@ def get_mapped_options(
122
170
  # reversing the index mapping to have thumbs up first (but still with the higher
123
171
  # index (=sentiment) in the list)
124
172
  options_indices = list(reversed(range(len(_THUMB_ICONS))))
125
- options = [ButtonGroupProto.Option(content=icon) for icon in _THUMB_ICONS]
173
+ options = [ButtonGroupProto.Option(content_icon=icon) for icon in _THUMB_ICONS]
126
174
  elif feedback_option == "faces":
127
175
  options_indices = list(range(len(_FACES_ICONS)))
128
- options = [ButtonGroupProto.Option(content=icon) for icon in _FACES_ICONS]
176
+ options = [ButtonGroupProto.Option(content_icon=icon) for icon in _FACES_ICONS]
129
177
  elif feedback_option == "stars":
130
178
  options_indices = list(range(_NUMBER_STARS))
131
179
  options = [
132
180
  ButtonGroupProto.Option(
133
- content=_STAR_ICON,
134
- selected_content=_SELECTED_STAR_ICON,
181
+ content_icon=_STAR_ICON,
182
+ selected_content_icon=_SELECTED_STAR_ICON,
135
183
  )
136
184
  ] * _NUMBER_STARS
137
185
 
@@ -148,6 +196,10 @@ def _build_proto(
148
196
  selection_visualization: ButtonGroupProto.SelectionVisualization.ValueType = (
149
197
  ButtonGroupProto.SelectionVisualization.ONLY_SELECTED
150
198
  ),
199
+ style: Literal["segment", "pills", "borderless"] = "segment",
200
+ label: str | None = None,
201
+ label_visibility: LabelVisibility = "visible",
202
+ help: str | None = None,
151
203
  ) -> ButtonGroupProto:
152
204
  proto = ButtonGroupProto()
153
205
 
@@ -156,6 +208,16 @@ def _build_proto(
156
208
  proto.form_id = current_form_id
157
209
  proto.disabled = disabled
158
210
  proto.click_mode = click_mode
211
+ proto.style = ButtonGroupProto.Style.Value(style.upper())
212
+
213
+ # not passing the label looks the same as a collapsed label
214
+ if label is not None:
215
+ proto.label = label
216
+ proto.label_visibility.value = get_label_visibility_proto_value(
217
+ label_visibility
218
+ )
219
+ if help is not None:
220
+ proto.help = help
159
221
 
160
222
  for formatted_option in formatted_options:
161
223
  proto.options.append(formatted_option)
@@ -163,40 +225,54 @@ def _build_proto(
163
225
  return proto
164
226
 
165
227
 
228
+ def _maybe_raise_selection_mode_warning(selection_mode: SelectionMode):
229
+ """Check if the selection_mode value is valid or raise exception otherwise."""
230
+ if selection_mode not in ["single", "multiple"]:
231
+ raise StreamlitAPIException(
232
+ "The selection_mode argument must be one of ['single', 'multiple']. "
233
+ f"The argument passed was '{selection_mode}'."
234
+ )
235
+
236
+
166
237
  class ButtonGroupMixin:
167
- @overload # These overloads are not documented in the docstring, at least not at this time, on the theory that most people won't know what it means. And the Literals here are a subclass of int anyway.
168
- # Usually, we would make a type alias for Literal["thumbs", "faces", "stars"]; but, in this case, we don't use it in too many other places, and it's a more helpful autocomplete if we just enumerate the values explicitly, so a decision has been made to keep it as not an alias.
238
+ # These overloads are not documented in the docstring, at least not at this time, on
239
+ # the theory that most people won't know what it means. And the Literals here are a
240
+ # subclass of int anyway. Usually, we would make a type alias for
241
+ # Literal["thumbs", "faces", "stars"]; but, in this case, we don't use it in too
242
+ # many other places, and it's a more helpful autocomplete if we just enumerate the
243
+ # values explicitly, so a decision has been made to keep it as not an alias.
244
+ @overload
169
245
  def feedback(
170
246
  self,
171
247
  options: Literal["thumbs"] = ...,
172
248
  *,
173
- key: str | None = None,
249
+ key: Key | None = None,
174
250
  disabled: bool = False,
175
251
  on_change: WidgetCallback | None = None,
176
- args: Any | None = None,
177
- kwargs: Any | None = None,
252
+ args: WidgetArgs | None = None,
253
+ kwargs: WidgetKwargs | None = None,
178
254
  ) -> Literal[0, 1] | None: ...
179
255
  @overload
180
256
  def feedback(
181
257
  self,
182
258
  options: Literal["faces", "stars"] = ...,
183
259
  *,
184
- key: str | None = None,
260
+ key: Key | None = None,
185
261
  disabled: bool = False,
186
262
  on_change: WidgetCallback | None = None,
187
- args: Any | None = None,
188
- kwargs: Any | None = None,
263
+ args: WidgetArgs | None = None,
264
+ kwargs: WidgetKwargs | None = None,
189
265
  ) -> Literal[0, 1, 2, 3, 4] | None: ...
190
266
  @gather_metrics("feedback")
191
267
  def feedback(
192
268
  self,
193
269
  options: Literal["thumbs", "faces", "stars"] = "thumbs",
194
270
  *,
195
- key: str | None = None,
271
+ key: Key | None = None,
196
272
  disabled: bool = False,
197
273
  on_change: WidgetCallback | None = None,
198
- args: Any | None = None,
199
- kwargs: Any | None = None,
274
+ args: WidgetArgs | None = None,
275
+ kwargs: WidgetKwargs | None = None,
200
276
  ) -> int | None:
201
277
  """Display a feedback widget.
202
278
 
@@ -288,7 +364,7 @@ class ButtonGroupMixin:
288
364
  f"The argument passed was '{options}'."
289
365
  )
290
366
  transformed_options, options_indices = get_mapped_options(options)
291
- serde = FeedbackSerde(options_indices)
367
+ serde = SingleSelectSerde[int](options_indices)
292
368
 
293
369
  selection_visualization = ButtonGroupProto.SelectionVisualization.ONLY_SELECTED
294
370
  if options == "stars":
@@ -300,7 +376,7 @@ class ButtonGroupMixin:
300
376
  transformed_options,
301
377
  default=None,
302
378
  key=key,
303
- click_mode=ButtonGroupProto.SINGLE_SELECT,
379
+ selection_mode="single",
304
380
  disabled=disabled,
305
381
  deserializer=serde.deserialize,
306
382
  serializer=serde.serialize,
@@ -308,56 +384,146 @@ class ButtonGroupMixin:
308
384
  args=args,
309
385
  kwargs=kwargs,
310
386
  selection_visualization=selection_visualization,
387
+ style="borderless",
311
388
  )
312
389
  return sentiment.value
313
390
 
314
- # Disable this more generic widget for now
315
- # @gather_metrics("button_group")
391
+ @gather_metrics("pills")
392
+ def pills(
393
+ self,
394
+ label: str,
395
+ options: OptionSequence[V],
396
+ *,
397
+ selection_mode: Literal["single", "multiple"] = "single",
398
+ default: Sequence[V] | V | None = None,
399
+ format_func: Callable[[Any], str] | None = None,
400
+ key: Key | None = None,
401
+ help: str | None = None,
402
+ on_change: WidgetCallback | None = None,
403
+ args: WidgetArgs | None = None,
404
+ kwargs: WidgetKwargs | None = None,
405
+ disabled: bool = False,
406
+ label_visibility: LabelVisibility = "visible",
407
+ ) -> list[V] | V | None:
408
+ maybe_raise_label_warnings(label, label_visibility)
409
+
410
+ def _transformed_format_func(option: V) -> ButtonGroupProto.Option:
411
+ """If option starts with a material icon or an emoji, we extract it to send
412
+ it parsed to the frontend."""
413
+ transformed = format_func(option) if format_func else str(option)
414
+ transformed_parts = transformed.split(" ")
415
+ icon: str | None = None
416
+ if len(transformed_parts) > 0:
417
+ maybe_icon = transformed_parts[0].strip()
418
+ try:
419
+ # we only want to extract material icons because we treat them
420
+ # differently than emojis visually
421
+ if maybe_icon.startswith(":material"):
422
+ icon = validate_material_icon(maybe_icon)
423
+ # reassamble the option string without the icon - also
424
+ # works if len(transformed_parts) == 1
425
+ transformed = " ".join(transformed_parts[1:])
426
+ except StreamlitAPIException:
427
+ # we don't have a valid icon or emoji, so we just pass
428
+ pass
429
+ return ButtonGroupProto.Option(
430
+ content=transformed,
431
+ content_icon=icon,
432
+ )
433
+
434
+ indexable_options = convert_to_sequence_and_check_comparable(options)
435
+ default_values = get_default_indices(indexable_options, default)
436
+
437
+ serde: SingleOrMultiSelectSerde[V] = SingleOrMultiSelectSerde[V](
438
+ indexable_options, default_values, selection_mode
439
+ )
440
+ res = self._button_group(
441
+ indexable_options,
442
+ key=key,
443
+ default=default_values,
444
+ selection_mode=selection_mode,
445
+ disabled=disabled,
446
+ format_func=_transformed_format_func,
447
+ serializer=serde.serialize,
448
+ deserializer=serde.deserialize,
449
+ on_change=on_change,
450
+ args=args,
451
+ kwargs=kwargs,
452
+ style="pills",
453
+ label=label,
454
+ label_visibility=label_visibility,
455
+ help=help,
456
+ )
457
+
458
+ if selection_mode == "multiple":
459
+ return res.value
460
+
461
+ return res.value
462
+
463
+ @gather_metrics("_internal_button_group")
316
464
  def _internal_button_group(
317
465
  self,
318
466
  options: OptionSequence[V],
319
467
  *,
320
468
  key: Key | None = None,
321
- default: Sequence[Any] | None = None,
322
- click_mode: Literal["select", "multiselect"] = "select",
469
+ default: Sequence[V] | V | None = None,
470
+ selection_mode: Literal["single", "multiple"] = "single",
323
471
  disabled: bool = False,
324
- format_func: Callable[[V], dict[str, str]] | None = None,
472
+ format_func: Callable[[Any], str] | None = None,
473
+ style: Literal["segment", "pills"] = "segment",
325
474
  on_change: WidgetCallback | None = None,
326
475
  args: WidgetArgs | None = None,
327
476
  kwargs: WidgetKwargs | None = None,
328
- ) -> list[V]:
329
- def _transformed_format_func(x: V) -> ButtonGroupProto.Option:
330
- if format_func is None:
331
- return ButtonGroupProto.Option(content=str(x))
332
-
333
- transformed = format_func(x)
477
+ ) -> list[V] | V | None:
478
+ def _transformed_format_func(option: V) -> ButtonGroupProto.Option:
479
+ """If option starts with a material icon or an emoji, we extract it to send
480
+ it parsed to the frontend."""
481
+ transformed = format_func(option) if format_func else str(option)
482
+ transformed_parts = transformed.split(" ")
483
+ icon: str | None = None
484
+ if len(transformed_parts) > 0:
485
+ maybe_icon = transformed_parts[0].strip()
486
+ try:
487
+ # we only want to extract material icons because we treat them
488
+ # differently than emojis visually
489
+ if maybe_icon.startswith(":material"):
490
+ icon = validate_material_icon(maybe_icon)
491
+ # reassamble the option string without the icon - also
492
+ # works if len(transformed_parts) == 1
493
+ transformed = " ".join(transformed_parts[1:])
494
+ except StreamlitAPIException:
495
+ # we don't have a valid icon or emoji, so we just pass
496
+ pass
334
497
  return ButtonGroupProto.Option(
335
- content=transformed["content"],
336
- selected_content=transformed["selected_content"],
498
+ content=transformed,
499
+ content_icon=icon,
337
500
  )
338
501
 
339
502
  indexable_options = convert_to_sequence_and_check_comparable(options)
340
503
  default_values = get_default_indices(indexable_options, default)
341
- serde = MultiSelectSerde(indexable_options, default_values)
504
+
505
+ serde: SingleOrMultiSelectSerde[V] = SingleOrMultiSelectSerde[V](
506
+ indexable_options, default_values, selection_mode
507
+ )
342
508
 
343
509
  res = self._button_group(
344
510
  indexable_options,
345
511
  key=key,
346
512
  default=default_values,
347
- click_mode=ButtonGroupProto.ClickMode.MULTI_SELECT
348
- if click_mode == "multiselect"
349
- else ButtonGroupProto.SINGLE_SELECT,
513
+ selection_mode=selection_mode,
350
514
  disabled=disabled,
351
515
  format_func=_transformed_format_func,
516
+ style=style,
352
517
  serializer=serde.serialize,
353
518
  deserializer=serde.deserialize,
354
519
  on_change=on_change,
355
520
  args=args,
356
521
  kwargs=kwargs,
357
- after_register_callback=lambda widget_state: maybe_coerce_enum_sequence(
358
- widget_state, options, indexable_options
359
- ),
360
522
  )
523
+
524
+ if selection_mode == "multiple":
525
+ return res.value
526
+
361
527
  return res.value
362
528
 
363
529
  def _button_group(
@@ -366,10 +532,9 @@ class ButtonGroupMixin:
366
532
  *,
367
533
  key: Key | None = None,
368
534
  default: list[int] | None = None,
369
- click_mode: ButtonGroupProto.ClickMode.ValueType = (
370
- ButtonGroupProto.SINGLE_SELECT
371
- ),
535
+ selection_mode: SelectionMode = "single",
372
536
  disabled: bool = False,
537
+ style: Literal["segment", "pills", "borderless"] = "segment",
373
538
  format_func: Callable[[V], ButtonGroupProto.Option] | None = None,
374
539
  deserializer: WidgetDeserializer[T],
375
540
  serializer: WidgetSerializer[T],
@@ -379,14 +544,45 @@ class ButtonGroupMixin:
379
544
  selection_visualization: ButtonGroupProto.SelectionVisualization.ValueType = (
380
545
  ButtonGroupProto.SelectionVisualization.ONLY_SELECTED
381
546
  ),
382
- after_register_callback: Callable[
383
- [RegisterWidgetResult[T]], RegisterWidgetResult[T]
384
- ]
385
- | None = None,
547
+ label: str | None = None,
548
+ label_visibility: LabelVisibility = "visible",
549
+ help: str | None = None,
386
550
  ) -> RegisterWidgetResult[T]:
551
+ _maybe_raise_selection_mode_warning(selection_mode)
552
+
553
+ parsed_selection_mode: ButtonGroupProto.ClickMode.ValueType = (
554
+ ButtonGroupProto.SINGLE_SELECT
555
+ if selection_mode == "single"
556
+ else ButtonGroupProto.MULTI_SELECT
557
+ )
558
+
559
+ # when selection mode is a single-value selection, the default must be a single
560
+ # value too.
561
+ if (
562
+ parsed_selection_mode == ButtonGroupProto.SINGLE_SELECT
563
+ and default is not None
564
+ and isinstance(default, Sequence)
565
+ and len(default) > 1
566
+ ):
567
+ # add more commands to the error message
568
+ raise StreamlitAPIException(
569
+ "The default argument to `st.pills` must be a single value when "
570
+ "`selection_mode='single'`."
571
+ )
572
+
573
+ if style not in ["segment", "pills", "borderless"]:
574
+ raise StreamlitAPIException(
575
+ "The style argument must be one of ['segment', 'pills', 'borderless']. "
576
+ f"The argument passed was '{style}'."
577
+ )
578
+
387
579
  key = to_key(key)
388
580
 
389
- check_widget_policies(self.dg, key, on_change, default_value=default)
581
+ _default = default
582
+ if default is not None and len(default) == 0:
583
+ _default = None
584
+
585
+ check_widget_policies(self.dg, key, on_change, default_value=_default)
390
586
 
391
587
  widget_name = "button_group"
392
588
  ctx = get_script_run_ctx()
@@ -394,7 +590,10 @@ class ButtonGroupMixin:
394
590
  formatted_options = (
395
591
  indexable_options
396
592
  if format_func is None
397
- else [format_func(option) for option in indexable_options]
593
+ else [
594
+ format_func(indexable_options[index])
595
+ for index, _ in enumerate(indexable_options)
596
+ ]
398
597
  )
399
598
  element_id = compute_and_register_element_id(
400
599
  widget_name,
@@ -402,7 +601,8 @@ class ButtonGroupMixin:
402
601
  form_id=form_id,
403
602
  options=formatted_options,
404
603
  default=default,
405
- click_mode=click_mode,
604
+ click_mode=parsed_selection_mode,
605
+ style=style,
406
606
  )
407
607
 
408
608
  proto = _build_proto(
@@ -411,8 +611,12 @@ class ButtonGroupMixin:
411
611
  default or [],
412
612
  disabled,
413
613
  form_id,
414
- click_mode=click_mode,
614
+ click_mode=parsed_selection_mode,
415
615
  selection_visualization=selection_visualization,
616
+ style=style,
617
+ label=label,
618
+ label_visibility=label_visibility,
619
+ help=help,
416
620
  )
417
621
 
418
622
  widget_state = register_widget(
@@ -426,9 +630,6 @@ class ButtonGroupMixin:
426
630
  ctx=ctx,
427
631
  )
428
632
 
429
- if after_register_callback is not None:
430
- widget_state = after_register_callback(widget_state)
431
-
432
633
  if widget_state.value_changed:
433
634
  proto.value[:] = serializer(widget_state.value)
434
635
  proto.set_value = True