essreduce 24.11.3__py3-none-any.whl → 25.1.0__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.
@@ -0,0 +1,176 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
3
+
4
+ from dataclasses import dataclass
5
+ from typing import NewType
6
+
7
+ import scipp as sc
8
+
9
+ Ltotal = NewType("Ltotal", sc.Variable)
10
+ """
11
+ Total length of the flight path from the source to the detector.
12
+ """
13
+
14
+
15
+ @dataclass
16
+ class SimulationResults:
17
+ """
18
+ Results of a time-of-flight simulation used to create a lookup table.
19
+
20
+ The results (apart from ``distance``) should be flat lists (1d arrays) of length N
21
+ where N is the number of neutrons, containing the properties of the neutrons in the
22
+ simulation.
23
+
24
+ Parameters
25
+ ----------
26
+ time_of_arrival:
27
+ Time of arrival of the neutrons at the position where the events were recorded
28
+ (1d array of size N).
29
+ speed:
30
+ Speed of the neutrons, typically derived from the wavelength of the neutrons
31
+ (1d array of size N).
32
+ wavelength:
33
+ Wavelength of the neutrons (1d array of size N).
34
+ weight:
35
+ Weight/probability of the neutrons (1d array of size N).
36
+ distance:
37
+ Distance from the source to the position where the events were recorded
38
+ (single value; we assume all neutrons were recorded at the same position).
39
+ For a ``tof`` simulation, this is just the position of the detector where the
40
+ events are recorded. For a ``McStas`` simulation, this is the distance between
41
+ the source and the event monitor.
42
+ """
43
+
44
+ time_of_arrival: sc.Variable
45
+ speed: sc.Variable
46
+ wavelength: sc.Variable
47
+ weight: sc.Variable
48
+ distance: sc.Variable
49
+
50
+
51
+ @dataclass
52
+ class FastestNeutron:
53
+ """
54
+ Properties of the fastest neutron in the simulation results.
55
+ """
56
+
57
+ time_of_arrival: sc.Variable
58
+ speed: sc.Variable
59
+ distance: sc.Variable
60
+
61
+
62
+ LtotalRange = NewType("LtotalRange", tuple[sc.Variable, sc.Variable])
63
+ """
64
+ Range (min, max) of the total length of the flight path from the source to the detector.
65
+ This is used to create the lookup table to compute the neutron time-of-flight.
66
+ Note that the resulting table will extend slightly beyond this range, as the supplied
67
+ range is not necessarily a multiple of the distance resolution.
68
+
69
+ Note also that the range of total flight paths is supplied manually to the workflow
70
+ instead of being read from the input data, as it allows us to compute the expensive part
71
+ of the workflow in advance (the lookup table) and does not need to be repeated for each
72
+ run, or for new data coming in in the case of live data collection.
73
+ """
74
+
75
+ DistanceResolution = NewType("DistanceResolution", sc.Variable)
76
+ """
77
+ Step size of the distance axis in the lookup table.
78
+ Should be a single scalar value with a unit of length.
79
+ This is typically of the order of 1-10 cm.
80
+ """
81
+
82
+ TimeOfArrivalResolution = NewType("TimeOfArrivalResolution", int | sc.Variable)
83
+ """
84
+ Resolution of the time of arrival axis in the lookup table.
85
+ Can be an integer (number of bins) or a sc.Variable (bin width).
86
+ """
87
+
88
+ TimeOfFlightLookupTable = NewType("TimeOfFlightLookupTable", sc.DataArray)
89
+ """
90
+ Lookup table giving time-of-flight as a function of distance and time of arrival.
91
+ """
92
+
93
+ MaskedTimeOfFlightLookupTable = NewType("MaskedTimeOfFlightLookupTable", sc.DataArray)
94
+ """
95
+ Lookup table giving time-of-flight as a function of distance and time of arrival, with
96
+ regions of large uncertainty masked out.
97
+ """
98
+
99
+ LookupTableRelativeErrorThreshold = NewType("LookupTableRelativeErrorThreshold", float)
100
+
101
+ FramePeriod = NewType("FramePeriod", sc.Variable)
102
+ """
103
+ The period of a frame, a (small) integer multiple of the source period.
104
+ """
105
+
106
+ UnwrappedTimeOfArrival = NewType("UnwrappedTimeOfArrival", sc.Variable)
107
+ """
108
+ Time of arrival of the neutron at the detector, unwrapped at the pulse period.
109
+ """
110
+
111
+ PivotTimeAtDetector = NewType("PivotTimeAtDetector", sc.Variable)
112
+ """
113
+ Pivot time at the detector, i.e., the time of the start of the frame at the detector.
114
+ """
115
+
116
+ UnwrappedTimeOfArrivalMinusPivotTime = NewType(
117
+ "UnwrappedTimeOfArrivalMinusPivotTime", sc.Variable
118
+ )
119
+ """
120
+ Time of arrival of the neutron at the detector, unwrapped at the pulse period, minus
121
+ the start time of the frame.
122
+ """
123
+
124
+ TimeOfArrivalMinusPivotTimeModuloPeriod = NewType(
125
+ "TimeOfArrivalMinusPivotTimeModuloPeriod", sc.Variable
126
+ )
127
+ """
128
+ Time of arrival of the neutron at the detector minus the start time of the frame,
129
+ modulo the frame period.
130
+ """
131
+
132
+ FrameFoldedTimeOfArrival = NewType("FrameFoldedTimeOfArrival", sc.Variable)
133
+
134
+
135
+ PulsePeriod = NewType("PulsePeriod", sc.Variable)
136
+ """
137
+ Period of the source pulses, i.e., time between consecutive pulse starts.
138
+ """
139
+
140
+ PulseStride = NewType("PulseStride", int)
141
+ """
142
+ Stride of used pulses. Usually 1, but may be a small integer when pulse-skipping.
143
+ """
144
+
145
+ PulseStrideOffset = NewType("PulseStrideOffset", int)
146
+ """
147
+ When pulse-skipping, the offset of the first pulse in the stride.
148
+ """
149
+
150
+ RawData = NewType("RawData", sc.DataArray)
151
+ """
152
+ Raw detector data loaded from a NeXus file, e.g., NXdetector containing NXevent_data.
153
+ """
154
+
155
+ TofData = NewType("TofData", sc.DataArray)
156
+ """
157
+ Detector data with time-of-flight coordinate.
158
+ """
159
+
160
+ ResampledTofData = NewType("ResampledTofData", sc.DataArray)
161
+ """
162
+ Histogrammed detector data with time-of-flight coordinate, that has been resampled.
163
+
164
+ Histogrammed data that has been converted to `tof` will typically have
165
+ unsorted bin edges (due to either wrapping of `time_of_flight` or wavelength
166
+ overlap between subframes).
167
+ We thus resample the data to ensure that the bin edges are sorted.
168
+ It makes use of the ``to_events`` helper which generates a number of events in each
169
+ bin with a uniform distribution. The new events are then histogrammed using a set of
170
+ sorted bin edges to yield a new histogram with sorted bin edges.
171
+
172
+ WARNING:
173
+ This function is highly experimental, has limitations and should be used with
174
+ caution. It is a workaround to the issue that rebinning data with unsorted bin
175
+ edges is not supported in scipp.
176
+ """
ess/reduce/ui.py CHANGED
@@ -9,7 +9,8 @@ from IPython import display
9
9
  from ipywidgets import Layout
