sciwidgets 0.1.2__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,553 @@
1
+ """Reusable dialogs for messages, questions, and About/help content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ from enum import Enum, auto
7
+ from typing import Optional
8
+
9
+ from PySide6.QtCore import Qt
10
+ from PySide6.QtGui import QGuiApplication, QIcon, QPixmap, QTextCursor
11
+ from PySide6.QtWidgets import (
12
+ QApplication,
13
+ QDialog,
14
+ QDialogButtonBox,
15
+ QHBoxLayout,
16
+ QLabel,
17
+ QLayout,
18
+ QPushButton,
19
+ QStyle,
20
+ QTextBrowser,
21
+ QVBoxLayout,
22
+ QWidget,
23
+ )
24
+
25
+
26
+ class AskResult(Enum):
27
+ """Possible outcomes of a question dialog."""
28
+
29
+ YES = auto()
30
+ NO = auto()
31
+ CANCEL = auto()
32
+
33
+
34
+ class DialogIcon(Enum):
35
+ """Supported standard-icon presets for reusable dialogs."""
36
+
37
+ INFORMATION = QStyle.SP_MessageBoxInformation
38
+ WARNING = QStyle.SP_MessageBoxWarning
39
+ CRITICAL = QStyle.SP_MessageBoxCritical
40
+ QUESTION = QStyle.SP_MessageBoxQuestion
41
+
42
+
43
+ class TitledDialogBase(QDialog):
44
+ """Common dialog base class with title text, optional icon, and body helpers."""
45
+
46
+ def __init__(
47
+ self,
48
+ title: str,
49
+ text: str,
50
+ informative_text: str = "",
51
+ icon: Optional[DialogIcon] = None,
52
+ parent: Optional[QWidget] = None,
53
+ ) -> None:
54
+ """Initialize the common titled-dialog layout.
55
+
56
+ Args:
57
+ title: Window title.
58
+ text: Main dialog text.
59
+ informative_text: Optional secondary explanatory text.
60
+ icon: Optional standard dialog icon preset.
61
+ parent: Optional parent widget.
62
+ """
63
+
64
+ super().__init__(parent)
65
+
66
+ left_margin = 5
67
+ icon_margin = 10
68
+ right_margin = 8
69
+ top_margin = 0
70
+ bottom_margin = 7
71
+
72
+ self.setWindowTitle(title)
73
+
74
+ text_layout = QVBoxLayout()
75
+ text_layout.setSpacing(6)
76
+
77
+ text_label = _make_html_label(text.replace("\n", "<br>"))
78
+ text_layout.addWidget(text_label)
79
+
80
+ if informative_text:
81
+ info_label = _make_html_label(informative_text.replace("\n", "<br>"))
82
+ text_layout.addWidget(info_label)
83
+
84
+ row = QHBoxLayout()
85
+ row.addSpacing(left_margin)
86
+ if icon is not None:
87
+ std_icon = self.style().standardIcon(icon.value)
88
+ pixmap = std_icon.pixmap(32, 32)
89
+ icon_label = QLabel()
90
+ icon_label.setPixmap(pixmap)
91
+ icon_label.setFixedSize(icon_label.sizeHint())
92
+ row.addWidget(icon_label, alignment=Qt.AlignTop)
93
+ row.addSpacing(icon_margin)
94
+ row.addLayout(text_layout)
95
+ row.addSpacing(right_margin)
96
+
97
+ self._layout = QVBoxLayout(self)
98
+ self._layout.addSpacing(top_margin)
99
+ self._layout.addLayout(row)
100
+ self._layout.addSpacing(bottom_margin)
101
+ self._layout.addStretch(1)
102
+
103
+ def add_layout(self, layout: QLayout) -> None:
104
+ """Add a layout to the dialog body.
105
+
106
+ Args:
107
+ layout: Layout to append to the main dialog layout.
108
+ """
109
+
110
+ self._layout.addLayout(layout)
111
+
112
+ def add_widget(self, widget: QWidget) -> None:
113
+ """Add a widget to the dialog body.
114
+
115
+ Args:
116
+ widget: Widget to append to the main dialog layout.
117
+ """
118
+
119
+ self._layout.addWidget(widget)
120
+
121
+ def add_stretch(self, stretch: int = 1) -> None:
122
+ """Add stretch space to the dialog body.
123
+
124
+ Args:
125
+ stretch: Stretch factor to add.
126
+ """
127
+
128
+ self._layout.addStretch(stretch)
129
+
130
+
131
+ class MessageDialog(TitledDialogBase):
132
+ """Simple informational dialog with a single acknowledgement button."""
133
+
134
+ def __init__(
135
+ self,
136
+ title: str,
137
+ text: str,
138
+ informative_text: str = "",
139
+ icon: Optional[DialogIcon] = None,
140
+ parent: Optional[QWidget] = None,
141
+ ) -> None:
142
+ """Initialize the message dialog.
143
+
144
+ Args:
145
+ title: Window title.
146
+ text: Main dialog text.
147
+ informative_text: Optional secondary explanatory text.
148
+ icon: Optional standard dialog icon preset.
149
+ parent: Optional parent widget.
150
+ """
151
+
152
+ super().__init__(
153
+ title=title,
154
+ text=text,
155
+ informative_text=informative_text,
156
+ icon=icon,
157
+ parent=parent,
158
+ )
159
+
160
+ ok_btn = QPushButton("OK")
161
+ ok_btn.setDefault(True)
162
+ ok_btn.setAutoDefault(True)
163
+ ok_btn.clicked.connect(self.accept)
164
+
165
+ btn_layout = QHBoxLayout()
166
+ btn_layout.addStretch(1)
167
+ btn_layout.addWidget(ok_btn)
168
+ btn_layout.addStretch(1)
169
+
170
+ self.add_layout(btn_layout)
171
+ self.setFixedSize(self.sizeHint())
172
+
173
+
174
+ class InfoDialog(QDialog):
175
+ """Rich-text dialog for longer informational content."""
176
+
177
+ def __init__(
178
+ self,
179
+ title: str,
180
+ html_text: str,
181
+ icon: Optional[DialogIcon] = DialogIcon.INFORMATION,
182
+ parent: Optional[QWidget] = None,
183
+ copy_button: bool = True,
184
+ ) -> None:
185
+ """Initialize the rich informational dialog.
186
+
187
+ Args:
188
+ title: Window title.
189
+ html_text: Rich HTML content shown in the text browser.
190
+ icon: Optional standard dialog icon preset.
191
+ parent: Optional parent widget.
192
+ copy_button: Whether to include a button that copies the visible text.
193
+ """
194
+
195
+ super().__init__(parent)
196
+
197
+ self.setWindowTitle(title)
198
+ self.resize(560, 360)
199
+ self.setMinimumSize(420, 260)
200
+
201
+ main_layout = QVBoxLayout(self)
202
+ main_layout.setContentsMargins(18, 16, 18, 16)
203
+ main_layout.setSpacing(12)
204
+
205
+ top_row = QHBoxLayout()
206
+ top_row.setSpacing(12)
207
+ if icon is not None:
208
+ std_icon = self.style().standardIcon(icon.value)
209
+ pixmap = std_icon.pixmap(32, 32)
210
+ icon_label = QLabel()
211
+ icon_label.setPixmap(pixmap)
212
+ icon_label.setFixedSize(icon_label.sizeHint())
213
+ top_row.addWidget(icon_label, alignment=Qt.AlignTop)
214
+
215
+ title_label = _make_html_label(f"<b>{html.escape(title)}</b>")
216
+ top_row.addWidget(title_label, 1, alignment=Qt.AlignVCenter)
217
+ main_layout.addLayout(top_row)
218
+
219
+ browser = QTextBrowser(self)
220
+ browser.setOpenExternalLinks(True)
221
+ browser.setReadOnly(True)
222
+ browser.setTextInteractionFlags(Qt.TextBrowserInteraction)
223
+ browser.setHtml(html_text)
224
+ browser.moveCursor(QTextCursor.Start)
225
+ self._browser = browser
226
+ main_layout.addWidget(browser, 1)
227
+
228
+ btn_layout = QHBoxLayout()
229
+ btn_layout.addStretch(1)
230
+
231
+ if copy_button:
232
+ copy_btn = QPushButton("Copy")
233
+ copy_btn.clicked.connect(self._copy_to_clipboard)
234
+ btn_layout.addWidget(copy_btn)
235
+
236
+ close_btn = QPushButton("Close")
237
+ close_btn.setDefault(True)
238
+ close_btn.setAutoDefault(True)
239
+ close_btn.clicked.connect(self.accept)
240
+ btn_layout.addWidget(close_btn)
241
+
242
+ main_layout.addLayout(btn_layout)
243
+
244
+ def _copy_to_clipboard(self) -> None:
245
+ """Copy the visible dialog text to the clipboard."""
246
+
247
+ QGuiApplication.clipboard().setText(self._browser.toPlainText())
248
+
249
+
250
+ class AboutDialog(QDialog):
251
+ """Qt-like dialog for application and author information."""
252
+
253
+ def __init__(
254
+ self,
255
+ title: str,
256
+ html_text: str,
257
+ parent: Optional[QWidget] = None,
258
+ heading: Optional[str] = None,
259
+ icon: Optional[QIcon] = None,
260
+ ) -> None:
261
+ """Initialize the about dialog layout and content.
262
+
263
+ Args:
264
+ title: Window title.
265
+ html_text: Rich HTML body content.
266
+ parent: Optional parent widget.
267
+ heading: Optional plain-text heading shown above the body content.
268
+ icon: Optional icon shown on the left. Defaults to the
269
+ application's window icon, or a standard information icon
270
+ if none is set.
271
+ """
272
+
273
+ super().__init__(parent)
274
+
275
+ self.setWindowTitle(title)
276
+
277
+ main_layout = QVBoxLayout(self)
278
+ main_layout.setContentsMargins(22, 18, 22, 18)
279
+ main_layout.setSpacing(16)
280
+
281
+ content_layout = QHBoxLayout()
282
+ content_layout.setSpacing(18)
283
+
284
+ icon_label = QLabel()
285
+ if icon is None:
286
+ icon = QApplication.windowIcon()
287
+ pixmap = icon.pixmap(96, 96) if not icon.isNull() else QPixmap()
288
+ if pixmap.isNull():
289
+ std_icon = self.style().standardIcon(QStyle.SP_MessageBoxInformation)
290
+ pixmap = std_icon.pixmap(64, 64)
291
+ icon_label.setPixmap(pixmap)
292
+ icon_label.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
293
+ icon_label.setFixedWidth(max(pixmap.width() + 8, 84))
294
+ content_layout.addWidget(icon_label, 0, Qt.AlignTop)
295
+
296
+ right_layout = QVBoxLayout()
297
+ right_layout.setSpacing(10)
298
+
299
+ heading_label = QLabel(heading or title)
300
+ heading_label.setTextFormat(Qt.PlainText)
301
+ heading_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
302
+ heading_label.setStyleSheet("font-size: 18pt; font-weight: bold;")
303
+ right_layout.addWidget(heading_label)
304
+
305
+ body_label = _make_html_label(html_text)
306
+ body_label.setWordWrap(True)
307
+ right_layout.addWidget(body_label, 1)
308
+
309
+ content_layout.addLayout(right_layout, 1)
310
+ main_layout.addLayout(content_layout, 1)
311
+ main_layout.addSpacing(10)
312
+
313
+ button_box = QDialogButtonBox(QDialogButtonBox.Ok)
314
+ button_box.setCenterButtons(True)
315
+ button_box.accepted.connect(self.accept)
316
+ main_layout.addWidget(button_box)
317
+
318
+ self.setFixedSize(self.sizeHint())
319
+
320
+
321
+ class AskDialog(TitledDialogBase):
322
+ """Confirmation dialog returning yes, no, or cancel."""
323
+
324
+ def __init__(
325
+ self,
326
+ title: str,
327
+ text: str,
328
+ informative_text: str = "",
329
+ yes_btn_label: str = "Yes",
330
+ no_btn_label: Optional[str] = None,
331
+ cancel_btn_label: str = "Cancel",
332
+ parent: Optional[QWidget] = None,
333
+ ) -> None:
334
+ """Initialize the question dialog.
335
+
336
+ Args:
337
+ title: Window title.
338
+ text: Main question text.
339
+ informative_text: Optional secondary explanatory text.
340
+ yes_btn_label: Label for the affirmative button.
341
+ no_btn_label: Optional label for an explicit negative button.
342
+ cancel_btn_label: Label for the cancel button.
343
+ parent: Optional parent widget.
344
+ """
345
+
346
+ super().__init__(
347
+ title=title,
348
+ text=text,
349
+ informative_text=informative_text,
350
+ icon=DialogIcon.QUESTION,
351
+ parent=parent,
352
+ )
353
+
354
+ self.result_value = AskResult.CANCEL
355
+
356
+ yes_btn = QPushButton(yes_btn_label)
357
+ cancel_btn = QPushButton(cancel_btn_label)
358
+
359
+ yes_btn.setDefault(True)
360
+ yes_btn.setAutoDefault(True)
361
+
362
+ yes_btn.clicked.connect(self._yes)
363
+ cancel_btn.clicked.connect(self.reject)
364
+
365
+ btn_layout = QHBoxLayout()
366
+ btn_layout.addStretch(1)
367
+ btn_layout.addWidget(yes_btn)
368
+ if no_btn_label:
369
+ no_btn = QPushButton(no_btn_label)
370
+ no_btn.clicked.connect(self._no)
371
+ btn_layout.addWidget(no_btn)
372
+ btn_layout.addWidget(cancel_btn)
373
+
374
+ self.add_layout(btn_layout)
375
+ self.setFixedSize(self.sizeHint())
376
+
377
+ def _yes(self) -> None:
378
+ """Record an affirmative answer and close the dialog."""
379
+
380
+ self.result_value = AskResult.YES
381
+ self.accept()
382
+
383
+ def _no(self) -> None:
384
+ """Record a negative answer and close the dialog."""
385
+
386
+ self.result_value = AskResult.NO
387
+ self.accept()
388
+
389
+
390
+ def _make_html_label(html_text: str) -> QLabel:
391
+ """Create a rich-text label with selection and external-link support.
392
+
393
+ Args:
394
+ html_text: HTML text to display.
395
+
396
+ Returns:
397
+ Configured rich-text label.
398
+ """
399
+
400
+ label = QLabel(html_text)
401
+ label.setTextFormat(Qt.RichText)
402
+ label.setOpenExternalLinks(True)
403
+ label.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse)
404
+ label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
405
+ return label
406
+
407
+
408
+ def show_information(
409
+ title: str,
410
+ text: str,
411
+ informative_text: str = "",
412
+ parent: Optional[QWidget] = None,
413
+ ) -> None:
414
+ """Show an informational message dialog.
415
+
416
+ Args:
417
+ title: Window title.
418
+ text: Main dialog text.
419
+ informative_text: Optional secondary explanatory text.
420
+ parent: Optional parent widget.
421
+ """
422
+
423
+ dlg = MessageDialog(title, text, informative_text, icon=DialogIcon.INFORMATION, parent=parent)
424
+ dlg.exec()
425
+
426
+
427
+ def show_rich_information(
428
+ title: str,
429
+ html_text: str,
430
+ parent: Optional[QWidget] = None,
431
+ icon: Optional[DialogIcon] = DialogIcon.INFORMATION,
432
+ copy_button: bool = True,
433
+ ) -> None:
434
+ """Show a rich-text informational dialog.
435
+
436
+ Args:
437
+ title: Window title.
438
+ html_text: Rich HTML content.
439
+ parent: Optional parent widget.
440
+ icon: Optional standard dialog icon preset.
441
+ copy_button: Whether to include a copy button.
442
+ """
443
+
444
+ dlg = InfoDialog(
445
+ title=title,
446
+ html_text=html_text,
447
+ icon=icon,
448
+ parent=parent,
449
+ copy_button=copy_button,
450
+ )
451
+ dlg.exec()
452
+
453
+
454
+ def show_about_dialog(
455
+ title: str,
456
+ html_text: str,
457
+ parent: Optional[QWidget] = None,
458
+ heading: Optional[str] = None,
459
+ icon: Optional[QIcon] = None,
460
+ ) -> None:
461
+ """Show a Qt-like About dialog.
462
+
463
+ Args:
464
+ title: Window title.
465
+ html_text: Rich HTML body content.
466
+ parent: Optional parent widget.
467
+ heading: Optional heading shown above the body content.
468
+ icon: Optional icon for the dialog; defaults to the application icon.
469
+ """
470
+
471
+ dlg = AboutDialog(
472
+ title=title,
473
+ html_text=html_text,
474
+ parent=parent,
475
+ heading=heading,
476
+ icon=icon,
477
+ )
478
+ dlg.exec()
479
+
480
+
481
+ def show_warning(
482
+ title: str,
483
+ text: str,
484
+ informative_text: str = "",
485
+ parent: Optional[QWidget] = None,
486
+ ) -> None:
487
+ """Show a warning message dialog.
488
+
489
+ Args:
490
+ title: Window title.
491
+ text: Main warning text.
492
+ informative_text: Optional secondary explanatory text.
493
+ parent: Optional parent widget.
494
+ """
495
+
496
+ dlg = MessageDialog(title, text, informative_text, icon=DialogIcon.WARNING, parent=parent)
497
+ dlg.exec()
498
+
499
+
500
+ def show_critical(
501
+ title: str,
502
+ text: str,
503
+ informative_text: str = "",
504
+ parent: Optional[QWidget] = None,
505
+ ) -> None:
506
+ """Show a critical-error message dialog.
507
+
508
+ Args:
509
+ title: Window title.
510
+ text: Main error text.
511
+ informative_text: Optional secondary explanatory text.
512
+ parent: Optional parent widget.
513
+ """
514
+
515
+ dlg = MessageDialog(title, text, informative_text, icon=DialogIcon.CRITICAL, parent=parent)
516
+ dlg.exec()
517
+
518
+
519
+ def ask_question(
520
+ title: str,
521
+ text: str,
522
+ informative_text: str = "",
523
+ yes_btn_label: str = "Yes",
524
+ no_btn_label: Optional[str] = None,
525
+ cancel_btn_label: str = "Cancel",
526
+ parent: Optional[QWidget] = None,
527
+ ) -> AskResult:
528
+ """Show a confirmation dialog and return the selected answer.
529
+
530
+ Args:
531
+ title: Window title.
532
+ text: Main question text.
533
+ informative_text: Optional secondary explanatory text.
534
+ yes_btn_label: Label for the affirmative button.
535
+ no_btn_label: Optional label for an explicit negative button.
536
+ cancel_btn_label: Label for the cancel button.
537
+ parent: Optional parent widget.
538
+
539
+ Returns:
540
+ Selected dialog result.
541
+ """
542
+
543
+ dlg = AskDialog(
544
+ title,
545
+ text,
546
+ informative_text,
547
+ yes_btn_label=yes_btn_label,
548
+ no_btn_label=no_btn_label,
549
+ cancel_btn_label=cancel_btn_label,
550
+ parent=parent,
551
+ )
552
+ dlg.exec()
553
+ return dlg.result_value
@@ -0,0 +1,161 @@
1
+ """Number formatting helpers shared by the parameter widgets.
2
+
3
+ Formats are ordinary Python format strings, plus a custom scientific marker
4
+ ``S`` (for example ``"{:.3S}"``) rendered by :func:`pretty_sci_text`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Literal
11
+
12
+ __all__ = [
13
+ "format_is_valid",
14
+ "get_numeric_format_field",
15
+ "pretty_sci_text",
16
+ "value_to_text",
17
+ "is_sci_format"
18
+ ]
19
+
20
+ _SCI_PAT = r"\{:[0-9]*\.(\d+)S\}"
21
+
22
+ def pretty_sci_text(
23
+ value: float,
24
+ sig_digits: int = 3,
25
+ notation: Literal["plain", "html", "latex"] = "html",
26
+ ) -> str:
27
+ """Format a float using scientific notation when appropriate.
28
+
29
+ Args:
30
+ value: Numeric value to format.
31
+ sig_digits: Number of significant digits. Must be at least 1.
32
+ notation: Output style. Supported values are ``"plain"``, ``"html"``,
33
+ and ``"latex"``.
34
+
35
+ Returns:
36
+ Formatted numeric string.
37
+
38
+ Note:
39
+ - Zero is returned as ``"0"``.
40
+ - Values with magnitude in ``[1, 10**sig_digits)`` are shown in ordinary
41
+ significant-digit form unless Python switches to exponent notation
42
+ after rounding.
43
+ - Other values are shown in mantissa-times-ten-to-the-power form.
44
+ """
45
+ if sig_digits < 1:
46
+ raise ValueError("sig_digits must be >= 1")
47
+
48
+ if notation not in {"plain", "html", "latex"}:
49
+ raise ValueError("notation must be 'plain', 'html', or 'latex'")
50
+
51
+ if value == 0:
52
+ return "0"
53
+
54
+ av = abs(value)
55
+
56
+ if 1 <= av < 10 ** sig_digits:
57
+ s = f"{value:.{sig_digits}g}"
58
+ if "e" not in s and "E" not in s:
59
+ if "." in s:
60
+ int_part, frac_part = s.split(".", 1)
61
+ return f"{int(int_part):,d}.{frac_part}"
62
+ return f"{int(s):,d}"
63
+
64
+ s = f"{value:.{sig_digits - 1}e}"
65
+ mant_str, exp_str = s.split("e", 1)
66
+ exp = int(exp_str)
67
+
68
+ if notation == "html":
69
+ return f"{mant_str}×10<sup>{exp}</sup>"
70
+ if notation == "latex":
71
+ return rf"{mant_str}\times 10^{{{exp}}}"
72
+ return f"{mant_str}×10^{exp}"
73
+
74
+ def format_is_valid(fmt: str, sample: int | float) -> bool:
75
+ """Return whether ``fmt`` can format ``sample`` without falling back.
76
+
77
+ Args:
78
+ fmt: Format string, possibly using the custom ``"S"`` marker.
79
+ sample: Value of the type the format will be used with.
80
+
81
+ Returns:
82
+ ``True`` if the format is usable for ``sample``.
83
+ """
84
+ try:
85
+ get_numeric_format_field(fmt).format(sample)
86
+ if not re.search(_SCI_PAT, fmt):
87
+ fmt.format(sample)
88
+ except (ValueError, TypeError, IndexError, KeyError):
89
+ return False
90
+ return True
91
+
92
+
93
+ def get_numeric_format_field(fmt: str) -> str:
94
+ """Return a Python-compatible numeric replacement field derived from ``fmt``.
95
+
96
+ This helper is used to configure numeric line-edit parsing and validation so
97
+ that entered values remain consistent with a widget's display format.
98
+
99
+ Args:
100
+ fmt: Original format string.
101
+
102
+ Returns:
103
+ Full replacement field such as ``"{:.3f}"`` or ``"{:}"``.
104
+
105
+ Note:
106
+ - Only the first replacement field with an explicit format specifier is
107
+ used.
108
+ - If no such field is found, ``"{:}"`` is returned.
109
+ - The custom scientific marker ``"S"`` is replaced with ``"g"`` because
110
+ ``"S"`` is not a valid Python numeric format type.
111
+ """
112
+ m = re.search(r"\{[^{}]*:([^{}]*)\}", fmt)
113
+ if not m:
114
+ return "{:}"
115
+ spec = m.group(1).replace("S", "g")
116
+ spec = spec.replace(",", "")
117
+ return f"{{:{spec}}}"
118
+
119
+
120
+ def value_to_text(value: int | float, fmt: str) -> str:
121
+ """Format a numeric value for display.
122
+
123
+ Args:
124
+ value: Numeric value to format.
125
+ fmt: Format string, which may use ordinary Python formatting or the
126
+ custom ``"S"`` scientific marker.
127
+
128
+ Returns:
129
+ Formatted text. Falls back to ``str(value)`` if formatting fails.
130
+
131
+ Note:
132
+ The custom scientific pattern such as ``"{:.2S}"`` is expanded using
133
+ ``pretty_sci_text``.
134
+ """
135
+
136
+ if not re.search(_SCI_PAT, fmt):
137
+ try:
138
+ return fmt.format(value)
139
+ except Exception:
140
+ return str(value)
141
+
142
+ def repl(m: re.Match) -> str:
143
+ """Replace a custom scientific field with formatted text."""
144
+ prec = int(m.group(1))
145
+ return pretty_sci_text(float(value), sig_digits=prec)
146
+
147
+ try:
148
+ return re.sub(_SCI_PAT, repl, fmt)
149
+ except Exception:
150
+ return str(value)
151
+
152
+ def is_sci_format(fmt: str) -> bool:
153
+ """Return whether ``fmt`` uses the custom ``S`` scientific marker.
154
+
155
+ Args:
156
+ fmt: Format string to inspect.
157
+
158
+ Returns:
159
+ ``True`` if ``fmt`` contains a field such as ``"{:.3S}"``.
160
+ """
161
+ return re.search(_SCI_PAT, fmt) is not None
@@ -0,0 +1,3 @@
1
+ from .math_label import MathLabel, set_latex_enabled
2
+
3
+ __all__ = ["MathLabel", "set_latex_enabled"]