10
10
 
11
11
  from .parameter import Parameter
12
- from .widgets import SwitchWidget, create_parameter_widget, default_layout
12
+ from .widgets import Spinner, SwitchWidget, create_parameter_widget, default_layout
13
+ from .widgets._base import get_fields, set_fields
13
14
  from .workflow import (
14
15
  Key,
15
16
  assign_parameter_values,
@@ -81,11 +82,13 @@ class ParameterBox(widgets.VBox):
81
82
  self._input_widgets.clear()
82
83
  self._input_widgets.update(
83
84
  {
84
- node: widgets.HBox([create_parameter_widget(parameter)])
85
- for node, parameter in registry_getter().items()
85
+ node: create_parameter_widget(parameter)
86
+ for node, parameter in new_input_parameters.items()
86
87
  }
87
88
  )
88
- self._input_box.children = list(self._input_widgets.values())
89
+ self._input_box.children = [
90
+ widgets.HBox([widget]) for widget in self._input_widgets.values()
91
+ ]
89
92
 
90
93
  self.parameter_refresh_button.on_click(_refresh_input_box)
91
94
 
@@ -97,8 +100,7 @@ class ParameterBox(widgets.VBox):
97
100
  return {
98
101
  node: widget.value
99
102
  for node, widget_box in self._input_widgets.items()
100
- if (not isinstance((widget := widget_box.children[0]), SwitchWidget))
101
- or widget.enabled
103
+ if (not isinstance((widget := widget_box), SwitchWidget)) or widget.enabled
102
104
  }
103
105
 
104
106
 
@@ -125,12 +127,14 @@ class ResultBox(widgets.VBox):
125
127
  def run_workflow(_: widgets.Button) -> None:
126
128
  self.output.clear_output()
127
129
  with self.output:
130
+ display.display(Spinner())
128
131
  compute_result = workflow_runner()
132
+ display.clear_output()
129
133
  if result_registry is not None:
130
134
  result_registry.clear()
131
135
  result_registry.update(compute_result)
132
136
  for i in compute_result.values():
133
- display.display(display.HTML(i._repr_html_()))
137
+ display.display(i)
134
138
 
135
139
  def clear_output(_: widgets.Button) -> None:
136
140
  self.output.clear_output()
@@ -232,3 +236,82 @@ def workflow_widget(result_registry: dict | None = None) -> widgets.Widget:
232
236
  workflow_selection_box = widgets.HBox([workflow_select], layout=default_layout)
233
237
  workflow_box = widgets.Box(layout=default_layout)
234
238
  return widgets.VBox([workflow_selection_box, workflow_box])
239
+
240
+
241
+ def _get_parameter_box(widget: WorkflowWidget | ParameterBox) -> ParameterBox:
242
+ if isinstance(widget, WorkflowWidget):
243
+ return widget.parameter_box
244
+ elif isinstance(widget, ParameterBox):
245
+ return widget
246
+ else:
247
+ raise TypeError(
248
+ f"Expected target_widget to be a WorkflowWidget or ParameterBox, "
249
+ f"got {type(widget)}."
250
+ )
251
+
252
+
253
+ def set_parameter_widget_values(
254
+ widget: WorkflowWidget | ParameterBox, new_parameter_values: dict[type, Any]
255
+ ) -> None:
256
+ """Set the values of the input widgets in the target widget.
257
+
258
+ Nodes that don't exist in the input widgets will be ignored.
259
+
260
+ Example
261
+ -------
262
+ .. code-block::
263
+
264
+ set_parameter_widget_values(widget, {
265
+ 'WavelengthBins': {'start': 1.0, 'stop': 14.0, 'nbins': 500}
266
+ })
267
+
268
+ Parameters
269
+ ----------
270
+ widget:
271
+ The widget containing the input widgets.
272
+ new_parameter_values:
273
+ A dictionary of values/state to set each fields/state or value of input widgets.
274
+
275
+ Raises
276
+ ------
277
+ TypeError:
278
+ If the widget is not a WorkflowWidget or a ParameterBox.
279
+
280
+ """
281
+ parameter_box = _get_parameter_box(widget)
282
+ # Walk through the existing input widgets and set the values
283
+ # ``node`s that don't exist in the input widgets will be ignored.
284
+ for node, widget in parameter_box._input_widgets.items():
285
+ if node in new_parameter_values:
286
+ # We shouldn't use `get` here because ``None`` is a valid value.
287
+ set_fields(widget, new_parameter_values[node])
288
+
289
+
290
+ def get_parameter_widget_values(
291
+ widget: WorkflowWidget | ParameterBox,
292
+ ) -> dict[type, Any]:
293
+ """Return the current values of the input widgets in the target widget.
294
+
295
+ The result of this function can be used to set the values of the input widgets
296
+ using the :py:func:`~set_parameter_widget_values` function.
297
+
298
+ Parameters
299
+ ----------
300
+ widget:
301
+ The widget containing the input widgets.
302
+
303
+ Returns
304
+ -------
305
+ :
306
+ A dictionary of the current values/state of each input widget.
307
+
308
+ Raises
309
+ ------
310
+ TypeError:
311
+ If the widget is not a WorkflowWidget or a ParameterBox.
312
+
313
+ """
314
+ return {
315
+ node: get_fields(widget)
316
+ for node, widget in _get_parameter_box(widget)._input_widgets.items()
317
+ }
ess/reduce/uncertainty.py CHANGED
@@ -13,7 +13,7 @@ The recommended use of this module is via the :py:func:`broadcast_uncertainties`
13
13
  helper function.
14
14
  """
15
15
 
16
- from enum import Enum
16
+ from enum import Enum, auto
17
17
  from typing import TypeVar, overload
18
18
 
19
19
  import numpy as np
@@ -23,18 +23,18 @@ from scipp.core.concepts import irreducible_mask
23
23
  T = TypeVar("T", bound=sc.Variable | sc.DataArray)
24
24
 
25
25
 
26
- UncertaintyBroadcastMode = Enum(
27
- 'UncertaintyBroadcastMode', ['drop', 'upper_bound', 'fail']
28
- )
29
- """
30
- Mode for broadcasting uncertainties.
26
+ class UncertaintyBroadcastMode(Enum):
27
+ """Mode for broadcasting uncertainties.
31
28
 
32
- - `drop`: Drop variances if the data is broadcasted.
33
- - `upper_bound`: Compute an upper bound for the variances.
34
- - `fail`: Do not broadcast, simply return the input data.
29
+ See https://doi.org/10.3233/JNR-220049 for context.
30
+ """
35
31
 
36
- See https://doi.org/10.3233/JNR-220049 for context.
37
- """
32
+ drop = auto()
33
+ """Drop variances if the data is broadcast."""
34
+ upper_bound = auto()
35
+ """Compute an upper bound for the variances."""
36
+ fail = auto()
37
+ """Do not broadcast, simply return the input data."""
38
38
 
39
39
 
40
40
  @overload
@@ -29,6 +29,7 @@ from ._bounds_widget import BoundsWidget
29
29
  from ._string_widget import MultiStringWidget, StringWidget
30
30
  from ._switchable_widget import SwitchWidget
31
31
  from ._optional_widget import OptionalWidget
32
+ from ._spinner import Spinner
32
33
 
33
34
 
34
35
  class EssWidget(Protocol):
@@ -191,4 +192,5 @@ __all__ = [
191
192
  'SwitchWidget',
192
193
  'VectorWidget',
193
194
  'create_parameter_widget',
195
+ 'Spinner',
194
196
  ]
@@ -0,0 +1,127 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
3
+ import warnings
4
+ from collections.abc import Iterable
5
+ from typing import Any, Protocol, runtime_checkable
6
+
7
+ from ipywidgets import Widget
8
+
9
+
10
+ @runtime_checkable
11
+ class WidgetWithFieldsProtocol(Protocol):
12
+ def set_fields(self, new_values: dict[str, Any]) -> None: ...
13
+
14
+ def get_fields(self) -> dict[str, Any]: ...
15
+
16
+
17
+ def _warn_invalid_field(invalid_fields: Iterable[str]) -> None:
18
+ for field_name in invalid_fields:
19
+ warning_msg = f"Cannot set field '{field_name}'."
20
+ " The field does not exist in the widget."
21
+ "The field value will be ignored."
22
+ warnings.warn(warning_msg, UserWarning, stacklevel=2)
23
+
24
+
25
+ class WidgetWithFieldsMixin:
26
+ def set_fields(self, new_values: dict[str, Any]) -> None:
27
+ # Extract valid fields
28
+ new_field_names = set(new_values.keys())
29
+ valid_field_names = new_field_names & set(self.fields.keys())
30
+ # Warn for invalid fields
31
+ invalid_field_names = new_field_names - valid_field_names
32
+ _warn_invalid_field(invalid_field_names)
33
+ # Set valid fields
34
+ for field_name in valid_field_names:
35
+ self.fields[field_name].value = new_values[field_name]
36
+
37
+ def get_fields(self) -> dict[str, Any]:
38
+ return {
39
+ field_name: field_sub_widget.value
40
+ for field_name, field_sub_widget in self.fields.items()
41
+ }
42
+
43
+
44
+ def set_fields(widget: Widget, new_values: dict[str, Any]) -> None:
45
+ """Set the fields of a widget with the given values.
46
+
47
+ Parameters
48
+ ----------
49
+ widget:
50
+ The widget to set the fields. It should either be an instance of
51
+ ``WidgetWithFieldsProtocol`` or have a value property setter.
52
+ new_values:
53
+ The new values to set for the fields.
54
+ i.e. ``{'field_name': field_value}``
55
+ If the widget does not have a ``set_fields/get_fields`` method,
56
+ (e.g. it is not an instance of ``WidgetWithFieldsProtocol``),
57
+ it will try to set the value of the widget directly.
58
+ The value of the widget should be available in the ``new_values`` dictionary
59
+ with the key 'value'.
60
+ i.e. ``{'value': widget_value}``
61
+
62
+ Raises
63
+ ------
64
+ TypeError:
65
+ If ``new_values`` is not a dictionary.
66
+
67
+ """
68
+ if not isinstance(new_values, dict):
69
+ raise TypeError(f"new_values must be a dictionary, got {type(new_values)}")
70
+
71
+ if isinstance(widget, WidgetWithFieldsProtocol) and isinstance(new_values, dict):
72
+ widget.set_fields(new_values)
73
+ else:
74
+ try:
75
+ # Use value property setter if ``new_values`` contains 'value'
76
+ if 'value' in new_values:
77
+ widget.value = new_values['value']
78
+ # Warn if there is any other fields in new_values
79
+ _warn_invalid_field(set(new_values.keys()) - {'value'})
80
+ except AttributeError as error:
81
+ # Checking if the widget value property has a setter in advance, i.e.
82
+ # ```python
83
+ # (widget_property := getattr(type(widget), 'value', None)) is not None
84
+ # and getattr(widget_property, 'fset', None) is not None
85
+ # ```
86
+ # does not work with a class that inherits Traitlets class.
87
+ # In those classes, even if a property has a setter,
88
+ # it may not have `fset` attribute.
89
+ # It is not really feasible to check all possible cases of value setters.
90
+ # Instead, we try setting the value and catch the AttributeError.
91
+ # to determine if the widget has a value setter.
92
+ warnings.warn(
93
+ f"Cannot set value for widget of type {type(widget)}."
94
+ " The new_value(s) will be ignored."
95
+ f" Setting value caused the following error: {error}",
96
+ UserWarning,
97
+ stacklevel=1,
98
+ )
99
+
100
+
101
+ def get_fields(widget: Widget) -> dict[str, Any] | None:
102
+ """Get the fields of a widget.
103
+
104
+ If the widget is an instance of ``WidgetWithFieldsProtocol``,
105
+ it will return the fields of the widget.
106
+ i.e. ``{'field_name': field_value}``
107
+ Otherwise, it will try to get the value of the widget and return a dictionary
108
+ with the key 'value' and the value of the widget.
109
+ i.e. ``{'value': widget_value}``
110
+
111
+ Parameters
112
+ ----------
113
+ widget:
114
+ The widget to get the fields. It should either be an instance of
115
+ ``WidgetWithFieldsProtocol`` or have a value property.
116
+
117
+ """
118
+ if isinstance(widget, WidgetWithFieldsProtocol):
119
+ return widget.get_fields()
120
+ try:
121
+ return {'value': widget.value}
122
+ except AttributeError:
123
+ warnings.warn(
124
+ f"Cannot get value or fields for widget of type {type(widget)}.",
125
+ UserWarning,
126
+ stacklevel=1,
127
+ )
@@ -3,6 +3,8 @@
3
3
  import ipywidgets as ipw
4
4
  import scipp as sc
5
5
 
6
+ from ._base import WidgetWithFieldsMixin
7
+
6
8
  UNITS_LIBRARY = {
7
9
  "wavelength": {"options": ("angstrom", "nm")},
8
10
  "Q": {"options": ("1/angstrom", "1/nm")},
@@ -19,7 +21,7 @@ UNITS_LIBRARY = {
19
21
  }
20
22
 
21
23
 
22
- class BinEdgesWidget(ipw.HBox, ipw.ValueWidget):
24
+ class BinEdgesWidget(ipw.HBox, ipw.ValueWidget, WidgetWithFieldsMixin):
23
25
  def __init__(
24
26
  self,
25
27
  name: str,
@@ -4,9 +4,10 @@ import scipp as sc
4
4
  from ipywidgets import FloatText, GridBox, Label, Text, ValueWidget
5
5
 
6
6
  from ..parameter import ParamWithBounds
7
+ from ._base import WidgetWithFieldsMixin
7
8
 
8
9
 
9
- class BoundsWidget(GridBox, ValueWidget):
10
+ class BoundsWidget(GridBox, ValueWidget, WidgetWithFieldsMixin):
10
11
  def __init__(self):
11
12
  super().__init__()
12
13
 
@@ -3,8 +3,10 @@
3
3
  import scipp as sc
4
4
  from ipywidgets import FloatText, GridBox, IntText, Label, ValueWidget
5
5
 
6
+ from ._base import WidgetWithFieldsMixin
6
7
 
7
- class LinspaceWidget(GridBox, ValueWidget):
8
+
9
+ class LinspaceWidget(GridBox, ValueWidget, WidgetWithFieldsMixin):
8
10
  def __init__(self, dim: str, unit: str):
9
11
  super().__init__()
10
12
 
@@ -4,6 +4,7 @@ from typing import Any
4
4
 
5
5
  from ipywidgets import HTML, HBox, Layout, RadioButtons, Widget
6
6
 
7
+ from ._base import get_fields, set_fields
7
8
  from ._config import default_style
8
9
 
9
10
 
@@ -64,3 +65,26 @@ class OptionalWidget(HBox):
64
65
  else:
65
66
  self._option_box.value = self.name
66
67
  self.wrapped.value = value
68
+
69
+ def set_fields(self, new_values: dict[str, Any]) -> None:
70
+ new_values = dict(new_values)
71
+ # Set the value of the option box
72
+ opted_out_flag = new_values.pop(
73
+ # We assume ``essreduce-opted-out`` is not used in any wrapped widget
74
+ 'essreduce-opted-out',
75
+ self._option_box.value is None,
76
+ )
77
+ if not isinstance(opted_out_flag, bool):
78
+ raise ValueError(
79
+ f"Invalid value for 'essreduce-opted-out' field: {opted_out_flag}."
80
+ " The value should be a boolean."
81
+ )
82
+ self._option_box.value = None if opted_out_flag else self.name
83
+ # Set the value of the wrapped widget
84
+ set_fields(self.wrapped, new_values)
85
+
86
+ def get_fields(self) -> dict[str, Any] | None:
87
+ return {
88
+ **(get_fields(self.wrapped) or {}),
89
+ 'essreduce-opted-out': self._option_box.value is None,
90
+ }
@@ -0,0 +1,100 @@
1
+ _spinner = '''
2
+ <style>
3
+ .lds-spinner {
4
+ /* change color here */
5
+ color: #1c4c5b
6
+ }
7
+ .lds-spinner,
8
+ .lds-spinner div,
9
+ .lds-spinner div:after {
10
+ box-sizing: border-box;
11
+ }
12
+ .lds-spinner {
13
+ color: currentColor;
14
+ display: inline-block;
15
+ position: relative;
16
+ width: 80px;
17
+ height: 80px;
18
+ }
19
+ .lds-spinner div {
20
+ transform-origin: 40px 40px;
21
+ animation: lds-spinner 1.2s linear infinite;
22
+ }
23
+ .lds-spinner div:after {
24
+ content: " ";
25
+ display: block;
26
+ position: absolute;
27
+ top: 3.2px;
28
+ left: 36.8px;
29
+ width: 6.4px;
30
+ height: 17.6px;
31
+ border-radius: 20%;
32
+ background: currentColor;
33
+ }
34
+ .lds-spinner div:nth-child(1) {
35
+ transform: rotate(0deg);
36
+ animation-delay: -1.1s;
37
+ }
38
+ .lds-spinner div:nth-child(2) {
39
+ transform: rotate(30deg);
40
+ animation-delay: -1s;
41
+ }
42
+ .lds-spinner div:nth-child(3) {
43
+ transform: rotate(60deg);
44
+ animation-delay: -0.9s;
45
+ }
46
+ .lds-spinner div:nth-child(4) {
47
+ transform: rotate(90deg);
48
+ animation-delay: -0.8s;
49
+ }
50
+ .lds-spinner div:nth-child(5) {
51
+ transform: rotate(120deg);
52
+ animation-delay: -0.7s;
53
+ }
54
+ .lds-spinner div:nth-child(6) {
55
+ transform: rotate(150deg);
56
+ animation-delay: -0.6s;
57
+ }
58
+ .lds-spinner div:nth-child(7) {
59
+ transform: rotate(180deg);
60
+ animation-delay: -0.5s;
61
+ }
62
+ .lds-spinner div:nth-child(8) {
63
+ transform: rotate(210deg);
64
+ animation-delay: -0.4s;
65
+ }
66
+ .lds-spinner div:nth-child(9) {
67
+ transform: rotate(240deg);
68
+ animation-delay: -0.3s;
69
+ }
70
+ .lds-spinner div:nth-child(10) {
71
+ transform: rotate(270deg);
72
+ animation-delay: -0.2s;
73
+ }
74
+ .lds-spinner div:nth-child(11) {
75
+ transform: rotate(300deg);
76
+ animation-delay: -0.1s;
77
+ }
78
+ .lds-spinner div:nth-child(12) {
79
+ transform: rotate(330deg);
80
+ animation-delay: 0s;
81
+ }
82
+ @keyframes lds-spinner {
83
+ 0% {
84
+ opacity: 1;
85
+ }
86
+ 100% {
87
+ opacity: 0;
88
+ }
89
+ }
90
+ </style>
91
+ <div class="lds-spinner"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>
92
+ ''' # noqa: E501
93
+
94
+
95
+ class Spinner:
96
+ def _repr_html_(self, *args, **kwargs):
97
+ return _spinner
98
+
99
+ def _repr_pretty_(self, *args, **kwargs):
100
+ return '...in progress'
@@ -27,6 +27,15 @@ class StringWidget(HBox, ValueWidget):
27
27
 
28
28
 
29
29
  class MultiStringWidget(StringWidget):
30
+ def __init__(
31
+ self, description: str, value: tuple[str, ...] | None = None, **kwargs
32
+ ):
33
+ # Special case handling to allow initialising with a single string
34
+ if not isinstance(value, str) and value is not None:
35
+ value = ', '.join(value)
36
+
37
+ super().__init__(description, value, **kwargs)
38
+
30
39
  @property
31
40
  def value(self) -> tuple[str, ...]:
32
41
  v = super().value