composeui 0.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.
Files changed (97) hide show
  1. composeui/__init__.py +11 -0
  2. composeui/core/__init__.py +0 -0
  3. composeui/core/application.py +368 -0
  4. composeui/core/chartcolors.py +23 -0
  5. composeui/core/chartimage.py +336 -0
  6. composeui/core/dataframesource.py +307 -0
  7. composeui/core/jobs.py +426 -0
  8. composeui/core/model.py +66 -0
  9. composeui/core/registry.py +66 -0
  10. composeui/core/reload.py +476 -0
  11. composeui/core/renderer.py +193 -0
  12. composeui/core/signal.py +358 -0
  13. composeui/core/sqlitemodel.py +1183 -0
  14. composeui/core/study.py +121 -0
  15. composeui/core/translation.py +221 -0
  16. composeui/core/translationcheck.py +254 -0
  17. composeui/core/view.py +5508 -0
  18. composeui/images/composeui.svg +15 -0
  19. composeui/locales/fr.json +73 -0
  20. composeui/mypy.py +46 -0
  21. composeui/py.typed +0 -0
  22. composeui/qtcore/__init__.py +0 -0
  23. composeui/qtcore/maprenderer.py +115 -0
  24. composeui/qtcore/qtapplication.py +222 -0
  25. composeui/qtcore/qtrenderer.py +2862 -0
  26. composeui/qtcore/widgets/__init__.py +0 -0
  27. composeui/qtcore/widgets/map.qml +123 -0
  28. composeui/qtcore/widgets/qchart.py +393 -0
  29. composeui/qtcore/widgets/qdatefield.py +101 -0
  30. composeui/qtcore/widgets/qexpander.py +60 -0
  31. composeui/qtcore/widgets/qfilepathfield.py +34 -0
  32. composeui/qtcore/widgets/qfiltertable.py +394 -0
  33. composeui/qtcore/widgets/qformdialog.py +64 -0
  34. composeui/qtcore/widgets/qformlabel.py +53 -0
  35. composeui/qtcore/widgets/qmap.py +68 -0
  36. composeui/qtcore/widgets/qmarkicon.py +37 -0
  37. composeui/qtcore/widgets/qmetric.py +44 -0
  38. composeui/qtcore/widgets/qplaintext.py +29 -0
  39. composeui/qtcore/widgets/qradiogroup.py +57 -0
  40. composeui/qtcore/widgets/qsidebar.py +116 -0
  41. composeui/qtcore/widgets/qsourcetable.py +216 -0
  42. composeui/qtcore/widgets/qsplitter.py +69 -0
  43. composeui/qtcore/widgets/qtexttable.py +703 -0
  44. composeui/qtcore/widgets/qvaluecombobox.py +41 -0
  45. composeui/qtcore/widgets/qvalueslider.py +64 -0
  46. composeui/qtcore/widgets/qwindowrelease.py +32 -0
  47. composeui/textualcore/__init__.py +0 -0
  48. composeui/textualcore/textualapplication.py +121 -0
  49. composeui/textualcore/textualrenderer.py +2893 -0
  50. composeui/textualcore/widgets/__init__.py +0 -0
  51. composeui/textualcore/widgets/composebutton.py +17 -0
  52. composeui/textualcore/widgets/composechart.py +212 -0
  53. composeui/textualcore/widgets/composecheckbox.py +35 -0
  54. composeui/textualcore/widgets/composecollapsible.py +41 -0
  55. composeui/textualcore/widgets/composecombobox.py +54 -0
  56. composeui/textualcore/widgets/composedateinput.py +325 -0
  57. composeui/textualcore/widgets/composedialog.py +90 -0
  58. composeui/textualcore/widgets/composefilepathinput.py +85 -0
  59. composeui/textualcore/widgets/composefiltertable.py +367 -0
  60. composeui/textualcore/widgets/composeformdialog.py +86 -0
  61. composeui/textualcore/widgets/composeinput.py +50 -0
  62. composeui/textualcore/widgets/composelist.py +20 -0
  63. composeui/textualcore/widgets/composemarkdown.py +17 -0
  64. composeui/textualcore/widgets/composemetric.py +60 -0
  65. composeui/textualcore/widgets/composeradioset.py +54 -0
  66. composeui/textualcore/widgets/composesidebar.py +111 -0
  67. composeui/textualcore/widgets/composeslider.py +144 -0
  68. composeui/textualcore/widgets/composesourcetable.py +288 -0
  69. composeui/textualcore/widgets/composesplitter.py +211 -0
  70. composeui/textualcore/widgets/composetabbedcontent.py +35 -0
  71. composeui/textualcore/widgets/composetable.py +476 -0
  72. composeui/textualcore/widgets/composetoolbar.py +62 -0
  73. composeui/textualcore/widgets/composetree.py +139 -0
  74. composeui/webcore/__init__.py +0 -0
  75. composeui/webcore/headers.py +57 -0
  76. composeui/webcore/icons.py +58 -0
  77. composeui/webcore/limits.py +55 -0
  78. composeui/webcore/origin.py +63 -0
  79. composeui/webcore/page.py +146 -0
  80. composeui/webcore/routes.py +303 -0
  81. composeui/webcore/session.py +436 -0
  82. composeui/webcore/static/app.css +2 -0
  83. composeui/webcore/static/cally.js +1312 -0
  84. composeui/webcore/static/daisyui.LICENSE +21 -0
  85. composeui/webcore/static/deckgl.LICENSE +19 -0
  86. composeui/webcore/static/deckgl.min.js +6423 -0
  87. composeui/webcore/static/htmx.min.js +1 -0
  88. composeui/webcore/static/page.js +1175 -0
  89. composeui/webcore/static/uPlot.LICENSE +21 -0
  90. composeui/webcore/static/uPlot.iife.min.js +2 -0
  91. composeui/webcore/webapplication.py +495 -0
  92. composeui/webcore/webrenderer.py +5113 -0
  93. composeui-0.1.0.dist-info/METADATA +222 -0
  94. composeui-0.1.0.dist-info/RECORD +97 -0
  95. composeui-0.1.0.dist-info/WHEEL +5 -0
  96. composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
  97. composeui-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2862 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import sys
5
+ from importlib import import_module
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar
8
+
9
+ from qtpy.QtCore import QObject, QSignalBlocker, Qt
10
+ from qtpy.QtCore import Signal as QtSignal
11
+ from qtpy.QtGui import (
12
+ QAction,
13
+ QDoubleValidator,
14
+ QIcon,
15
+ QKeySequence,
16
+ QTextDocument,
17
+ )
18
+ from qtpy.QtWidgets import (
19
+ QAbstractButton,
20
+ QAbstractSpinBox,
21
+ QApplication,
22
+ QCheckBox,
23
+ QDockWidget,
24
+ QFileDialog,
25
+ QFormLayout,
26
+ QGridLayout,
27
+ QGroupBox,
28
+ QHBoxLayout,
29
+ QHeaderView,
30
+ QLabel,
31
+ QLineEdit,
32
+ QMainWindow,
33
+ QMenu,
34
+ QMenuBar,
35
+ QMessageBox,
36
+ QProgressBar,
37
+ QPushButton,
38
+ QScrollArea,
39
+ QSpinBox,
40
+ QSplitter,
41
+ QStackedWidget,
42
+ QStatusBar,
43
+ QStyle,
44
+ QTabWidget,
45
+ QTextBrowser,
46
+ QToolBar,
47
+ QTreeWidget,
48
+ QTreeWidgetItem,
49
+ QVBoxLayout,
50
+ QWidget,
51
+ )
52
+ from typing_extensions import Protocol
53
+
54
+ from composeui.core import chartimage
55
+ from composeui.core.registry import RendererType
56
+ from composeui.core.renderer import (
57
+ MainRenderer,
58
+ NoRenderedObjectError,
59
+ Renderer,
60
+ RendererContainer,
61
+ ViewLike,
62
+ )
63
+ from composeui.core.view import (
64
+ ActionView,
65
+ Align,
66
+ ButtonVariant,
67
+ CellKind,
68
+ ChartKind,
69
+ ChartView,
70
+ DialogKind,
71
+ DialogView,
72
+ ExpanderView,
73
+ FormLabelRowView,
74
+ FormRowView,
75
+ FormView,
76
+ GridView,
77
+ GroupFormView,
78
+ HorizontalView,
79
+ ListView,
80
+ LogType,
81
+ MainView,
82
+ Menu,
83
+ MetricView,
84
+ NodeMark,
85
+ Orientation,
86
+ PageView,
87
+ PickFileAction,
88
+ ProgressBarView,
89
+ Router,
90
+ SelectionMode,
91
+ SeparatorAction,
92
+ SidebarView,
93
+ SliderMixin,
94
+ SourceTableByPosition,
95
+ SplitterView,
96
+ StatusBar,
97
+ TabPage,
98
+ TabView,
99
+ ToolBar,
100
+ TreeView,
101
+ TypedFormDialogView,
102
+ VerticalView,
103
+ View,
104
+ date_hint,
105
+ replace_cell,
106
+ walk_tree,
107
+ )
108
+ from composeui.qtcore.widgets.qchart import QChart
109
+ from composeui.qtcore.widgets.qdatefield import QDateField
110
+ from composeui.qtcore.widgets.qexpander import QExpander
111
+ from composeui.qtcore.widgets.qfilepathfield import QFilePathField
112
+ from composeui.qtcore.widgets.qfiltertable import QFilterTable
113
+ from composeui.qtcore.widgets.qformdialog import QFormDialog
114
+ from composeui.qtcore.widgets.qformlabel import QFormLabel
115
+ from composeui.qtcore.widgets.qmarkicon import mark_icon
116
+ from composeui.qtcore.widgets.qmetric import QMetric
117
+ from composeui.qtcore.widgets.qplaintext import plain_label, plain_tool_tip
118
+ from composeui.qtcore.widgets.qradiogroup import QRadioGroup
119
+ from composeui.qtcore.widgets.qsidebar import QSidebar
120
+ from composeui.qtcore.widgets.qsourcetable import QDataFilterTable
121
+ from composeui.qtcore.widgets.qsplitter import QComposeSplitter
122
+ from composeui.qtcore.widgets.qvaluecombobox import QValueComboBox
123
+ from composeui.qtcore.widgets.qvalueslider import QValueSlider
124
+ from composeui.qtcore.widgets.qwindowrelease import release_when_hidden
125
+
126
+ if TYPE_CHECKING:
127
+ import datetime
128
+ from collections.abc import Callable, Iterable
129
+
130
+ from composeui.core.signal import Signal, SignalNoArgs
131
+ from composeui.core.view import (
132
+ CellStyle,
133
+ CellWidget,
134
+ HeaderGroup,
135
+ ReferenceLine,
136
+ Series,
137
+ TreeNode,
138
+ TreePath,
139
+ )
140
+
141
+ TView = TypeVar("TView", bound=ViewLike)
142
+ TWidget = TypeVar("TWidget", bound="QWidget | QAction")
143
+
144
+
145
+ class _MainThreadInvoker(QObject):
146
+ """Bridges a call from any thread onto the thread that owns this `QObject`."""
147
+
148
+ _invoke = QtSignal(object)
149
+
150
+ def __init__(self) -> None:
151
+ super().__init__()
152
+ self._invoke.connect(self._run, Qt.ConnectionType.QueuedConnection)
153
+
154
+ def call(self, callback: Callable[[], None]) -> None:
155
+ self._invoke.emit(callback)
156
+
157
+ @staticmethod
158
+ def _run(callback: Callable[[], None]) -> None:
159
+ callback()
160
+
161
+
162
+ class QtWidgetRenderer(Renderer[TView, TWidget]):
163
+ #: a field renderer's style sheet while its form row is invalid (see
164
+ #: `FormRowView.is_invalid`); None for a widget that is never a row's field
165
+ _invalid_style: ClassVar[str | None] = None
166
+
167
+ def __init__(self, view: TView) -> None:
168
+ super().__init__(view)
169
+ self._refresh_dispatcher.update(
170
+ {
171
+ "is_visible": self._refresh_visible,
172
+ "is_enabled": self._refresh_enabled,
173
+ }
174
+ )
175
+ if self._invalid_style is not None and isinstance(view, FormRowView):
176
+ # only a row has these: the same renderer also draws a standalone field
177
+ self._refresh_dispatcher.update(
178
+ {
179
+ name: self._invalid_refresher(name)
180
+ for name in ("log_message", "log_type", "has_form_error")
181
+ }
182
+ )
183
+ self._main_thread_invoker = _MainThreadInvoker()
184
+
185
+ def call_on_main_thread(self, callback: Callable[[], None]) -> None:
186
+ self._main_thread_invoker.call(callback)
187
+
188
+ @property
189
+ def qt_object(self) -> TWidget | None:
190
+ return self._rendered_object
191
+
192
+ def refresh(self) -> None:
193
+ if self._rendered_object is None:
194
+ raise NoRenderedObjectError
195
+ self._rendered_object.blockSignals(True) # noqa: FBT003
196
+ super().refresh()
197
+ self._rendered_object.blockSignals(False) # noqa: FBT003
198
+
199
+ def refresh_attribute(self, attr_name: str, value: Any) -> None:
200
+ """Show `value`, the new value of `attr_name`, without the widget emitting signals."""
201
+ if self._rendered_object is None:
202
+ raise NoRenderedObjectError
203
+ self._rendered_object.blockSignals(True) # noqa: FBT003
204
+ super().refresh_attribute(attr_name, value)
205
+ self._rendered_object.blockSignals(False) # noqa: FBT003
206
+
207
+ @staticmethod
208
+ def _refresh_visible(_view: TView, renderer_object: TWidget, value: bool) -> None:
209
+ renderer_object.setVisible(value)
210
+
211
+ @staticmethod
212
+ def _refresh_enabled(_view: TView, renderer_object: TWidget, value: bool) -> None:
213
+ renderer_object.setEnabled(value)
214
+
215
+ def _invalid_refresher(self, name: str) -> Callable[[TView, TWidget, Any], None]:
216
+ """Restyle the field once the row's attribute `name` changes to the given value."""
217
+
218
+ def refresh(view: TView, renderer_object: TWidget, value: Any) -> None:
219
+ is_invalid = isinstance(view, FormRowView) and view.is_invalid_once(name, value)
220
+ assert isinstance(renderer_object, QWidget) # a field renderer's
221
+ renderer_object.setStyleSheet((self._invalid_style if is_invalid else None) or "")
222
+
223
+ return refresh
224
+
225
+
226
+ class _InputViewLike(ViewLike, Protocol):
227
+ placeholder: str
228
+ text_changed: Signal[str]
229
+ text_edited: Signal[str]
230
+ editing_finished: SignalNoArgs
231
+
232
+
233
+ class InputViewLike(_InputViewLike, Protocol):
234
+ text: str
235
+
236
+
237
+ class QtInputRenderer(QtWidgetRenderer[InputViewLike, QLineEdit]):
238
+ _invalid_style = "QLineEdit { border: 1px solid red; }"
239
+
240
+ def __init__(self, view: InputViewLike) -> None:
241
+ super().__init__(view)
242
+ self._refresh_dispatcher.update(
243
+ {
244
+ "text": self._refresh_text,
245
+ "placeholder": self._refresh_placeholder,
246
+ }
247
+ )
248
+
249
+ def render(self) -> None:
250
+ if self._rendered_object is None:
251
+ self._rendered_object = QLineEdit()
252
+ self._rendered_object.textChanged.connect(self._update_text)
253
+ # Qt reports an edit before the change: the view has the text first
254
+ self._rendered_object.textEdited.connect(self._update_text)
255
+ self._view.editing_finished.add_qt_signal(self._rendered_object.editingFinished)
256
+ self._view.text_changed.add_qt_signal(self._rendered_object.textChanged)
257
+ self._view.text_edited.add_qt_signal(self._rendered_object.textEdited)
258
+ self.refresh()
259
+
260
+ @staticmethod
261
+ def _refresh_text(_view: InputViewLike, rendered_object: QLineEdit, value: str) -> None:
262
+ rendered_object.setText(value)
263
+
264
+ @staticmethod
265
+ def _refresh_placeholder(_view: InputViewLike, rendered_object: QLineEdit, value: str) -> None:
266
+ rendered_object.setPlaceholderText(value)
267
+
268
+ def _update_text(self, text: str) -> None:
269
+ with self.stop_refresh():
270
+ self._view.text = text
271
+
272
+
273
+ class FloatInputViewLike(_InputViewLike, Protocol):
274
+ value: float | None
275
+ minimum: float | None
276
+ maximum: float | None
277
+ decimals: int | None
278
+
279
+ def shown_text(self, value: float | None, decimals: int | None) -> str: ...
280
+
281
+
282
+ class QtFloatInputRenderer(QtWidgetRenderer[FloatInputViewLike, QLineEdit]):
283
+ _invalid_style = "QLineEdit { border: 1px solid red; }"
284
+
285
+ def __init__(self, view: FloatInputViewLike) -> None:
286
+ super().__init__(view)
287
+ self._refresh_dispatcher.update(
288
+ {
289
+ "value": self._refresh_value,
290
+ "placeholder": self._refresh_placeholder,
291
+ "minimum": self._refresh_minimum,
292
+ "maximum": self._refresh_maximum,
293
+ "decimals": self._refresh_decimals,
294
+ }
295
+ )
296
+
297
+ def render(self) -> None:
298
+ if self._rendered_object is None:
299
+ self._rendered_object = QLineEdit()
300
+ validator = QDoubleValidator()
301
+ self._rendered_object.setValidator(validator)
302
+ self._rendered_object.textChanged.connect(self._update_text)
303
+ # Qt reports an edit before the change: the view has the value first
304
+ self._rendered_object.textEdited.connect(self._update_text)
305
+ self._view.editing_finished.add_qt_signal(self._rendered_object.editingFinished)
306
+ self._view.text_changed.add_qt_signal(self._rendered_object.textChanged)
307
+ self._view.text_edited.add_qt_signal(self._rendered_object.textEdited)
308
+ self.refresh()
309
+
310
+ @staticmethod
311
+ def _refresh_value(
312
+ view: FloatInputViewLike, rendered_object: QLineEdit, value: float | None
313
+ ) -> None:
314
+ rendered_object.setText(view.shown_text(value, view.decimals))
315
+
316
+ @staticmethod
317
+ def _refresh_placeholder(
318
+ _view: FloatInputViewLike, rendered_object: QLineEdit, value: str
319
+ ) -> None:
320
+ rendered_object.setPlaceholderText(value)
321
+
322
+ @staticmethod
323
+ def _refresh_minimum(
324
+ _view: FloatInputViewLike, rendered_object: QLineEdit, value: float | None
325
+ ) -> None:
326
+ validator = rendered_object.validator()
327
+ assert isinstance(validator, QDoubleValidator)
328
+ validator.setBottom(float("-inf") if value is None else value)
329
+
330
+ @staticmethod
331
+ def _refresh_maximum(
332
+ _view: FloatInputViewLike, rendered_object: QLineEdit, value: float | None
333
+ ) -> None:
334
+ validator = rendered_object.validator()
335
+ assert isinstance(validator, QDoubleValidator)
336
+ validator.setTop(float("inf") if value is None else value)
337
+
338
+ @staticmethod
339
+ def _refresh_decimals(
340
+ view: FloatInputViewLike, rendered_object: QLineEdit, value: int | None
341
+ ) -> None:
342
+ validator = rendered_object.validator()
343
+ assert isinstance(validator, QDoubleValidator)
344
+ validator.setDecimals(-1 if value is None else value)
345
+ rendered_object.setText(view.shown_text(view.value, value))
346
+
347
+ def _update_text(self, text: str) -> None:
348
+ with self.stop_refresh():
349
+ if text != "":
350
+ self._view.value = float(text)
351
+ else:
352
+ self._view.value = None
353
+
354
+
355
+ def _line_edit(rendered_object: QAbstractSpinBox) -> QLineEdit:
356
+ """Get the field of a spin box or a date edit."""
357
+ # `QAbstractSpinBox.lineEdit` is protected
358
+ line_edit = rendered_object.findChild(QLineEdit)
359
+ assert line_edit is not None
360
+ return line_edit
361
+
362
+
363
+ class IntInputViewLike(_InputViewLike, Protocol):
364
+ value: int | None
365
+ minimum: int | None
366
+ maximum: int | None
367
+
368
+
369
+ #: a spin box's widest range: Qt's spin box holds a 32-bit int
370
+ _INT_MINIMUM, _INT_MAXIMUM = -(2**31), 2**31 - 1
371
+
372
+
373
+ class QtIntInputRenderer(QtWidgetRenderer[IntInputViewLike, QSpinBox]):
374
+ """A spin box, keeping its value within `minimum`/`maximum`.
375
+
376
+ It shows no value while the view's is `None`; once it has one, leaving it emptied
377
+ puts the last value back, as any Qt spin box does.
378
+ """
379
+
380
+ _invalid_style = "QSpinBox { border: 1px solid red; }"
381
+
382
+ def __init__(self, view: IntInputViewLike) -> None:
383
+ super().__init__(view)
384
+ self._refresh_dispatcher.update(
385
+ {
386
+ "value": self._refresh_value,
387
+ "placeholder": self._refresh_placeholder,
388
+ "minimum": self._refresh_minimum,
389
+ "maximum": self._refresh_maximum,
390
+ }
391
+ )
392
+
393
+ def render(self) -> None:
394
+ if self._rendered_object is None:
395
+ self._rendered_object = QSpinBox()
396
+ self._rendered_object.setRange(_INT_MINIMUM, _INT_MAXIMUM)
397
+ # its text only changes once acceptable, so never for a transient "-"
398
+ self._rendered_object.valueChanged.connect(self._update_value)
399
+ self._view.editing_finished.add_qt_signal(self._rendered_object.editingFinished)
400
+ self._view.text_changed.add_qt_signal(self._rendered_object.textChanged)
401
+ line_edit = _line_edit(self._rendered_object)
402
+ # Qt reports an edit before the spin box reads it: the view has the value first
403
+ line_edit.textEdited.connect(self._update_edited)
404
+ self._view.text_edited.add_qt_signal(line_edit.textEdited)
405
+ self.refresh()
406
+
407
+ @staticmethod
408
+ def _refresh_value(
409
+ _view: IntInputViewLike, rendered_object: QSpinBox, value: int | None
410
+ ) -> None:
411
+ if value is None:
412
+ rendered_object.clear()
413
+ else:
414
+ rendered_object.setValue(value)
415
+
416
+ @staticmethod
417
+ def _refresh_placeholder(
418
+ _view: IntInputViewLike, rendered_object: QSpinBox, value: str
419
+ ) -> None:
420
+ _line_edit(rendered_object).setPlaceholderText(value)
421
+
422
+ @staticmethod
423
+ def _refresh_minimum(
424
+ view: IntInputViewLike, rendered_object: QSpinBox, value: int | None
425
+ ) -> None:
426
+ rendered_object.setMinimum(_INT_MINIMUM if value is None else value)
427
+ # a new range shows a value even while the view has none
428
+ QtIntInputRenderer._refresh_value(view, rendered_object, view.value)
429
+
430
+ @staticmethod
431
+ def _refresh_maximum(
432
+ view: IntInputViewLike, rendered_object: QSpinBox, value: int | None
433
+ ) -> None:
434
+ rendered_object.setMaximum(_INT_MAXIMUM if value is None else value)
435
+ QtIntInputRenderer._refresh_value(view, rendered_object, view.value)
436
+
437
+ def _update_value(self, value: int) -> None:
438
+ with self.stop_refresh():
439
+ self._view.value = value
440
+
441
+ def _update_edited(self, _text: str) -> None:
442
+ """Give the view the value edited, a whole number in range, before the spin box does."""
443
+ assert self._rendered_object is not None
444
+ text = self._rendered_object.cleanText()
445
+ if text == "":
446
+ with self.stop_refresh():
447
+ self._view.value = None
448
+ return
449
+ try:
450
+ value = int(text)
451
+ except ValueError:
452
+ return # transient text, e.g. "-": the spin box keeps its value
453
+ if self._rendered_object.minimum() <= value <= self._rendered_object.maximum():
454
+ self._update_value(value)
455
+
456
+
457
+ class DateInputViewLike(_InputViewLike, Protocol):
458
+ value: datetime.date | None
459
+ minimum: datetime.date | None
460
+ maximum: datetime.date | None
461
+ date_format: str
462
+ pick_text: str
463
+ value_changed: Signal[datetime.date | None]
464
+
465
+
466
+ class QtDateInputRenderer(QtWidgetRenderer[DateInputViewLike, QDateField]):
467
+ """A field typing the date as the view's `date_format` says, beside a calendar button.
468
+
469
+ Its calendar names the months and days in the system's language: Qt's own, as the
470
+ web's in the browser's.
471
+ """
472
+
473
+ _invalid_style = "QLineEdit { border: 1px solid red; }"
474
+
475
+ def __init__(self, view: DateInputViewLike) -> None:
476
+ super().__init__(view)
477
+ self._refresh_dispatcher.update(
478
+ {
479
+ "value": self._refresh_value,
480
+ "placeholder": self._refresh_placeholder,
481
+ "minimum": self._refresh_range,
482
+ "maximum": self._refresh_range,
483
+ "date_format": self._refresh_date_format,
484
+ "pick_text": self._refresh_pick_text,
485
+ # Qt's calendar names them, in the system's language
486
+ "month_names": lambda _view, _rendered_object, _value: None,
487
+ "day_names": lambda _view, _rendered_object, _value: None,
488
+ "previous_month_text": lambda _view, _rendered_object, _value: None,
489
+ "next_month_text": lambda _view, _rendered_object, _value: None,
490
+ }
491
+ )
492
+
493
+ def render(self) -> None:
494
+ if self._rendered_object is None:
495
+ self._rendered_object = QDateField()
496
+ # first, so the view's callbacks see the new date
497
+ self._rendered_object.date_edited.connect(self._update_value)
498
+ line_edit = self._rendered_object.line_edit
499
+ self._view.editing_finished.add_qt_signal(line_edit.editingFinished)
500
+ self._view.text_changed.add_qt_signal(line_edit.textChanged)
501
+ self._view.text_edited.add_qt_signal(line_edit.textEdited)
502
+ self.refresh()
503
+
504
+ @staticmethod
505
+ def _refresh_value(
506
+ _view: DateInputViewLike, rendered_object: QDateField, value: datetime.date | None
507
+ ) -> None:
508
+ rendered_object.set_date(value)
509
+
510
+ @staticmethod
511
+ def _refresh_placeholder(
512
+ view: DateInputViewLike, rendered_object: QDateField, value: str
513
+ ) -> None:
514
+ rendered_object.line_edit.setPlaceholderText(date_hint(value, view.date_format))
515
+
516
+ @staticmethod
517
+ def _refresh_range(view: DateInputViewLike, rendered_object: QDateField, _value: Any) -> None:
518
+ rendered_object.set_range(view.minimum, view.maximum)
519
+
520
+ @staticmethod
521
+ def _refresh_date_format(
522
+ view: DateInputViewLike, rendered_object: QDateField, value: str
523
+ ) -> None:
524
+ rendered_object.set_date_format(value)
525
+ rendered_object.line_edit.setPlaceholderText(date_hint(view.placeholder, value))
526
+
527
+ @staticmethod
528
+ def _refresh_pick_text(
529
+ _view: DateInputViewLike, rendered_object: QDateField, value: str
530
+ ) -> None:
531
+ rendered_object.button.setToolTip(plain_tool_tip(value))
532
+ rendered_object.button.setAccessibleName(value)
533
+
534
+ def _update_value(self, value: datetime.date | None) -> None:
535
+ with self.stop_refresh():
536
+ self._view.value = value
537
+ self._view.value_changed.emit(value)
538
+
539
+
540
+ def _file_filters(extensions: dict[str, tuple[str, ...]]) -> str:
541
+ """Get a file dialog's filters, e.g. "CSV (*.csv);;Text (*.txt *.text)"."""
542
+ return ";;".join(
543
+ f"{name} ({' '.join(f'*{e}' for e in name_extensions)})"
544
+ for name, name_extensions in extensions.items()
545
+ )
546
+
547
+
548
+ class FilePathInputViewLike(InputViewLike, Protocol):
549
+ extensions: dict[str, tuple[str, ...]]
550
+ has_reload: bool
551
+ browse_text: str
552
+ reload_text: str
553
+ path_picked: Signal[Path]
554
+ reload_clicked: SignalNoArgs
555
+
556
+ @property
557
+ def path(self) -> Path | None: ...
558
+
559
+
560
+ class QtFilePathInputRenderer(QtWidgetRenderer[FilePathInputViewLike, QFilePathField]):
561
+ """A field typing a file's path, then a "…" button opening Qt's file dialog.
562
+
563
+ The dialog opens in the directory of the field's path, if any.
564
+ """
565
+
566
+ _invalid_style = "QLineEdit { border: 1px solid red; }"
567
+
568
+ def __init__(self, view: FilePathInputViewLike) -> None:
569
+ super().__init__(view)
570
+ self._refresh_dispatcher.update(
571
+ {
572
+ "text": self._refresh_text,
573
+ "placeholder": self._refresh_placeholder,
574
+ "has_reload": self._refresh_has_reload,
575
+ "browse_text": self._refresh_browse_text,
576
+ "reload_text": self._refresh_reload_text,
577
+ # read by the dialog each time it opens
578
+ "extensions": lambda _view, _rendered_object, _value: None,
579
+ }
580
+ )
581
+
582
+ def render(self) -> None:
583
+ if self._rendered_object is None:
584
+ self._rendered_object = QFilePathField()
585
+ line_edit = self._rendered_object.line_edit
586
+ line_edit.textChanged.connect(self._update_text)
587
+ # Qt reports an edit before the change: the view has the text first
588
+ line_edit.textEdited.connect(self._update_text)
589
+ self._view.editing_finished.add_qt_signal(line_edit.editingFinished)
590
+ self._view.text_changed.add_qt_signal(line_edit.textChanged)
591
+ self._view.text_edited.add_qt_signal(line_edit.textEdited)
592
+ self._rendered_object.browse_button.clicked.connect(self.browse)
593
+ self._view.reload_clicked.add_qt_signal(self._rendered_object.reload_button.clicked)
594
+ self.refresh()
595
+
596
+ def browse(self) -> None:
597
+ """Pick a file in Qt's dialog, its path then the field's; nothing if cancelled."""
598
+ assert self._rendered_object is not None
599
+ path = self._view.path
600
+ directory = "" if path is None else str(path.parent)
601
+ picked, _selected_filter = QFileDialog.getOpenFileName(
602
+ self._rendered_object,
603
+ self._view.browse_text,
604
+ directory,
605
+ _file_filters(self._view.extensions),
606
+ )
607
+ if picked == "":
608
+ return
609
+ # its `textChanged` gives the view the text, and reports it
610
+ self._rendered_object.line_edit.setText(picked)
611
+ self._view.path_picked.emit(Path(picked))
612
+
613
+ @staticmethod
614
+ def _refresh_text(
615
+ _view: FilePathInputViewLike, rendered_object: QFilePathField, value: str
616
+ ) -> None:
617
+ # the field's own signals: set in code, the text isn't reported, as a QLineEdit's
618
+ with QSignalBlocker(rendered_object.line_edit):
619
+ rendered_object.line_edit.setText(value)
620
+
621
+ @staticmethod
622
+ def _refresh_placeholder(
623
+ _view: FilePathInputViewLike, rendered_object: QFilePathField, value: str
624
+ ) -> None:
625
+ rendered_object.line_edit.setPlaceholderText(value)
626
+
627
+ @staticmethod
628
+ def _refresh_has_reload(
629
+ _view: FilePathInputViewLike, rendered_object: QFilePathField, value: bool
630
+ ) -> None:
631
+ rendered_object.reload_button.setVisible(value)
632
+
633
+ @staticmethod
634
+ def _refresh_browse_text(
635
+ _view: FilePathInputViewLike, rendered_object: QFilePathField, value: str
636
+ ) -> None:
637
+ rendered_object.browse_button.setToolTip(plain_tool_tip(value))
638
+ rendered_object.browse_button.setAccessibleName(value)
639
+
640
+ @staticmethod
641
+ def _refresh_reload_text(
642
+ _view: FilePathInputViewLike, rendered_object: QFilePathField, value: str
643
+ ) -> None:
644
+ rendered_object.reload_button.setToolTip(plain_tool_tip(value))
645
+ rendered_object.reload_button.setAccessibleName(value)
646
+
647
+ def _update_text(self, text: str) -> None:
648
+ with self.stop_refresh():
649
+ self._view.text = text
650
+
651
+
652
+ class SliderViewLike(ViewLike, Protocol):
653
+ value: int
654
+ minimum: int
655
+ maximum: int
656
+ step: int
657
+ value_changed: Signal[int]
658
+
659
+
660
+ class QtSliderRenderer(QtWidgetRenderer[SliderViewLike, QValueSlider]):
661
+ """A horizontal slider stopping on each step, its value painted beside the track."""
662
+
663
+ _invalid_style = "QSlider { border: 1px solid red; }"
664
+
665
+ def __init__(self, view: SliderViewLike) -> None:
666
+ super().__init__(view)
667
+ self._refresh_dispatcher.update(
668
+ {
669
+ "value": self._refresh_value,
670
+ "minimum": self._refresh_minimum,
671
+ "maximum": self._refresh_maximum,
672
+ "step": self._refresh_step,
673
+ }
674
+ )
675
+
676
+ def render(self) -> None:
677
+ if self._rendered_object is None:
678
+ self._rendered_object = QValueSlider()
679
+ # first, so the view's callbacks see the new value
680
+ self._rendered_object.shown_value_changed.connect(self._update_value)
681
+ self._view.value_changed.add_qt_signal(self._rendered_object.shown_value_changed)
682
+ self.refresh()
683
+
684
+ @staticmethod
685
+ def _refresh_value(view: SliderViewLike, rendered_object: QValueSlider, value: int) -> None:
686
+ QtSliderRenderer._show(rendered_object, value, view.minimum, view.maximum, view.step)
687
+
688
+ @staticmethod
689
+ def _refresh_minimum(view: SliderViewLike, rendered_object: QValueSlider, value: int) -> None:
690
+ QtSliderRenderer._show(rendered_object, view.value, value, view.maximum, view.step)
691
+
692
+ @staticmethod
693
+ def _refresh_maximum(view: SliderViewLike, rendered_object: QValueSlider, value: int) -> None:
694
+ QtSliderRenderer._show(rendered_object, view.value, view.minimum, value, view.step)
695
+
696
+ @staticmethod
697
+ def _refresh_step(view: SliderViewLike, rendered_object: QValueSlider, value: int) -> None:
698
+ QtSliderRenderer._show(rendered_object, view.value, view.minimum, view.maximum, value)
699
+
700
+ @staticmethod
701
+ def _show(
702
+ rendered_object: QValueSlider, value: int, minimum: int, maximum: int, step: int
703
+ ) -> None:
704
+ last = SliderMixin.last_position(minimum, maximum, step)
705
+ rendered_object.first, rendered_object.step = minimum, step
706
+ rendered_object.setRange(0, last)
707
+ rendered_object.setPageStep(max(1, last // 10))
708
+ rendered_object.setValue(SliderMixin.position(value, minimum, maximum, step))
709
+ rendered_object.updateGeometry() # the value's slot follows the widest value
710
+ rendered_object.update() # the value painted, even if the handle didn't move
711
+
712
+ def _update_value(self, value: int) -> None:
713
+ with self.stop_refresh():
714
+ self._view.value = value
715
+
716
+
717
+ class LabelViewLike(ViewLike, Protocol):
718
+ text: str
719
+
720
+
721
+ class QtLabelRenderer(QtWidgetRenderer[LabelViewLike, QLabel]):
722
+ def __init__(self, view: LabelViewLike) -> None:
723
+ super().__init__(view)
724
+ self._refresh_dispatcher.update(
725
+ {
726
+ "text": self._refresh_text,
727
+ }
728
+ )
729
+
730
+ def render(self) -> None:
731
+ if self._rendered_object is None:
732
+ self._rendered_object = plain_label()
733
+ self.refresh()
734
+
735
+ @staticmethod
736
+ def _refresh_text(_view: LabelViewLike, rendered_object: QLabel, value: str) -> None:
737
+ rendered_object.setText(value)
738
+
739
+
740
+ class MarkdownViewLike(ViewLike, Protocol):
741
+ text: str
742
+
743
+
744
+ class QtMarkdownRenderer(QtWidgetRenderer[MarkdownViewLike, QTextBrowser]):
745
+ def __init__(self, view: MarkdownViewLike) -> None:
746
+ super().__init__(view)
747
+ self._refresh_dispatcher.update({"text": self._refresh_text})
748
+
749
+ def render(self) -> None:
750
+ if self._rendered_object is None:
751
+ self._rendered_object = QTextBrowser()
752
+ self._rendered_object.setOpenExternalLinks(True)
753
+ self.refresh()
754
+
755
+ @staticmethod
756
+ def _refresh_text(_view: MarkdownViewLike, rendered_object: QTextBrowser, value: str) -> None:
757
+ # raw HTML shown as text, as on the web
758
+ features = QTextDocument.MarkdownFeature
759
+ rendered_object.document().setMarkdown(
760
+ value, features.MarkdownDialectGitHub | features.MarkdownNoHTML
761
+ )
762
+
763
+
764
+ class QtFormLabelRenderer(QtWidgetRenderer[FormLabelRowView, QFormLabel]):
765
+ def __init__(self, view: FormLabelRowView) -> None:
766
+ super().__init__(view)
767
+ self._refresh_dispatcher.update(
768
+ {
769
+ "label": self._refresh_text,
770
+ "help": self._refresh_help,
771
+ "help_button_text": self._refresh_help_button_text,
772
+ }
773
+ )
774
+
775
+ def render(self) -> None:
776
+ if self._rendered_object is None:
777
+ self._rendered_object = QFormLabel()
778
+ self.refresh()
779
+
780
+ @staticmethod
781
+ def _refresh_text(_view: FormLabelRowView, rendered_object: QFormLabel, value: str) -> None:
782
+ rendered_object.set_text(value)
783
+
784
+ @staticmethod
785
+ def _refresh_help(_view: FormLabelRowView, rendered_object: QFormLabel, value: str) -> None:
786
+ rendered_object.set_help(value)
787
+
788
+ @staticmethod
789
+ def _refresh_help_button_text(
790
+ _view: FormLabelRowView, rendered_object: QFormLabel, value: str
791
+ ) -> None:
792
+ rendered_object.set_help_button_text(value)
793
+
794
+
795
+ class CheckBoxViewLike(ViewLike, Protocol):
796
+ text: str
797
+ is_checked: bool
798
+ toggled: Signal[bool]
799
+
800
+
801
+ class QtCheckBoxRenderer(QtWidgetRenderer[CheckBoxViewLike, QCheckBox]):
802
+ _invalid_style = "QCheckBox { color: red; }"
803
+
804
+ def __init__(self, view: CheckBoxViewLike) -> None:
805
+ super().__init__(view)
806
+ self._refresh_dispatcher.update(
807
+ {
808
+ "text": self._refresh_text,
809
+ "is_checked": self._refresh_is_checked,
810
+ }
811
+ )
812
+
813
+ def render(self) -> None:
814
+ if self._rendered_object is None:
815
+ self._rendered_object = QCheckBox()
816
+ # first, so the view's callbacks see the new check state
817
+ self._rendered_object.toggled.connect(self._update_checked)
818
+ self._view.toggled.add_qt_signal(self._rendered_object.toggled)
819
+ self.refresh()
820
+
821
+ @staticmethod
822
+ def _refresh_text(_view: CheckBoxViewLike, rendered_object: QCheckBox, value: str) -> None:
823
+ rendered_object.setText(value)
824
+
825
+ @staticmethod
826
+ def _refresh_is_checked(
827
+ _view: CheckBoxViewLike, rendered_object: QCheckBox, value: bool
828
+ ) -> None:
829
+ rendered_object.setChecked(value)
830
+
831
+ def _update_checked(self, is_checked: bool) -> None:
832
+ with self.stop_refresh():
833
+ self._view.is_checked = is_checked
834
+
835
+
836
+ class ButtonViewLike(ViewLike, Protocol):
837
+ text: str
838
+ variant: ButtonVariant
839
+ clicked: SignalNoArgs
840
+
841
+
842
+ class QtButtonRenderer(QtWidgetRenderer[ButtonViewLike, QPushButton]):
843
+ def __init__(self, view: ButtonViewLike) -> None:
844
+ super().__init__(view)
845
+ self._refresh_dispatcher.update(
846
+ {"text": self._refresh_text, "variant": self._refresh_variant}
847
+ )
848
+
849
+ def render(self) -> None:
850
+ if self._rendered_object is None:
851
+ self._rendered_object = QPushButton()
852
+ # Qt's own `clicked` also passes the check state, which a button doesn't have
853
+ self._rendered_object.clicked.connect(self._emit_clicked)
854
+ self.refresh()
855
+
856
+ @staticmethod
857
+ def _refresh_text(_view: ButtonViewLike, rendered_object: QPushButton, value: str) -> None:
858
+ rendered_object.setText(value)
859
+
860
+ @staticmethod
861
+ def _refresh_variant(
862
+ _view: ButtonViewLike, rendered_object: QPushButton, value: ButtonVariant
863
+ ) -> None:
864
+ # Qt has no variants: the style draws a default button emphasized, and a
865
+ # destroying one is red like an invalid field
866
+ rendered_object.setDefault(value is ButtonVariant.PRIMARY)
867
+ is_danger = value is ButtonVariant.DANGER
868
+ rendered_object.setStyleSheet("QPushButton { color: red; }" if is_danger else "")
869
+
870
+ def _emit_clicked(self) -> None:
871
+ self._view.clicked.emit()
872
+
873
+
874
+ class ProgressBarViewLike(ViewLike, Protocol):
875
+ value: int
876
+ maximum: int | None
877
+
878
+
879
+ class QtProgressBarRenderer(QtWidgetRenderer[ProgressBarViewLike, QProgressBar]):
880
+ def __init__(self, view: ProgressBarViewLike) -> None:
881
+ super().__init__(view)
882
+ self._refresh_dispatcher.update(
883
+ {"value": self._refresh_value, "maximum": self._refresh_maximum}
884
+ )
885
+
886
+ def render(self) -> None:
887
+ if self._rendered_object is None:
888
+ self._rendered_object = QProgressBar()
889
+ self.refresh()
890
+
891
+ @staticmethod
892
+ def _refresh_value(
893
+ view: ProgressBarViewLike, rendered_object: QProgressBar, value: int
894
+ ) -> None:
895
+ # clamped: Qt ignores a value out of range, leaving the bar where it was
896
+ rendered_object.setValue(ProgressBarView.clamp(value, view.maximum))
897
+
898
+ @staticmethod
899
+ def _refresh_maximum(
900
+ view: ProgressBarViewLike, rendered_object: QProgressBar, value: int | None
901
+ ) -> None:
902
+ # an empty range is Qt's busy bar, for a task of unknown length
903
+ rendered_object.setRange(0, 0 if value is None else value)
904
+ rendered_object.setValue(ProgressBarView.clamp(view.value, value))
905
+
906
+
907
+ def _map_renderer(view: Any) -> Renderer[Any, Any]:
908
+ # Qt Quick and its native libraries are needed only by applications that use maps.
909
+ renderer: Renderer[Any, Any] = import_module("composeui.qtcore.maprenderer").QtMapRenderer(view)
910
+ return renderer
911
+
912
+
913
+ class ChartViewLike(ViewLike, Protocol):
914
+ kind: ChartKind
915
+ title: str
916
+ x_title: str
917
+ y_title: str
918
+ x_unit: str
919
+ y_unit: str
920
+ series: list[Series]
921
+ reference_lines: list[ReferenceLine]
922
+ categories: list[str]
923
+ x_log: bool
924
+ y_log: bool
925
+ save_image_text: str
926
+ clicked: Signal[float, float]
927
+ height: int | None
928
+
929
+
930
+ #: Qt's largest widget size, a widget's maximum when nothing limits it
931
+ _QWIDGETSIZE_MAX = 16777215
932
+ #: the formats the Save Image dialog offers, the vector ones first, by its filters
933
+ _IMAGE_FILTERS = {"PDF (*.pdf)": "pdf", "SVG (*.svg)": "svg", "PNG (*.png)": "png"}
934
+
935
+
936
+ class QtChartRenderer(QtWidgetRenderer[ChartViewLike, QChart]):
937
+ def __init__(self, view: ChartViewLike) -> None:
938
+ super().__init__(view)
939
+ self._refresh_dispatcher.update(
940
+ {
941
+ "kind": self._refresh_kind,
942
+ "title": self._refresh_title,
943
+ "x_title": self._refresh_x_title,
944
+ "x_unit": self._refresh_x_unit,
945
+ "y_title": self._refresh_y_title,
946
+ "y_unit": self._refresh_y_unit,
947
+ "series": self._refresh_series,
948
+ "reference_lines": self._refresh_reference_lines,
949
+ "categories": self._refresh_categories,
950
+ "x_log": self._refresh_x_log,
951
+ "y_log": self._refresh_y_log,
952
+ "save_image_text": self._refresh_save_image_text,
953
+ "height": self._refresh_height,
954
+ }
955
+ )
956
+
957
+ def render(self) -> None:
958
+ if self._rendered_object is None:
959
+ self._rendered_object = QChart()
960
+ self._rendered_object.save_image_requested.connect(self._save_image)
961
+ self._rendered_object.point_clicked.connect(self._view.clicked.emit)
962
+ self.refresh()
963
+
964
+ @staticmethod
965
+ def _refresh_kind(_view: ChartViewLike, rendered_object: QChart, value: ChartKind) -> None:
966
+ rendered_object.set_kind(value)
967
+
968
+ @staticmethod
969
+ def _refresh_title(_view: ChartViewLike, rendered_object: QChart, value: str) -> None:
970
+ rendered_object.set_title(value)
971
+
972
+ # each refreshed with its new value, the view still holding the old one
973
+ @staticmethod
974
+ def _refresh_x_title(view: ChartViewLike, rendered_object: QChart, value: str) -> None:
975
+ rendered_object.set_axis("bottom", value, view.x_unit)
976
+
977
+ @staticmethod
978
+ def _refresh_x_unit(view: ChartViewLike, rendered_object: QChart, value: str) -> None:
979
+ rendered_object.set_axis("bottom", view.x_title, value)
980
+
981
+ @staticmethod
982
+ def _refresh_y_title(view: ChartViewLike, rendered_object: QChart, value: str) -> None:
983
+ rendered_object.set_axis("left", value, view.y_unit)
984
+
985
+ @staticmethod
986
+ def _refresh_y_unit(view: ChartViewLike, rendered_object: QChart, value: str) -> None:
987
+ rendered_object.set_axis("left", view.y_title, value)
988
+
989
+ @staticmethod
990
+ def _refresh_series(_view: ChartViewLike, rendered_object: QChart, value: list[Series]) -> None:
991
+ rendered_object.set_series(value)
992
+
993
+ @staticmethod
994
+ def _refresh_reference_lines(
995
+ _view: ChartViewLike, rendered_object: QChart, value: list[ReferenceLine]
996
+ ) -> None:
997
+ rendered_object.set_reference_lines(value)
998
+
999
+ @staticmethod
1000
+ def _refresh_categories(
1001
+ _view: ChartViewLike, rendered_object: QChart, value: list[str]
1002
+ ) -> None:
1003
+ rendered_object.set_categories(value)
1004
+
1005
+ @staticmethod
1006
+ def _refresh_x_log(view: ChartViewLike, rendered_object: QChart, value: bool) -> None:
1007
+ rendered_object.set_log(x_log=value, y_log=view.y_log)
1008
+
1009
+ @staticmethod
1010
+ def _refresh_y_log(view: ChartViewLike, rendered_object: QChart, value: bool) -> None:
1011
+ rendered_object.set_log(x_log=view.x_log, y_log=value)
1012
+
1013
+ @staticmethod
1014
+ def _refresh_save_image_text(_view: ChartViewLike, rendered_object: QChart, value: str) -> None:
1015
+ rendered_object.save_image_action.setText(f"{value}…")
1016
+
1017
+ @staticmethod
1018
+ def _refresh_height(_view: ChartViewLike, rendered_object: QChart, value: int | None) -> None:
1019
+ # None: as tall as the layout makes it
1020
+ rendered_object.setMinimumHeight(0 if value is None else value)
1021
+ rendered_object.setMaximumHeight(_QWIDGETSIZE_MAX if value is None else value)
1022
+ # the layout lays it out again, back at its share of the column too
1023
+ rendered_object.updateGeometry()
1024
+
1025
+ def _save_image(self) -> None:
1026
+ """Ask where to save the chart's image, and save it as it shows, zoomed in too."""
1027
+ chart, view = self._rendered_object, self._view
1028
+ assert chart is not None
1029
+ assert isinstance(view, ChartView)
1030
+ path, chosen = QFileDialog.getSaveFileName(
1031
+ chart,
1032
+ view.save_image_text,
1033
+ chartimage.image_name(view.title, "pdf"),
1034
+ ";;".join(_IMAGE_FILTERS),
1035
+ )
1036
+ if path == "":
1037
+ return
1038
+ filepath = Path(path)
1039
+ if filepath.suffix.lstrip(".").lower() not in chartimage.FORMATS:
1040
+ filepath = filepath.with_name(f"{filepath.name}.{_IMAGE_FILTERS.get(chosen, 'pdf')}")
1041
+ x_range, y_range = chart.shown_range()
1042
+ options = chartimage.ImageOptions(x_range=x_range, y_range=y_range)
1043
+ chartimage.save_chart_image(view, filepath, options)
1044
+
1045
+
1046
+ class MetricViewLike(ViewLike, Protocol):
1047
+ label: str
1048
+ value: str
1049
+ delta: float | None
1050
+ delta_text: str
1051
+
1052
+
1053
+ class QtMetricRenderer(QtWidgetRenderer[MetricViewLike, QMetric]):
1054
+ def __init__(self, view: MetricViewLike) -> None:
1055
+ super().__init__(view)
1056
+ self._refresh_dispatcher.update(
1057
+ {
1058
+ "label": self._refresh_label,
1059
+ "value": self._refresh_value,
1060
+ "delta": self._refresh_delta,
1061
+ "delta_text": self._refresh_delta_text,
1062
+ }
1063
+ )
1064
+
1065
+ def render(self) -> None:
1066
+ if self._rendered_object is None:
1067
+ self._rendered_object = QMetric()
1068
+ self.refresh()
1069
+
1070
+ @staticmethod
1071
+ def _refresh_label(_view: MetricViewLike, rendered_object: QMetric, value: str) -> None:
1072
+ rendered_object.label.setText(value)
1073
+
1074
+ @staticmethod
1075
+ def _refresh_value(_view: MetricViewLike, rendered_object: QMetric, value: str) -> None:
1076
+ rendered_object.value.setText(value)
1077
+
1078
+ @staticmethod
1079
+ def _refresh_delta(view: MetricViewLike, rendered_object: QMetric, value: float | None) -> None:
1080
+ QtMetricRenderer._show_badge(rendered_object, value, view.delta_text)
1081
+
1082
+ @staticmethod
1083
+ def _refresh_delta_text(view: MetricViewLike, rendered_object: QMetric, value: str) -> None:
1084
+ QtMetricRenderer._show_badge(rendered_object, view.delta, value)
1085
+
1086
+ @staticmethod
1087
+ def _show_badge(rendered_object: QMetric, delta: float | None, delta_text: str) -> None:
1088
+ if delta is None:
1089
+ rendered_object.set_delta("", 0)
1090
+ else:
1091
+ sign = (delta > 0) - (delta < 0)
1092
+ rendered_object.set_delta(MetricView.badge(delta, delta_text), sign)
1093
+
1094
+
1095
+ class TableViewLike(ViewLike, Protocol):
1096
+ headers: list[str]
1097
+ filter_texts: dict[str, str]
1098
+ selection_mode: SelectionMode
1099
+ rows: list[list[str]]
1100
+ sortable_columns: list[int]
1101
+ filterable_columns: list[int]
1102
+ sort_column: int | None
1103
+ sort_descending: bool
1104
+ search: str
1105
+ filters: dict[int, str]
1106
+ selected_rows: list[int]
1107
+ shown_rows: list[int]
1108
+ selection_changed: Signal[list[int]]
1109
+ editable_columns: list[int]
1110
+ cell_edited: Signal[int, int, str]
1111
+ cell_widgets: dict[int, CellWidget]
1112
+ cell_clicked: Signal[int, int]
1113
+ cell_styles: dict[tuple[int, int], CellStyle]
1114
+ header_groups: list[HeaderGroup]
1115
+ row_headers: list[str]
1116
+
1117
+ def emit_cell_edited(self, row: int, column: int, text: str) -> None: ...
1118
+ def mark_text(self, mark: NodeMark) -> str: ...
1119
+ def shown_cell_styles(self) -> dict[tuple[int, int], CellStyle]: ...
1120
+ def column_checked(self, column: int) -> bool | None: ...
1121
+ def check_all(self, column: int, *, is_checked: bool) -> None: ...
1122
+ def sort_by(self, column: int) -> None: ...
1123
+ def filter_error(self, column: int, text: str) -> str | None: ...
1124
+ def filter_placeholder(self, column: int, *, is_pattern: bool = False) -> str: ...
1125
+ def filter_takes_pattern(self, column: int) -> bool: ...
1126
+ def filter_chip(self, column: int) -> str: ...
1127
+
1128
+
1129
+ class SourceTableViewLike(ViewLike, Protocol):
1130
+ texts: dict[str, str]
1131
+ export_filename: str
1132
+ sort_column: str | None
1133
+ sort_descending: bool
1134
+ search: str
1135
+ filters: dict[str, str]
1136
+ filterable_columns: list[str]
1137
+ filter_texts: dict[str, str]
1138
+
1139
+ @property
1140
+ def headers(self) -> list[str]: ...
1141
+ @property
1142
+ def column_names(self) -> list[str]: ...
1143
+ @property
1144
+ def row_count(self) -> int: ...
1145
+ def rows(self, start: int, stop: int) -> list[tuple[Any, list[str]]]: ...
1146
+ def sort_by(self, column: str) -> None: ...
1147
+ def is_selected(self, row_id: Any) -> bool: ...
1148
+ def select_at(self, position: int) -> None: ...
1149
+ def extend_to(self, position: int) -> None: ...
1150
+ def activate_at(self, position: int) -> None: ...
1151
+ @property
1152
+ def count_text(self) -> str: ...
1153
+ def header(self, column: str) -> str: ...
1154
+ def filter_error(self, column: str, text: str) -> str | None: ...
1155
+ def filter_takes_pattern(self, column: str) -> bool: ...
1156
+ def filter_placeholder(self, column: str, *, is_pattern: bool = False) -> str: ...
1157
+ def filter_chip(self, column: str) -> str: ...
1158
+ def selection_text(self) -> str: ...
1159
+ def export_csv(self, path: Path) -> None: ...
1160
+
1161
+
1162
+ class QtSourceTableRenderer(QtWidgetRenderer[SourceTableViewLike, QDataFilterTable]):
1163
+ """A large table: Qt asks the view for the rows it draws, as the user scrolls.
1164
+
1165
+ A click on a header sorts by its column (`SourceTableView.sort_by`). A click on a row
1166
+ selects it (`select_at`), Shift extending the selection (`extend_to`), and a double
1167
+ click activates it (`activate_at`).
1168
+
1169
+ Its filter bar (`QTableFilterBar`) searches it, and filters the columns of
1170
+ `filterable_columns`: the bar knows them by their position among the source's.
1171
+ Ctrl+C copies the selected rows to the clipboard (`SourceTableView.selection_text`).
1172
+ Export CSV, above the rows and in their context menu, asks where to write the rows
1173
+ (`export_csv`). While a job orders the rows (`is_ordering`), the cursor over them is
1174
+ busy.
1175
+ """
1176
+
1177
+ def __init__(self, view: SourceTableViewLike) -> None:
1178
+ super().__init__(view)
1179
+ self._refresh_dispatcher.update(
1180
+ {
1181
+ "source": self._refresh_source,
1182
+ "row_count": self._reset,
1183
+ "column_formats": self._repaint,
1184
+ "filter_texts": self._refresh_filter_texts,
1185
+ "filterable_columns": self._refresh_filterable_columns,
1186
+ "search": self._refresh_search,
1187
+ "filters": self._refresh_filters,
1188
+ # shown with the order they give, which the view sends as `row_count`
1189
+ "sort_column": lambda _view, _table, _value: None,
1190
+ "sort_descending": lambda _view, _table, _value: None,
1191
+ # each click asks the view, which knows the mode
1192
+ "selection_mode": lambda _view, _table, _value: None,
1193
+ "selected_ids": self._repaint,
1194
+ "texts": self._refresh_texts,
1195
+ # read as the user exports
1196
+ "export_filename": lambda _view, _table, _value: None,
1197
+ "is_ordering": self._refresh_is_ordering,
1198
+ }
1199
+ )
1200
+
1201
+ def render(self) -> None:
1202
+ if self._rendered_object is None:
1203
+ self._rendered_object = QDataFilterTable(self._view)
1204
+ table, bar = self._rendered_object.table, self._rendered_object.bar
1205
+ columns = SourceTableByPosition(self._view)
1206
+ table.header_clicked.connect(columns.sort_by)
1207
+ table.row_clicked.connect(
1208
+ lambda position, is_extended: columns.select(position, is_extended=is_extended)
1209
+ )
1210
+ table.row_activated.connect(self._view.activate_at)
1211
+ table.copy_requested.connect(
1212
+ lambda: QApplication.clipboard().setText(self._view.selection_text())
1213
+ )
1214
+ table.export_requested.connect(self._export)
1215
+ bar.check = columns.filter_error
1216
+ bar.placeholder = lambda column, is_pattern: columns.filter_placeholder(
1217
+ column, is_pattern=is_pattern
1218
+ )
1219
+ bar.takes_pattern = columns.filter_takes_pattern
1220
+ bar.search_edited.connect(self._update_search)
1221
+ bar.filter_added.connect(columns.add_filter)
1222
+ bar.filter_removed.connect(columns.remove_filter)
1223
+ bar.filters_cleared.connect(columns.clear_filters)
1224
+ self.refresh()
1225
+
1226
+ @classmethod
1227
+ def _refresh_source(
1228
+ cls, view: SourceTableViewLike, rendered_object: QDataFilterTable, value: Any
1229
+ ) -> None:
1230
+ """Show the new source's columns in the bar, then its rows."""
1231
+ cls._refresh_filterable_columns(view, rendered_object, view.filterable_columns)
1232
+ cls._refresh_filters(view, rendered_object, view.filters)
1233
+ cls._reset(view, rendered_object, value)
1234
+
1235
+ @staticmethod
1236
+ def _reset(view: SourceTableViewLike, rendered_object: QDataFilterTable, _value: Any) -> None:
1237
+ """Show a new order of the rows: the sort indicator, the count, and no row if none."""
1238
+ table = rendered_object.table
1239
+ table.data_model.reset()
1240
+ sort = SourceTableByPosition(view).sort
1241
+ table.set_sort_indicator(sort)
1242
+ rendered_object.bar.set_count(view.count_text)
1243
+ rendered_object.empty.setVisible(view.row_count == 0 and view.count_text != "")
1244
+
1245
+ @staticmethod
1246
+ def _refresh_texts(
1247
+ _view: SourceTableViewLike, rendered_object: QDataFilterTable, value: dict[str, str]
1248
+ ) -> None:
1249
+ # "empty": the web's; here the headers stay, over no row
1250
+ rendered_object.table.export_action.setText(f"{value.get('export', '')}…")
1251
+
1252
+ @staticmethod
1253
+ def _refresh_is_ordering(
1254
+ _view: SourceTableViewLike, rendered_object: QDataFilterTable, value: bool
1255
+ ) -> None:
1256
+ rows = rendered_object.table.viewport()
1257
+ if value:
1258
+ rows.setCursor(Qt.CursorShape.BusyCursor)
1259
+ else:
1260
+ rows.unsetCursor()
1261
+
1262
+ def _export(self) -> None:
1263
+ """Ask where to write the rows shown as CSV, and write them there."""
1264
+ table = self._rendered_object
1265
+ assert table is not None
1266
+ path, _chosen = QFileDialog.getSaveFileName(
1267
+ table, self._view.texts.get("export", ""), self._view.export_filename, "CSV (*.csv)"
1268
+ )
1269
+ if path != "":
1270
+ filepath = Path(path)
1271
+ if filepath.suffix.lower() != ".csv":
1272
+ filepath = filepath.with_name(f"{filepath.name}.csv")
1273
+ self._view.export_csv(filepath)
1274
+
1275
+ @staticmethod
1276
+ def _refresh_filter_texts(
1277
+ view: SourceTableViewLike, rendered_object: QDataFilterTable, value: dict[str, str]
1278
+ ) -> None:
1279
+ rendered_object.bar.set_texts(value)
1280
+ rendered_object.bar.set_count(view.count_text)
1281
+ rendered_object.empty.setText(value.get("no_match", ""))
1282
+
1283
+ @staticmethod
1284
+ def _refresh_filterable_columns(
1285
+ view: SourceTableViewLike, rendered_object: QDataFilterTable, value: list[str]
1286
+ ) -> None:
1287
+ columns = SourceTableByPosition(view).bar_columns(value)
1288
+ rendered_object.bar.set_columns(columns)
1289
+ rendered_object.bar.setVisible(len(columns) > 0)
1290
+
1291
+ @staticmethod
1292
+ def _refresh_search(
1293
+ _view: SourceTableViewLike, rendered_object: QDataFilterTable, value: str
1294
+ ) -> None:
1295
+ rendered_object.bar.set_search(value)
1296
+
1297
+ @staticmethod
1298
+ def _refresh_filters(
1299
+ view: SourceTableViewLike, rendered_object: QDataFilterTable, value: dict[str, str]
1300
+ ) -> None:
1301
+ rendered_object.bar.set_filters(SourceTableByPosition(view).chips(value))
1302
+
1303
+ def _update_search(self, text: str) -> None:
1304
+ with self.stop_refresh():
1305
+ self._view.search = text
1306
+
1307
+ @staticmethod
1308
+ def _repaint(
1309
+ _view: SourceTableViewLike, rendered_object: QDataFilterTable, _value: Any
1310
+ ) -> None:
1311
+ rendered_object.table.data_model.repaint()
1312
+
1313
+
1314
+ class QtTableRenderer(QtWidgetRenderer[TableViewLike, QFilterTable]):
1315
+ """A table under its filter bar; the table shows `shown_rows`, in their order.
1316
+
1317
+ The widgets know the rows only as shown: their indexes map back to `rows` through
1318
+ `shown_rows`.
1319
+ """
1320
+
1321
+ def __init__(self, view: TableViewLike) -> None:
1322
+ super().__init__(view)
1323
+ self._refresh_dispatcher.update(
1324
+ {
1325
+ "headers": self._refresh_headers,
1326
+ "filter_texts": self._refresh_filter_texts,
1327
+ "selection_mode": self._refresh_selection_mode,
1328
+ # shown with `shown_rows`, which the view always sends next
1329
+ "rows": lambda _view, _table, _value: None,
1330
+ "sortable_columns": self._refresh_sort,
1331
+ "filterable_columns": self._refresh_filterable_columns,
1332
+ "sort_column": self._refresh_sort,
1333
+ "sort_descending": self._refresh_sort,
1334
+ "search": self._refresh_search,
1335
+ "filters": self._refresh_filters,
1336
+ "selected_rows": self._refresh_selected_rows,
1337
+ "shown_rows": self._refresh_shown_rows,
1338
+ "editable_columns": self._refresh_editable_columns,
1339
+ "cell_widgets": self._refresh_cell_widgets,
1340
+ "cell_styles": self._refresh_looks,
1341
+ "header_groups": self._refresh_header_groups,
1342
+ "row_headers": self._refresh_looks,
1343
+ "done_text": functools.partial(self._refresh_mark_text, NodeMark.DONE),
1344
+ "warning_text": functools.partial(self._refresh_mark_text, NodeMark.WARNING),
1345
+ "error_text": functools.partial(self._refresh_mark_text, NodeMark.ERROR),
1346
+ }
1347
+ )
1348
+
1349
+ def render(self) -> None:
1350
+ if self._rendered_object is None:
1351
+ self._rendered_object = QFilterTable()
1352
+ table, bar = self._rendered_object.table, self._rendered_object.bar
1353
+ table.selected_rows_changed.connect(self._update_selected_rows)
1354
+ table.cell_edited.connect(self._update_cell)
1355
+ table.cell_clicked.connect(self._click_cell)
1356
+ table.check_all_clicked.connect(self._check_all)
1357
+ table.header_clicked.connect(self._sort_by)
1358
+ bar.check = self._view.filter_error
1359
+ bar.placeholder = lambda column, is_pattern: self._view.filter_placeholder(
1360
+ column, is_pattern=is_pattern
1361
+ )
1362
+ bar.takes_pattern = self._view.filter_takes_pattern
1363
+ bar.search_edited.connect(self._update_search)
1364
+ bar.filter_added.connect(self._add_filter)
1365
+ bar.filter_removed.connect(self._remove_filter)
1366
+ bar.filters_cleared.connect(self._clear_filters)
1367
+ self.refresh()
1368
+
1369
+ @classmethod
1370
+ def _refresh_headers(
1371
+ cls, view: TableViewLike, rendered_object: QFilterTable, _value: list[str]
1372
+ ) -> None:
1373
+ cls._refresh_shown_rows(view, rendered_object, view.shown_rows)
1374
+ cls._refresh_filterable_columns(view, rendered_object, view.filterable_columns)
1375
+ cls._refresh_filters(view, rendered_object, view.filters)
1376
+ cls._refresh_sort(view, rendered_object, None)
1377
+
1378
+ @staticmethod
1379
+ def _refresh_filter_texts(
1380
+ view: TableViewLike, rendered_object: QFilterTable, value: dict[str, str]
1381
+ ) -> None:
1382
+ rendered_object.bar.set_texts(value)
1383
+ rendered_object.bar.set_count(_count_text(view, view.shown_rows))
1384
+ rendered_object.empty.setText(value.get("no_match", ""))
1385
+
1386
+ @staticmethod
1387
+ def _refresh_selection_mode(
1388
+ _view: TableViewLike, rendered_object: QFilterTable, value: SelectionMode
1389
+ ) -> None:
1390
+ rendered_object.table.set_multiple_selection(is_multiple=value is SelectionMode.MULTIPLE)
1391
+
1392
+ @staticmethod
1393
+ def _refresh_sort(view: TableViewLike, rendered_object: QFilterTable, _value: Any) -> None:
1394
+ sort = None if view.sort_column is None else (view.sort_column, view.sort_descending)
1395
+ rendered_object.table.set_sort_indicator(sort, is_shown=len(view.sortable_columns) > 0)
1396
+
1397
+ @staticmethod
1398
+ def _refresh_filterable_columns(
1399
+ view: TableViewLike, rendered_object: QFilterTable, value: list[int]
1400
+ ) -> None:
1401
+ columns = [(column, view.headers[column]) for column in value if column < len(view.headers)]
1402
+ rendered_object.bar.set_columns(columns)
1403
+ rendered_object.bar.setVisible(len(columns) > 0)
1404
+
1405
+ @staticmethod
1406
+ def _refresh_search(_view: TableViewLike, rendered_object: QFilterTable, value: str) -> None:
1407
+ rendered_object.bar.set_search(value)
1408
+
1409
+ @staticmethod
1410
+ def _refresh_filters(
1411
+ view: TableViewLike, rendered_object: QFilterTable, value: dict[int, str]
1412
+ ) -> None:
1413
+ rendered_object.bar.set_filters(
1414
+ [
1415
+ (column, view.filter_chip(column), text, view.headers[column])
1416
+ for column, text in sorted(value.items())
1417
+ if column < len(view.headers)
1418
+ ]
1419
+ )
1420
+
1421
+ @staticmethod
1422
+ def _refresh_selected_rows(
1423
+ view: TableViewLike, rendered_object: QFilterTable, value: list[int]
1424
+ ) -> None:
1425
+ positions = {row: position for position, row in enumerate(view.shown_rows)}
1426
+ rendered_object.table.set_selected_rows(
1427
+ sorted(positions[row] for row in value if row in positions)
1428
+ )
1429
+
1430
+ @classmethod
1431
+ def _refresh_shown_rows(
1432
+ cls, view: TableViewLike, rendered_object: QFilterTable, value: list[int]
1433
+ ) -> None:
1434
+ row_headers = [
1435
+ view.row_headers[row] if row < len(view.row_headers) else "" for row in value
1436
+ ]
1437
+ rendered_object.table.set_table(
1438
+ view.headers,
1439
+ [view.rows[row] for row in value],
1440
+ view.shown_cell_styles(),
1441
+ row_headers if len(view.row_headers) > 0 else [],
1442
+ )
1443
+ # resetting the table drops its selection: reapply it
1444
+ cls._refresh_selected_rows(view, rendered_object, view.selected_rows)
1445
+ rendered_object.bar.set_count(_count_text(view, value))
1446
+ rendered_object.empty.setVisible(len(value) == 0 and len(view.rows) > 0)
1447
+ cls._refresh_checked_columns(view, rendered_object)
1448
+
1449
+ @staticmethod
1450
+ def _refresh_checked_columns(
1451
+ view: TableViewLike,
1452
+ rendered_object: QFilterTable,
1453
+ *,
1454
+ editable_columns: list[int] | None = None,
1455
+ cell_widgets: dict[int, CellWidget] | None = None,
1456
+ ) -> None:
1457
+ """Show each check-all box; with the new value of a field the view doesn't hold yet."""
1458
+ editable = view.editable_columns if editable_columns is None else editable_columns
1459
+ widgets = view.cell_widgets if cell_widgets is None else cell_widgets
1460
+ rendered_object.table.set_checked_columns(
1461
+ {
1462
+ column: (view.column_checked(column), column in editable)
1463
+ for column, widget in widgets.items()
1464
+ if widget.kind is CellKind.CHECK_BOX and widget.check_all
1465
+ }
1466
+ )
1467
+
1468
+ @classmethod
1469
+ def _refresh_editable_columns(
1470
+ cls, view: TableViewLike, rendered_object: QFilterTable, value: list[int]
1471
+ ) -> None:
1472
+ rendered_object.table.set_editable_columns(value)
1473
+ cls._refresh_checked_columns(view, rendered_object, editable_columns=value)
1474
+
1475
+ @classmethod
1476
+ def _refresh_cell_widgets(
1477
+ cls, view: TableViewLike, rendered_object: QFilterTable, value: dict[int, CellWidget]
1478
+ ) -> None:
1479
+ rendered_object.table.set_cell_widgets(value)
1480
+ cls._refresh_checked_columns(view, rendered_object, cell_widgets=value)
1481
+
1482
+ @classmethod
1483
+ def _refresh_looks(
1484
+ cls, view: TableViewLike, rendered_object: QFilterTable, _value: Any
1485
+ ) -> None:
1486
+ cls._refresh_shown_rows(view, rendered_object, view.shown_rows)
1487
+
1488
+ @staticmethod
1489
+ def _refresh_header_groups(
1490
+ _view: TableViewLike, rendered_object: QFilterTable, value: list[HeaderGroup]
1491
+ ) -> None:
1492
+ rendered_object.table.set_header_groups(value)
1493
+
1494
+ @staticmethod
1495
+ def _refresh_mark_text(
1496
+ mark: NodeMark, _view: TableViewLike, rendered_object: QFilterTable, value: str
1497
+ ) -> None:
1498
+ rendered_object.table.set_mark_text(mark, value)
1499
+
1500
+ def _update_selected_rows(self, positions: list[int]) -> None:
1501
+ shown = self._view.shown_rows
1502
+ rows = sorted(shown[position] for position in positions)
1503
+ with self.stop_refresh():
1504
+ self._view.selected_rows = rows
1505
+ self._view.selection_changed.emit(rows)
1506
+
1507
+ def _update_cell(self, position: int, column: int, text: str) -> None:
1508
+ rows, row = self._view.rows, self._view.shown_rows[position]
1509
+ self._view.emit_cell_edited(row, column, text)
1510
+ if self._view.rows is rows:
1511
+ with self.stop_refresh():
1512
+ self._view.rows = replace_cell(rows, row, column, text)
1513
+ assert self._rendered_object is not None
1514
+ # a check-all box follows its column's
1515
+ self._refresh_checked_columns(self._view, self._rendered_object)
1516
+
1517
+ def _click_cell(self, position: int, column: int) -> None:
1518
+ self._view.cell_clicked.emit(self._view.shown_rows[position], column)
1519
+
1520
+ def _check_all(self, column: int) -> None:
1521
+ if column in self._view.editable_columns:
1522
+ self._view.check_all(column, is_checked=self._view.column_checked(column) is not True)
1523
+
1524
+ def _sort_by(self, column: int) -> None:
1525
+ if column in self._view.sortable_columns:
1526
+ self._view.sort_by(column)
1527
+
1528
+ def _update_search(self, text: str) -> None:
1529
+ with self.stop_refresh():
1530
+ self._view.search = text
1531
+
1532
+ def _add_filter(self, column: int, text: str) -> None:
1533
+ self._view.filters = {**self._view.filters, column: text}
1534
+
1535
+ def _remove_filter(self, column: int) -> None:
1536
+ self._view.filters = {
1537
+ other: text for other, text in self._view.filters.items() if other != column
1538
+ }
1539
+
1540
+ def _clear_filters(self) -> None:
1541
+ self._view.filters = {}
1542
+
1543
+
1544
+ def _count_text(view: TableViewLike, shown: list[int]) -> str:
1545
+ """Count the rows shown, while the search or the filters hide some."""
1546
+ if len(shown) == len(view.rows):
1547
+ return ""
1548
+ return view.filter_texts.get("count", "").format(shown=len(shown), total=len(view.rows))
1549
+
1550
+
1551
+ TValue = TypeVar("TValue")
1552
+
1553
+
1554
+ class ValuesViewLike(ViewLike, Protocol, Generic[TValue]):
1555
+ value: TValue | None
1556
+ values: dict[str, TValue]
1557
+ value_changed: Signal[TValue | None]
1558
+
1559
+
1560
+ class QtRadioGroupRenderer(QtWidgetRenderer[ValuesViewLike[TValue], QRadioGroup], Generic[TValue]):
1561
+ _invalid_style = "QRadioButton { color: red; }"
1562
+
1563
+ def __init__(self, view: ValuesViewLike[TValue]) -> None:
1564
+ super().__init__(view)
1565
+ self._refresh_dispatcher.update(
1566
+ {
1567
+ "value": self._refresh_value,
1568
+ "values": self._refresh_values,
1569
+ }
1570
+ )
1571
+
1572
+ def render(self) -> None:
1573
+ if self._rendered_object is None:
1574
+ self._rendered_object = QRadioGroup(self._view.values)
1575
+ self._rendered_object.value_changed.connect(self._update_value)
1576
+ self._view.value_changed.add_qt_signal(self._rendered_object.value_changed)
1577
+ self.refresh()
1578
+
1579
+ @staticmethod
1580
+ def _refresh_value(
1581
+ _view: ValuesViewLike[TValue], rendered_object: QRadioGroup, value: Any
1582
+ ) -> None:
1583
+ rendered_object.set_value(value)
1584
+
1585
+ @staticmethod
1586
+ def _refresh_values(
1587
+ view: ValuesViewLike[TValue], rendered_object: QRadioGroup, values: dict[str, Any]
1588
+ ) -> None:
1589
+ # rebuilding leaves nothing checked, even if `view.value` didn't itself change
1590
+ # (and so wouldn't otherwise trigger its own refresh) - reapply it explicitly
1591
+ rendered_object.set_values(values)
1592
+ rendered_object.set_value(view.value)
1593
+
1594
+ def _update_value(self, value: TValue | None) -> None:
1595
+ with self.stop_refresh():
1596
+ self._view.value = value
1597
+
1598
+
1599
+ class QtComboBoxRenderer(
1600
+ QtWidgetRenderer[ValuesViewLike[TValue], QValueComboBox[TValue]], Generic[TValue]
1601
+ ):
1602
+ _invalid_style = "QComboBox { border: 1px solid red; }"
1603
+
1604
+ def __init__(self, view: ValuesViewLike[TValue]) -> None:
1605
+ super().__init__(view)
1606
+ self._refresh_dispatcher.update(
1607
+ {
1608
+ "value": self._refresh_value,
1609
+ "values": self._refresh_values,
1610
+ }
1611
+ )
1612
+
1613
+ def render(self) -> None:
1614
+ if self._rendered_object is None:
1615
+ self._rendered_object = QValueComboBox(self._view.values)
1616
+ self._rendered_object.current_value_changed.connect(self._update_value)
1617
+ self._view.value_changed.add_qt_signal(self._rendered_object.current_value_changed)
1618
+ self.refresh()
1619
+
1620
+ @staticmethod
1621
+ def _refresh_value(
1622
+ _view: ValuesViewLike[TValue], rendered_object: QValueComboBox[TValue], value: TValue
1623
+ ) -> None:
1624
+ rendered_object.set_current_value(value)
1625
+
1626
+ @staticmethod
1627
+ def _refresh_values(
1628
+ view: ValuesViewLike[TValue],
1629
+ rendered_object: QValueComboBox[TValue],
1630
+ values: dict[str, TValue],
1631
+ ) -> None:
1632
+ rendered_object.set_values(values)
1633
+ rendered_object.set_current_value(view.value)
1634
+
1635
+ def _update_value(self, value: TValue | None) -> None:
1636
+ with self.stop_refresh():
1637
+ self._view.value = value
1638
+
1639
+
1640
+ class LogViewLike(ViewLike, Protocol):
1641
+ log_message: str
1642
+ log_type: LogType
1643
+
1644
+
1645
+ class QtLogRenderer(QtWidgetRenderer[LogViewLike, QLabel]):
1646
+ def __init__(self, view: LogViewLike) -> None:
1647
+ super().__init__(view)
1648
+ self._refresh_dispatcher.update(
1649
+ {
1650
+ "log_message": self._refresh_log_message,
1651
+ "log_type": self._refresh_log_type,
1652
+ }
1653
+ )
1654
+
1655
+ def render(self) -> None:
1656
+ if self._rendered_object is None:
1657
+ self._rendered_object = plain_label()
1658
+ self._rendered_object.setWordWrap(True)
1659
+ self.refresh()
1660
+
1661
+ @staticmethod
1662
+ def _refresh_log_message(_view: LogViewLike, rendered_object: QLabel, value: str) -> None:
1663
+ rendered_object.setVisible(value != "")
1664
+ rendered_object.setText(value)
1665
+
1666
+ @staticmethod
1667
+ def _refresh_log_type(_view: LogViewLike, rendered_object: QLabel, value: LogType) -> None:
1668
+ if value == LogType.ERROR:
1669
+ rendered_object.setStyleSheet("QLabel { color : red; }")
1670
+ elif value == LogType.WARNING:
1671
+ rendered_object.setStyleSheet("QLabel { color : orange; }")
1672
+ else:
1673
+ rendered_object.setStyleSheet("")
1674
+
1675
+
1676
+ class QtFormRenderer(QtWidgetRenderer[FormView, QWidget]):
1677
+ def render(self) -> None:
1678
+ if self._rendered_object is None:
1679
+ if isinstance(self._view, GroupFormView):
1680
+ self._rendered_object = QGroupBox()
1681
+ else:
1682
+ self._rendered_object = QWidget()
1683
+ layout = QFormLayout()
1684
+ self._rendered_object.setLayout(layout)
1685
+ for field_row in self._view.field_rows:
1686
+ if isinstance(field_row, FormRowView) and isinstance(
1687
+ field_row.renderer, RendererContainer
1688
+ ):
1689
+ field_container = QWidget()
1690
+ field_container_layout = QVBoxLayout(field_container)
1691
+ # avoid extra row spacing
1692
+ field_container_layout.setContentsMargins(0, 0, 0, 0)
1693
+ field_object = self._get_field_object(field_row.renderer)
1694
+ field_container_layout.addWidget(field_object)
1695
+ log_object = self._get_log_object(field_row.renderer)
1696
+ field_container_layout.addWidget(log_object)
1697
+ if isinstance(field_row, FormLabelRowView):
1698
+ label_object = self._get_label_object(field_row.renderer)
1699
+ layout.addRow(label_object, field_container)
1700
+ else:
1701
+ layout.addRow(field_container)
1702
+ elif isinstance(field_row, FormView):
1703
+ assert isinstance(field_row.renderer, QtWidgetRenderer)
1704
+ assert isinstance(field_row.renderer.qt_object, QWidget)
1705
+ layout.addRow(field_row.renderer.qt_object)
1706
+ # else a row whose field this backend doesn't register: left out
1707
+ log = self._view.log_view.renderer
1708
+ assert isinstance(log, QtWidgetRenderer)
1709
+ assert isinstance(log.qt_object, QWidget)
1710
+ layout.addRow(log.qt_object)
1711
+ self.refresh()
1712
+
1713
+ @staticmethod
1714
+ def _get_field_object(field_row_renderer: RendererContainer[View]) -> QWidget:
1715
+ field_renderer = field_row_renderer.get_renderer("field")
1716
+ assert isinstance(field_renderer, QtWidgetRenderer)
1717
+ assert isinstance(field_renderer.qt_object, QWidget)
1718
+ return field_renderer.qt_object
1719
+
1720
+ @staticmethod
1721
+ def _get_log_object(field_row_renderer: RendererContainer[View]) -> QWidget:
1722
+ log_renderer = field_row_renderer.get_renderer("log")
1723
+ assert isinstance(log_renderer, QtLogRenderer)
1724
+ assert log_renderer.qt_object is not None
1725
+ return log_renderer.qt_object
1726
+
1727
+ @staticmethod
1728
+ def _get_label_object(field_row_renderer: RendererContainer[View]) -> QFormLabel:
1729
+ label_renderer = field_row_renderer.get_renderer("label")
1730
+ assert isinstance(label_renderer, QtFormLabelRenderer)
1731
+ assert label_renderer.qt_object is not None
1732
+ return label_renderer.qt_object
1733
+
1734
+
1735
+ class QtGroupFormRenderer(QtFormRenderer):
1736
+ def __init__(self, view: GroupFormView) -> None:
1737
+ super().__init__(view)
1738
+ self._refresh_dispatcher.update(
1739
+ {
1740
+ "title": self._refresh_title,
1741
+ }
1742
+ )
1743
+
1744
+ @staticmethod
1745
+ def _refresh_title(_view: FormView, rendered_object: QWidget, value: str) -> None:
1746
+ assert isinstance(rendered_object, QGroupBox)
1747
+ rendered_object.setTitle(value)
1748
+
1749
+
1750
+ #: widgets scrolling within themselves: as tall as the room they're given, rather than
1751
+ #: their content, so they take a column's spare height, and a row's whole height; a
1752
+ #: splitter too, its panes as tall as it
1753
+ _SCROLLING_WIDGETS = (QTextBrowser, QScrollArea, QTreeWidget, QSplitter, QDataFilterTable)
1754
+
1755
+
1756
+ def _add_content_widget(layout: QVBoxLayout, view: View) -> None:
1757
+ """Add `view`'s widget to a column of content: a window's, or a page's."""
1758
+ if not isinstance(view.renderer, QtWidgetRenderer):
1759
+ return
1760
+ widget = view.renderer.qt_object
1761
+ if not isinstance(widget, QWidget):
1762
+ return
1763
+ if isinstance(view, DialogView):
1764
+ # a window of its own, over the column's window
1765
+ widget.setParent(layout.parentWidget(), widget.windowFlags())
1766
+ elif isinstance(view, (FormView, PageView)) and not isinstance(view, GroupFormView):
1767
+ # a plain form insets its rows with its own layout's margins, and a page its views;
1768
+ # a page holding a view that scrolls within itself takes the spare height, for it
1769
+ inner = widget.layout()
1770
+ expands = inner is not None and bool(inner.expandingDirections() & Qt.Orientation.Vertical)
1771
+ layout.addWidget(widget, 1 if expands else 0)
1772
+ else:
1773
+ # inset like a plain form's rows
1774
+ inset = QVBoxLayout()
1775
+ style = widget.style()
1776
+ left = style.pixelMetric(QStyle.PixelMetric.PM_LayoutLeftMargin)
1777
+ right = style.pixelMetric(QStyle.PixelMetric.PM_LayoutRightMargin)
1778
+ inset.setContentsMargins(left, 0, right, 0)
1779
+ if isinstance(widget, QPushButton):
1780
+ # as wide as its text, like a form's own buttons, not the whole column
1781
+ inset.addWidget(widget, alignment=Qt.AlignmentFlag.AlignLeft)
1782
+ else:
1783
+ inset.addWidget(widget)
1784
+ # a document, a list or a tree scrolls within itself, and so does a row holding one:
1785
+ # it takes the column's spare height, ahead of the spacer under the views
1786
+ inner = widget.layout()
1787
+ expands = isinstance(widget, _SCROLLING_WIDGETS) or (
1788
+ isinstance(view, HorizontalView)
1789
+ and inner is not None
1790
+ and bool(inner.expandingDirections() & Qt.Orientation.Vertical)
1791
+ )
1792
+ layout.addLayout(inset, 1 if expands else 0)
1793
+
1794
+
1795
+ #: macOS shows no title on a message box (Apple's guidelines): its bold text is the title there,
1796
+ #: and its informative text the message
1797
+ _TITLE_AS_TEXT = sys.platform == "darwin"
1798
+
1799
+
1800
+ def _show_dialog_text(box: QMessageBox, title: str, text: str) -> None:
1801
+ """Show a dialog's title and text, the title as the bold text where it has no title."""
1802
+ title_as_text = _TITLE_AS_TEXT and title != ""
1803
+ box.setWindowTitle(title) # macOS ignores it
1804
+ box.setText(title if title_as_text else text)
1805
+ box.setInformativeText(text if title_as_text else "")
1806
+
1807
+
1808
+ class QtDialogRenderer(QtWidgetRenderer[DialogView, QMessageBox]):
1809
+ """A modal message box, shown without blocking: its answer reaches the view later."""
1810
+
1811
+ def __init__(self, view: DialogView) -> None:
1812
+ super().__init__(view)
1813
+ self._accept_button: QPushButton | None = None
1814
+ self._reject_button: QPushButton | None = None
1815
+ #: the button that closed the box; None when it was closed from its window
1816
+ self._clicked: QAbstractButton | None = None
1817
+ self._refresh_dispatcher.update(
1818
+ {
1819
+ "is_visible": self._refresh_dialog_visible,
1820
+ "title": self._refresh_dialog_title,
1821
+ "text": self._refresh_dialog_text,
1822
+ "kind": self._refresh_kind,
1823
+ "accept_text": self._refresh_accept_text,
1824
+ "reject_text": self._refresh_reject_text,
1825
+ }
1826
+ )
1827
+
1828
+ def render(self) -> None:
1829
+ if self._rendered_object is None:
1830
+ box = QMessageBox()
1831
+ box.setTextFormat(Qt.TextFormat.PlainText)
1832
+ box.setModal(True)
1833
+ release_when_hidden(box)
1834
+ self._accept_button = box.addButton("", QMessageBox.ButtonRole.AcceptRole)
1835
+ self._reject_button = box.addButton("", QMessageBox.ButtonRole.RejectRole)
1836
+ box.buttonClicked.connect(self._set_clicked)
1837
+ box.finished.connect(self._answer)
1838
+ self._rendered_object = box
1839
+ self.refresh()
1840
+
1841
+ def _set_clicked(self, button: QAbstractButton) -> None:
1842
+ self._clicked = button
1843
+
1844
+ def _answer(self, _result: int) -> None:
1845
+ if self._clicked is None:
1846
+ self._view.dismiss()
1847
+ elif self._clicked is self._accept_button:
1848
+ self._view.accept()
1849
+ else:
1850
+ self._view.reject()
1851
+
1852
+ def _refresh_dialog_visible(
1853
+ self, _view: DialogView, rendered_object: QMessageBox, value: bool
1854
+ ) -> None:
1855
+ if value:
1856
+ self._clicked = None
1857
+ rendered_object.setVisible(value)
1858
+
1859
+ @staticmethod
1860
+ def _refresh_dialog_title(view: DialogView, rendered_object: QMessageBox, value: str) -> None:
1861
+ # the view's field still holds the previous value: it's set once the refresh is over
1862
+ _show_dialog_text(rendered_object, value, view.text)
1863
+
1864
+ @staticmethod
1865
+ def _refresh_dialog_text(view: DialogView, rendered_object: QMessageBox, value: str) -> None:
1866
+ _show_dialog_text(rendered_object, view.title, value)
1867
+
1868
+ def _refresh_kind(
1869
+ self, _view: DialogView, rendered_object: QMessageBox, value: DialogKind
1870
+ ) -> None:
1871
+ assert self._accept_button is not None
1872
+ assert self._reject_button is not None
1873
+ is_confirm = value is DialogKind.CONFIRM
1874
+ self._reject_button.setVisible(is_confirm)
1875
+ # Escape clicks it: the hidden reject button would otherwise answer a message
1876
+ rendered_object.setEscapeButton(self._reject_button if is_confirm else self._accept_button)
1877
+
1878
+ def _refresh_accept_text(
1879
+ self, _view: DialogView, _rendered_object: QMessageBox, value: str
1880
+ ) -> None:
1881
+ assert self._accept_button is not None
1882
+ self._accept_button.setText(value)
1883
+
1884
+ def _refresh_reject_text(
1885
+ self, _view: DialogView, _rendered_object: QMessageBox, value: str
1886
+ ) -> None:
1887
+ assert self._reject_button is not None
1888
+ self._reject_button.setText(value)
1889
+
1890
+
1891
+ TForm = TypeVar("TForm", bound=FormView)
1892
+
1893
+
1894
+ class QtFormDialogRenderer(QtWidgetRenderer[TypedFormDialogView[TForm], QFormDialog]):
1895
+ """A modal dialog holding its form, shown without blocking: its answer reaches the view.
1896
+
1897
+ OK is disabled while a check of the form fails; the view closes the dialog once it
1898
+ accepts the answer, so OK on a form refusing it leaves the dialog open.
1899
+ """
1900
+
1901
+ def __init__(self, view: TypedFormDialogView[TForm]) -> None:
1902
+ super().__init__(view)
1903
+ self._refresh_dispatcher.update(
1904
+ {
1905
+ "title": self._refresh_dialog_title,
1906
+ "text": self._refresh_text,
1907
+ "kind": self._refresh_kind,
1908
+ "accept_text": self._refresh_accept_text,
1909
+ "reject_text": self._refresh_reject_text,
1910
+ "is_accept_enabled": self._refresh_is_accept_enabled,
1911
+ }
1912
+ )
1913
+
1914
+ def render(self) -> None:
1915
+ if self._rendered_object is None:
1916
+ dialog = QFormDialog()
1917
+ release_when_hidden(dialog)
1918
+ form = self._view.form.renderer
1919
+ assert isinstance(form, QtWidgetRenderer)
1920
+ assert isinstance(form.qt_object, QWidget)
1921
+ dialog.set_form(form.qt_object)
1922
+ dialog.answered_ok.connect(self._view.accept)
1923
+ dialog.answered_cancel.connect(self._view.reject)
1924
+ dialog.dismissed.connect(self._view.dismiss)
1925
+ self._rendered_object = dialog
1926
+ self.refresh()
1927
+
1928
+ @staticmethod
1929
+ def _refresh_dialog_title(
1930
+ _view: TypedFormDialogView[TForm], rendered_object: QFormDialog, value: str
1931
+ ) -> None:
1932
+ rendered_object.setWindowTitle(value)
1933
+
1934
+ @staticmethod
1935
+ def _refresh_text(
1936
+ _view: TypedFormDialogView[TForm], rendered_object: QFormDialog, value: str
1937
+ ) -> None:
1938
+ rendered_object.text.setText(value)
1939
+ rendered_object.text.setVisible(value != "")
1940
+
1941
+ @staticmethod
1942
+ def _refresh_kind(
1943
+ _view: TypedFormDialogView[TForm], _rendered_object: QFormDialog, _value: Any
1944
+ ) -> None:
1945
+ pass # always OK and Cancel: a form is confirmed or not
1946
+
1947
+ @staticmethod
1948
+ def _refresh_accept_text(
1949
+ _view: TypedFormDialogView[TForm], rendered_object: QFormDialog, value: str
1950
+ ) -> None:
1951
+ rendered_object.ok_button.setText(value)
1952
+
1953
+ @staticmethod
1954
+ def _refresh_reject_text(
1955
+ _view: TypedFormDialogView[TForm], rendered_object: QFormDialog, value: str
1956
+ ) -> None:
1957
+ rendered_object.cancel_button.setText(value)
1958
+
1959
+ @staticmethod
1960
+ def _refresh_is_accept_enabled(
1961
+ _view: TypedFormDialogView[TForm], rendered_object: QFormDialog, value: bool
1962
+ ) -> None:
1963
+ rendered_object.ok_button.setEnabled(value)
1964
+
1965
+
1966
+ class QtPageRenderer(QtWidgetRenderer[PageView, QWidget]):
1967
+ """A page's views in a column, laid out like the window's own."""
1968
+
1969
+ def render(self) -> None:
1970
+ if self._rendered_object is None:
1971
+ self._rendered_object = QWidget()
1972
+ layout = QVBoxLayout(self._rendered_object)
1973
+ # the window's layout already insets the page
1974
+ layout.setContentsMargins(0, 0, 0, 0)
1975
+ for view in self._view.content_views:
1976
+ _add_content_widget(layout, view)
1977
+ layout.addStretch()
1978
+ self.refresh()
1979
+
1980
+
1981
+ class QtHorizontalRenderer(QtWidgetRenderer[HorizontalView, QWidget]):
1982
+ """Views side by side, each as wide as its share of the row."""
1983
+
1984
+ def render(self) -> None:
1985
+ if self._rendered_object is None:
1986
+ self._rendered_object = QWidget()
1987
+ layout = QHBoxLayout(self._rendered_object)
1988
+ # the column holding the row already insets it
1989
+ layout.setContentsMargins(0, 0, 0, 0)
1990
+ # a spacer with no share: it takes the free width only when no view has a
1991
+ # share, ahead of views (e.g. buttons) that would otherwise grow into it
1992
+ if self._view.align is Align.END:
1993
+ layout.addStretch(0)
1994
+ for view, stretch in self._view.stretched_views:
1995
+ if isinstance(view.renderer, QtWidgetRenderer) and isinstance(
1996
+ view.renderer.qt_object, QWidget
1997
+ ):
1998
+ widget = view.renderer.qt_object
1999
+ # at the top: Qt centres a view shorter than the row (e.g. a table
2000
+ # as tall as its rows, beside a taller one) as the window grows; one
2001
+ # scrolling within itself, as tall as the row
2002
+ alignment = (
2003
+ Qt.AlignmentFlag(0)
2004
+ if isinstance(widget, _SCROLLING_WIDGETS)
2005
+ else Qt.AlignmentFlag.AlignTop
2006
+ )
2007
+ layout.addWidget(widget, stretch, alignment)
2008
+ if self._view.align is Align.START:
2009
+ layout.addStretch(0)
2010
+ self.refresh()
2011
+
2012
+
2013
+ class QtVerticalRenderer(QtWidgetRenderer[VerticalView, QWidget]):
2014
+ """Views one above the other, at the top of the column."""
2015
+
2016
+ def render(self) -> None:
2017
+ if self._rendered_object is None:
2018
+ self._rendered_object = QWidget()
2019
+ layout = QVBoxLayout(self._rendered_object)
2020
+ # the row holding the column already insets it
2021
+ layout.setContentsMargins(0, 0, 0, 0)
2022
+ for view in self._view.children_views:
2023
+ if isinstance(view.renderer, QtWidgetRenderer) and isinstance(
2024
+ view.renderer.qt_object, QWidget
2025
+ ):
2026
+ layout.addWidget(view.renderer.qt_object)
2027
+ # height to spare, next to a taller view, goes below the views, not between them
2028
+ layout.addStretch()
2029
+ self.refresh()
2030
+
2031
+
2032
+ class QtSplitterRenderer(QtWidgetRenderer[SplitterView, QComposeSplitter]):
2033
+ """A splitter: the panes in a row or a column, a handle between two the user drags."""
2034
+
2035
+ def __init__(self, view: SplitterView) -> None:
2036
+ super().__init__(view)
2037
+ self._refresh_dispatcher["sizes"] = self._refresh_sizes
2038
+ # names a border on the web only
2039
+ self._refresh_dispatcher["border_text"] = lambda _view, _splitter, _value: None
2040
+
2041
+ def render(self) -> None:
2042
+ if self._rendered_object is None:
2043
+ self._rendered_object = QComposeSplitter(
2044
+ Qt.Orientation.Vertical
2045
+ if self._view.orientation is Orientation.VERTICAL
2046
+ else Qt.Orientation.Horizontal
2047
+ )
2048
+ # a pane dragged to nothing would be lost to the user
2049
+ self._rendered_object.setChildrenCollapsible(False)
2050
+ for view in self._view.panes:
2051
+ if isinstance(view.renderer, QtWidgetRenderer) and isinstance(
2052
+ view.renderer.qt_object, QWidget
2053
+ ):
2054
+ self._rendered_object.addWidget(view.renderer.qt_object)
2055
+ self._rendered_object.moved_by_user.connect(self._update_sizes)
2056
+ self.refresh()
2057
+
2058
+ @staticmethod
2059
+ def _refresh_sizes(
2060
+ _view: SplitterView, rendered_object: QComposeSplitter, value: list[int]
2061
+ ) -> None:
2062
+ # in pixels, but Qt shares any width more or less than theirs in their proportions:
2063
+ # well over any screen's, the panes' minimum widths don't skew those
2064
+ rendered_object.setSizes([size * 10_000 for size in value])
2065
+
2066
+ def _update_sizes(self) -> None:
2067
+ assert self._rendered_object is not None
2068
+ sizes = self._view.shares_of(self._rendered_object.sizes())
2069
+ if sizes == self._view.sizes: # a click, or a move against a pane's minimum
2070
+ return
2071
+ with self.stop_refresh():
2072
+ self._view.sizes = sizes
2073
+ self._view.resized.emit(sizes)
2074
+
2075
+
2076
+ class QtExpanderRenderer(QtWidgetRenderer[ExpanderView, QExpander]):
2077
+ """A header button with an arrow, then the views one above the other while expanded."""
2078
+
2079
+ def __init__(self, view: ExpanderView) -> None:
2080
+ super().__init__(view)
2081
+ self._refresh_dispatcher.update(
2082
+ {"title": self._refresh_title, "is_expanded": self._refresh_is_expanded}
2083
+ )
2084
+
2085
+ def render(self) -> None:
2086
+ if self._rendered_object is None:
2087
+ self._rendered_object = QExpander()
2088
+ for view in self._view.children_views:
2089
+ if isinstance(view.renderer, QtWidgetRenderer) and isinstance(
2090
+ view.renderer.qt_object, QWidget
2091
+ ):
2092
+ widget = view.renderer.qt_object
2093
+ # a button as wide as its text, as in a window's column, not the section
2094
+ alignment = (
2095
+ Qt.AlignmentFlag.AlignLeft
2096
+ if isinstance(widget, QPushButton)
2097
+ else Qt.AlignmentFlag(0)
2098
+ )
2099
+ self._rendered_object.content_layout.addWidget(widget, 0, alignment)
2100
+ # first, so the view's callbacks see the new state
2101
+ self._rendered_object.expanded_changed.connect(self._update_expanded)
2102
+ self._view.toggled.add_qt_signal(self._rendered_object.expanded_changed)
2103
+ self.refresh()
2104
+
2105
+ @staticmethod
2106
+ def _refresh_title(_view: ExpanderView, rendered_object: QExpander, value: str) -> None:
2107
+ rendered_object.header.setText(value)
2108
+
2109
+ @staticmethod
2110
+ def _refresh_is_expanded(_view: ExpanderView, rendered_object: QExpander, value: bool) -> None:
2111
+ rendered_object.set_expanded(value)
2112
+
2113
+ def _update_expanded(self, is_expanded: bool) -> None:
2114
+ with self.stop_refresh():
2115
+ self._view.is_expanded = is_expanded
2116
+
2117
+
2118
+ class QtSidebarRenderer(QtWidgetRenderer[SidebarView, QSidebar]):
2119
+ """A dock on the window's left: its title and a button retracting it, then its views."""
2120
+
2121
+ def __init__(self, view: SidebarView) -> None:
2122
+ super().__init__(view)
2123
+ self._refresh_dispatcher.update(
2124
+ {
2125
+ "title": self._refresh_title,
2126
+ "toggle_text": self._refresh_toggle_text,
2127
+ "is_expanded": self._refresh_is_expanded,
2128
+ }
2129
+ )
2130
+
2131
+ def render(self) -> None:
2132
+ if self._rendered_object is None:
2133
+ self._rendered_object = QSidebar()
2134
+ for view in self._view.children_views:
2135
+ _add_content_widget(self._rendered_object.content_layout, view)
2136
+ self._rendered_object.finish_content()
2137
+ # first, so the view's callbacks see the new state
2138
+ self._rendered_object.expanded_changed.connect(self._update_expanded)
2139
+ self._view.toggled.add_qt_signal(self._rendered_object.expanded_changed)
2140
+ self.refresh()
2141
+
2142
+ @staticmethod
2143
+ def _refresh_title(_view: SidebarView, rendered_object: QSidebar, value: str) -> None:
2144
+ rendered_object.set_title(value)
2145
+
2146
+ @staticmethod
2147
+ def _refresh_toggle_text(_view: SidebarView, rendered_object: QSidebar, value: str) -> None:
2148
+ rendered_object.toggle.setToolTip(plain_tool_tip(value))
2149
+ rendered_object.toggle.setAccessibleName(value)
2150
+
2151
+ @staticmethod
2152
+ def _refresh_is_expanded(_view: SidebarView, rendered_object: QSidebar, value: bool) -> None:
2153
+ rendered_object.set_expanded(value)
2154
+
2155
+ def _update_expanded(self, is_expanded: bool) -> None:
2156
+ with self.stop_refresh():
2157
+ self._view.is_expanded = is_expanded
2158
+
2159
+
2160
+ class QtListRenderer(QtWidgetRenderer["ListView[Any]", QScrollArea]):
2161
+ """The records' views one above the other, added, moved and removed as they change.
2162
+
2163
+ In a scroll area taking the column's spare height (see `_add_content_widget`): the
2164
+ window keeps its height however many records there are.
2165
+ """
2166
+
2167
+ def __init__(self, view: ListView[Any]) -> None:
2168
+ super().__init__(view)
2169
+ self._refresh_dispatcher.update({"item_views": self._refresh_item_views})
2170
+
2171
+ def render(self) -> None:
2172
+ if self._rendered_object is None:
2173
+ self._rendered_object = QScrollArea()
2174
+ self._rendered_object.setWidgetResizable(True)
2175
+ self._rendered_object.setFrameShape(QScrollArea.Shape.NoFrame)
2176
+ column = QWidget()
2177
+ layout = QVBoxLayout(column)
2178
+ # the column holding the list already insets it
2179
+ layout.setContentsMargins(0, 0, 0, 0)
2180
+ # height to spare goes below the views, not between them
2181
+ layout.addStretch()
2182
+ self._rendered_object.setWidget(column)
2183
+ self.refresh()
2184
+
2185
+ @staticmethod
2186
+ def _refresh_item_views(
2187
+ _view: ListView[Any], rendered_object: QScrollArea, value: list[View]
2188
+ ) -> None:
2189
+ widgets: list[QWidget] = []
2190
+ for item_view in value:
2191
+ if item_view.renderer is None:
2192
+ item_view.render(RendererType.QT) # a record's view built since
2193
+ if isinstance(item_view.renderer, QtWidgetRenderer) and isinstance(
2194
+ item_view.renderer.qt_object, QWidget
2195
+ ):
2196
+ widgets.append(item_view.renderer.qt_object)
2197
+ column = rendered_object.widget()
2198
+ layout = None if column is None else column.layout()
2199
+ assert isinstance(layout, QVBoxLayout)
2200
+ # the views, ahead of the stretch under them
2201
+ while layout.count() > 1:
2202
+ item = layout.takeAt(0)
2203
+ widget = None if item is None else item.widget()
2204
+ if widget is not None and widget not in widgets:
2205
+ # its record is gone
2206
+ widget.setParent(None)
2207
+ widget.deleteLater()
2208
+ for index, widget in enumerate(widgets):
2209
+ layout.insertWidget(index, widget)
2210
+
2211
+
2212
+ class QtTreeRenderer(QtWidgetRenderer[TreeView, QTreeWidget]):
2213
+ """A tree widget without a header, each item holding its node's path.
2214
+
2215
+ Its first column shows a node's icon and label, its second the node's mark, hidden
2216
+ while no node has one. The user selects one item at most: a Ctrl+click deselecting
2217
+ it selects it again, as the view always keeps the node the user selected.
2218
+ """
2219
+
2220
+ def __init__(self, view: TreeView) -> None:
2221
+ super().__init__(view)
2222
+ self._refresh_dispatcher.update(
2223
+ {
2224
+ "nodes": self._refresh_nodes,
2225
+ "expanded": self._refresh_expanded,
2226
+ "selected": self._refresh_selected,
2227
+ "done_text": functools.partial(self._refresh_mark_text, NodeMark.DONE),
2228
+ "warning_text": functools.partial(self._refresh_mark_text, NodeMark.WARNING),
2229
+ "error_text": functools.partial(self._refresh_mark_text, NodeMark.ERROR),
2230
+ }
2231
+ )
2232
+
2233
+ def render(self) -> None:
2234
+ if self._rendered_object is None:
2235
+ self._rendered_object = QTreeWidget()
2236
+ self._rendered_object.setHeaderHidden(True)
2237
+ self._rendered_object.setColumnCount(2)
2238
+ header = self._rendered_object.header()
2239
+ assert header is not None
2240
+ # the marks as narrow as their icon, at the right
2241
+ header.setStretchLastSection(False)
2242
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
2243
+ header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
2244
+ self._rendered_object.setSelectionMode(QTreeWidget.SelectionMode.SingleSelection)
2245
+ self._rendered_object.itemSelectionChanged.connect(self._update_selected)
2246
+ self._rendered_object.itemExpanded.connect(
2247
+ lambda item: self._update_expanded(item, is_expanded=True)
2248
+ )
2249
+ self._rendered_object.itemCollapsed.connect(
2250
+ lambda item: self._update_expanded(item, is_expanded=False)
2251
+ )
2252
+ self.refresh()
2253
+
2254
+ @classmethod
2255
+ def _refresh_nodes(
2256
+ cls, view: TreeView, rendered_object: QTreeWidget, value: list[TreeNode]
2257
+ ) -> None:
2258
+ rendered_object.clear()
2259
+
2260
+ def add(
2261
+ parent: QTreeWidget | QTreeWidgetItem, nodes: Iterable[TreeNode], path: TreePath
2262
+ ) -> None:
2263
+ for node in nodes:
2264
+ item = QTreeWidgetItem(parent, [node.label])
2265
+ item.setData(0, Qt.ItemDataRole.UserRole, [*path, node.key])
2266
+ if node.icon is not None:
2267
+ item.setIcon(0, QIcon(node.icon))
2268
+ font = item.font(0)
2269
+ font.setBold(node.is_bold)
2270
+ font.setUnderline(node.is_underlined)
2271
+ item.setFont(0, font)
2272
+ if node.mark is not None:
2273
+ item.setIcon(1, mark_icon(node.mark))
2274
+ item.setToolTip(1, plain_tool_tip(view.mark_text(node.mark)))
2275
+ add(item, node.children, (*path, node.key))
2276
+
2277
+ add(rendered_object, value, ())
2278
+ rendered_object.setColumnHidden(1, all(node.mark is None for _, node in walk_tree(value)))
2279
+ # the view's, already kept to those still in the nodes
2280
+ cls._refresh_expanded(view, rendered_object, view.expanded)
2281
+ cls._refresh_selected(view, rendered_object, view.selected)
2282
+
2283
+ @staticmethod
2284
+ def _refresh_expanded(
2285
+ _view: TreeView, rendered_object: QTreeWidget, value: list[TreePath]
2286
+ ) -> None:
2287
+ expanded = set(value)
2288
+ for path, item in _tree_items(rendered_object):
2289
+ item.setExpanded(path in expanded)
2290
+
2291
+ @staticmethod
2292
+ def _refresh_selected(
2293
+ _view: TreeView, rendered_object: QTreeWidget, value: TreePath | None
2294
+ ) -> None:
2295
+ rendered_object.clearSelection()
2296
+ for path, item in _tree_items(rendered_object):
2297
+ if path == value:
2298
+ rendered_object.setCurrentItem(item)
2299
+ item.setSelected(True)
2300
+
2301
+ @staticmethod
2302
+ def _refresh_mark_text(
2303
+ mark: NodeMark, view: TreeView, rendered_object: QTreeWidget, value: str
2304
+ ) -> None:
2305
+ for path, item in _tree_items(rendered_object):
2306
+ if view.node(path).mark is mark:
2307
+ item.setToolTip(1, plain_tool_tip(value))
2308
+
2309
+ def _update_selected(self) -> None:
2310
+ assert self._rendered_object is not None
2311
+ selected = self._rendered_object.selectedItems()
2312
+ if len(selected) == 0:
2313
+ self.refresh_attribute("selected", self._view.selected)
2314
+ return
2315
+ path = _tree_path(selected[0])
2316
+ if path == self._view.selected:
2317
+ # Qt reports a change only: kept against a second `selection_changed`
2318
+ return # pragma: no cover
2319
+ with self.stop_refresh():
2320
+ self._view.selected = path
2321
+ self._view.selection_changed.emit(path)
2322
+
2323
+ def _update_expanded(self, item: QTreeWidgetItem, *, is_expanded: bool) -> None:
2324
+ path = _tree_path(item)
2325
+ with self.stop_refresh():
2326
+ self._view.expanded = self._view.expanded_with(path, is_expanded=is_expanded)
2327
+ self._view.toggled.emit(path, is_expanded)
2328
+
2329
+
2330
+ def _tree_path(item: QTreeWidgetItem) -> TreePath:
2331
+ return tuple(item.data(0, Qt.ItemDataRole.UserRole))
2332
+
2333
+
2334
+ def _tree_items(tree: QTreeWidget) -> list[tuple[TreePath, QTreeWidgetItem]]:
2335
+ """Get the tree's items with their nodes' paths, depth first."""
2336
+ items: list[tuple[TreePath, QTreeWidgetItem]] = []
2337
+
2338
+ def add(item: QTreeWidgetItem) -> None:
2339
+ items.append((_tree_path(item), item))
2340
+ for index in range(item.childCount()):
2341
+ child = item.child(index)
2342
+ assert child is not None
2343
+ add(child)
2344
+
2345
+ for index in range(tree.topLevelItemCount()):
2346
+ item = tree.topLevelItem(index)
2347
+ assert item is not None
2348
+ add(item)
2349
+ return items
2350
+
2351
+
2352
+ def _tab_of(widget: QWidget) -> tuple[QTabWidget, int] | None:
2353
+ """Get the tab widget showing `widget` as a tab, and its tab's index; None outside one."""
2354
+ parent = widget.parentWidget()
2355
+ while parent is not None and not isinstance(parent, QTabWidget):
2356
+ parent = parent.parentWidget()
2357
+ if parent is None:
2358
+ return None
2359
+ index = parent.indexOf(widget)
2360
+ return None if index < 0 else (parent, index)
2361
+
2362
+
2363
+ class QtTabPageRenderer(QtWidgetRenderer[TabPage, QWidget]):
2364
+ """A tab's views in a column; its title, visibility and enabled state are its tab's."""
2365
+
2366
+ def __init__(self, view: TabPage) -> None:
2367
+ super().__init__(view)
2368
+ self._refresh_dispatcher.update(
2369
+ {
2370
+ "title": self._refresh_title,
2371
+ "is_visible": self._refresh_tab_visible,
2372
+ "is_enabled": self._refresh_tab_enabled,
2373
+ }
2374
+ )
2375
+
2376
+ def render(self) -> None:
2377
+ if self._rendered_object is None:
2378
+ self._rendered_object = QWidget()
2379
+ layout = QVBoxLayout(self._rendered_object)
2380
+ for view in self._view.children_views:
2381
+ _add_content_widget(layout, view)
2382
+ # height to spare goes below the views, not between them
2383
+ layout.addStretch()
2384
+ self.refresh()
2385
+
2386
+ @staticmethod
2387
+ def _refresh_title(_view: TabPage, rendered_object: QWidget, value: str) -> None:
2388
+ tab = _tab_of(rendered_object)
2389
+ if tab is not None:
2390
+ tab[0].setTabText(tab[1], value)
2391
+
2392
+ @staticmethod
2393
+ def _refresh_tab_visible(_view: TabPage, rendered_object: QWidget, value: bool) -> None:
2394
+ # not the widget's own: only the current tab's shows
2395
+ tab = _tab_of(rendered_object)
2396
+ if tab is not None:
2397
+ tab[0].setTabVisible(tab[1], value)
2398
+
2399
+ @staticmethod
2400
+ def _refresh_tab_enabled(_view: TabPage, rendered_object: QWidget, value: bool) -> None:
2401
+ rendered_object.setEnabled(value)
2402
+ tab = _tab_of(rendered_object)
2403
+ if tab is not None:
2404
+ tab[0].setTabEnabled(tab[1], value)
2405
+
2406
+
2407
+ class QtTabsRenderer(QtWidgetRenderer[TabView, QTabWidget]):
2408
+ """A tab widget: the tabs in a row, the current one's views under them."""
2409
+
2410
+ def __init__(self, view: TabView) -> None:
2411
+ super().__init__(view)
2412
+ self._refresh_dispatcher["current_index"] = self._refresh_current_index
2413
+
2414
+ def render(self) -> None:
2415
+ if self._rendered_object is None:
2416
+ self._rendered_object = QTabWidget()
2417
+ for tab in self._view.tabs:
2418
+ assert isinstance(tab.renderer, QtTabPageRenderer)
2419
+ assert tab.renderer.qt_object is not None
2420
+ self._rendered_object.addTab(tab.renderer.qt_object, tab.title)
2421
+ tab.renderer.refresh() # its tab's state, now that it has one
2422
+ # once the tabs are in: adding the first one makes it current
2423
+ self._rendered_object.currentChanged.connect(self._update_current_index)
2424
+ self._view.current_changed.add_qt_signal(self._rendered_object.currentChanged)
2425
+ self.refresh()
2426
+
2427
+ @staticmethod
2428
+ def _refresh_current_index(_view: TabView, rendered_object: QTabWidget, value: int) -> None:
2429
+ rendered_object.setCurrentIndex(value)
2430
+
2431
+ def _update_current_index(self, index: int) -> None:
2432
+ with self.stop_refresh():
2433
+ self._view.current_index = index
2434
+
2435
+
2436
+ class QtGridRenderer(QtWidgetRenderer[GridView, QWidget]):
2437
+ """Views in a grid's cells: equally wide columns, rows as tall as their views."""
2438
+
2439
+ def render(self) -> None:
2440
+ if self._rendered_object is None:
2441
+ self._rendered_object = QWidget()
2442
+ layout = QGridLayout(self._rendered_object)
2443
+ # the column holding the grid already insets it
2444
+ layout.setContentsMargins(0, 0, 0, 0)
2445
+ for view, position in self._view.positioned_views:
2446
+ if isinstance(view.renderer, QtWidgetRenderer) and isinstance(
2447
+ view.renderer.qt_object, QWidget
2448
+ ):
2449
+ # at the top, as in a row; with no share of a row, as wide as its content
2450
+ alignment = Qt.AlignmentFlag.AlignTop
2451
+ if view.default_stretch == 0:
2452
+ alignment |= Qt.AlignmentFlag.AlignLeft
2453
+ layout.addWidget(view.renderer.qt_object, *position, alignment)
2454
+ for column in range(self._view.column_count):
2455
+ layout.setColumnStretch(column, 1)
2456
+ # height to spare, next to a taller view, goes below the rows, not between them
2457
+ layout.setRowStretch(self._view.row_count, 1)
2458
+ self.refresh()
2459
+
2460
+
2461
+ class QtRouterRenderer(QtWidgetRenderer[Router, QStackedWidget]):
2462
+ """The pages, stacked, and a toolbar of their actions that the window docks.
2463
+
2464
+ Each page sits in a slot of its own: the stack shows and hides its slots, which
2465
+ leaves the page's own `is_visible` to the page.
2466
+ """
2467
+
2468
+ def __init__(self, view: Router) -> None:
2469
+ super().__init__(view)
2470
+ #: the toolbar of the pages' actions, docked by the main window
2471
+ self.toolbar: QToolBar | None = None
2472
+ self._slots: dict[str, QWidget] = {}
2473
+ self._refresh_dispatcher.update({"current_route": self._refresh_current_route})
2474
+
2475
+ def render(self) -> None:
2476
+ if self._rendered_object is None:
2477
+ self._rendered_object = QStackedWidget()
2478
+ self.toolbar = QToolBar()
2479
+ self.toolbar.setMovable(False)
2480
+ self.toolbar.toggleViewAction().setVisible(False) # no way to close it
2481
+ for page in self._view.pages:
2482
+ assert isinstance(page.action.renderer, QtActionRenderer)
2483
+ page.action.renderer.set_parent_rendered_object(self.toolbar)
2484
+ page.action.renderer.render()
2485
+ assert page.action.renderer.qt_object is not None
2486
+ self.toolbar.addAction(page.action.renderer.qt_object)
2487
+ slot = QWidget()
2488
+ slot_layout = QVBoxLayout(slot)
2489
+ slot_layout.setContentsMargins(0, 0, 0, 0)
2490
+ assert isinstance(page.renderer, QtPageRenderer)
2491
+ assert page.renderer.qt_object is not None
2492
+ slot_layout.addWidget(page.renderer.qt_object)
2493
+ self._slots[page.route] = slot
2494
+ self._rendered_object.addWidget(slot)
2495
+ self.refresh()
2496
+
2497
+ def _refresh_current_route(
2498
+ self, _view: Router, rendered_object: QStackedWidget, value: str
2499
+ ) -> None:
2500
+ slot = self._slots.get(value)
2501
+ if slot is not None:
2502
+ rendered_object.setCurrentWidget(slot)
2503
+
2504
+
2505
+ class QtMainRenderer(QtWidgetRenderer[MainView, QMainWindow], MainRenderer[MainView, QMainWindow]):
2506
+ def __init__(self, view: MainView, window: QMainWindow | None = None) -> None:
2507
+ super().__init__(view)
2508
+ #: another main view's window to show this one in, in place of its parts, e.g.
2509
+ #: the previous code's on a reload: the window keeps its place and size
2510
+ self._window = window
2511
+ self._refresh_dispatcher.update(
2512
+ {
2513
+ "title": self._refresh_title,
2514
+ "state_filepath": self._refresh_state_filepath,
2515
+ "minimum_width": self._refresh_minimum_width,
2516
+ "minimum_height": self._refresh_minimum_height,
2517
+ }
2518
+ )
2519
+
2520
+ def render(self) -> None:
2521
+ if self._rendered_object is None:
2522
+ if self._window is None:
2523
+ self._rendered_object = QMainWindow()
2524
+ else:
2525
+ self._rendered_object = self._window
2526
+ self._clear_window(self._window)
2527
+ self._rendered_object.setMinimumSize(
2528
+ self._view.minimum_width, self._view.minimum_height
2529
+ )
2530
+ if self._view.app_icon is not None:
2531
+ self._rendered_object.setWindowIcon(QIcon(str(self._view.app_icon)))
2532
+ self._add_child_widgets(self._view, self._rendered_object)
2533
+ self.refresh()
2534
+
2535
+ def open_file(self) -> Path | None:
2536
+ """Return an existing file selected by the user."""
2537
+ assert self._rendered_object is not None
2538
+ filepath_str, selected_filter = QFileDialog.getOpenFileName(
2539
+ self._rendered_object, "Open File", "", self._build_extensions()
2540
+ )
2541
+ return self._build_filepath(filepath_str, selected_filter)
2542
+
2543
+ def save_file(self) -> Path | None:
2544
+ assert self._rendered_object is not None
2545
+ filepath_str, selected_filter = QFileDialog.getSaveFileName(
2546
+ self._rendered_object, "Save File", "", self._build_extensions()
2547
+ )
2548
+ return self._build_filepath(filepath_str, selected_filter)
2549
+
2550
+ def close(self) -> None:
2551
+ assert self._rendered_object is not None
2552
+ self._rendered_object.close()
2553
+
2554
+ def _build_filepath(self, filepath_str: str, selected_filter: str) -> Path | None:
2555
+ if filepath_str == "":
2556
+ return None
2557
+ name_extension = selected_filter.split("(", maxsplit=1)[0].strip()
2558
+ filepath = Path(filepath_str)
2559
+ extensions = self._view.file_view.extensions.get(name_extension)
2560
+ if extensions is not None and all(
2561
+ not self._has_extension(filepath, extension) for extension in extensions
2562
+ ):
2563
+ filepath = Path(filepath_str + extensions[0])
2564
+ return filepath
2565
+
2566
+ @staticmethod
2567
+ def _has_extension(filepath: Path, extension: str) -> bool:
2568
+ parts = [f".{e}" for e in extension.split(".") if e != ""]
2569
+ if len(parts) > 0:
2570
+ return filepath.suffixes[-len(parts) :] == parts
2571
+ return False
2572
+
2573
+ def _build_extensions(self) -> str:
2574
+ return _file_filters(self._view.file_view.extensions)
2575
+
2576
+ @staticmethod
2577
+ def _clear_window(window: QMainWindow) -> None:
2578
+ """Remove what another main view set on `window`: its bars, menus, sidebar and icon.
2579
+
2580
+ Its content goes once replaced (`setCentralWidget`).
2581
+ """
2582
+ window.setWindowIcon(QIcon()) # set again only if this main view has one
2583
+ # the previous bars are deleted
2584
+ window.setMenuBar(QMenuBar())
2585
+ window.setStatusBar(None) # type: ignore[arg-type] # Qt takes none: it removes the bar
2586
+ for child in window.findChildren(QWidget, "", Qt.FindChildOption.FindDirectChildrenOnly):
2587
+ if isinstance(child, QToolBar):
2588
+ window.removeToolBar(child)
2589
+ elif isinstance(child, QDockWidget):
2590
+ window.removeDockWidget(child)
2591
+ elif not isinstance(child, QMenu):
2592
+ continue
2593
+ child.deleteLater()
2594
+
2595
+ @staticmethod
2596
+ def _add_child_widgets(view: MainView, rendered_object: QMainWindow) -> None:
2597
+ central_widget = QWidget()
2598
+ central_layout = QVBoxLayout()
2599
+ central_widget.setLayout(central_layout)
2600
+ for child_view in view.children_views:
2601
+ if QtMainRenderer._add_window_part(child_view, rendered_object):
2602
+ continue
2603
+ if isinstance(child_view.renderer, QtRouterRenderer):
2604
+ toolbar = child_view.renderer.toolbar
2605
+ stack = child_view.renderer.qt_object
2606
+ assert toolbar is not None
2607
+ assert stack is not None
2608
+ rendered_object.addToolBar(toolbar)
2609
+ central_layout.addWidget(stack)
2610
+ else:
2611
+ _add_content_widget(central_layout, child_view)
2612
+ central_layout.addStretch()
2613
+ central_layout.update()
2614
+ rendered_object.setCentralWidget(central_widget)
2615
+
2616
+ @staticmethod
2617
+ def _add_window_part(child_view: View, rendered_object: QMainWindow) -> bool:
2618
+ """Add a part of the window around the content, if `child_view` is one.
2619
+
2620
+ Its menus, status bar, toolbars and sidebar; return whether `child_view` is one.
2621
+ """
2622
+ renderer = child_view.renderer
2623
+ if isinstance(renderer, QtMenuRenderer):
2624
+ renderer.set_parent_rendered_object(rendered_object)
2625
+ renderer.render()
2626
+ assert renderer.qt_object is not None
2627
+ rendered_object.menuBar().addMenu(renderer.qt_object)
2628
+ elif isinstance(renderer, QtStatusBarRenderer):
2629
+ assert isinstance(renderer.qt_object, QStatusBar)
2630
+ rendered_object.setStatusBar(renderer.qt_object)
2631
+ elif isinstance(renderer, QtToolBarRenderer):
2632
+ assert renderer.qt_object is not None
2633
+ rendered_object.addToolBar(renderer.qt_object)
2634
+ elif isinstance(renderer, QtSidebarRenderer):
2635
+ assert renderer.qt_object is not None
2636
+ rendered_object.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, renderer.qt_object)
2637
+ else:
2638
+ return False
2639
+ return True
2640
+
2641
+ @staticmethod
2642
+ def _refresh_title(_view: MainView, rendered_object: QMainWindow, value: str) -> None:
2643
+ rendered_object.setWindowTitle(value)
2644
+
2645
+ @staticmethod
2646
+ def _refresh_state_filepath(
2647
+ _view: MainView, rendered_object: QMainWindow, value: Path
2648
+ ) -> None: ...
2649
+
2650
+ @staticmethod
2651
+ def _refresh_minimum_width(_view: MainView, rendered_object: QMainWindow, value: int) -> None:
2652
+ rendered_object.setMinimumWidth(value)
2653
+
2654
+ @staticmethod
2655
+ def _refresh_minimum_height(_view: MainView, rendered_object: QMainWindow, value: int) -> None:
2656
+ rendered_object.setMinimumHeight(value)
2657
+
2658
+
2659
+ class QtStatusBarRenderer(QtWidgetRenderer[StatusBar, QStatusBar]):
2660
+ def __init__(self, view: StatusBar) -> None:
2661
+ super().__init__(view)
2662
+ self._refresh_dispatcher.update(
2663
+ {
2664
+ "message": self._refresh_message,
2665
+ }
2666
+ )
2667
+
2668
+ def render(self) -> None:
2669
+ if self._rendered_object is None:
2670
+ self._rendered_object = QStatusBar()
2671
+ self._view.message_changed.add_qt_signal(self._rendered_object.messageChanged)
2672
+ self.refresh()
2673
+
2674
+ @staticmethod
2675
+ def _refresh_message(_view: StatusBar, rendered_object: QStatusBar, message: str) -> None:
2676
+ rendered_object.showMessage(message)
2677
+
2678
+
2679
+ class QtActionRenderer(QtWidgetRenderer[ActionView, QAction]):
2680
+ def __init__(self, view: ActionView) -> None:
2681
+ super().__init__(view)
2682
+ self._parent_rendered_object: QWidget | None = None
2683
+ self._refresh_dispatcher.update(
2684
+ {
2685
+ "text": self._refresh_text,
2686
+ "icon": self._refresh_icon,
2687
+ "data": self._refresh_data,
2688
+ "shortcut": self._refresh_shortcut,
2689
+ "is_checkable": self._refresh_is_checkable,
2690
+ "is_checked": self._refresh_is_checked,
2691
+ }
2692
+ )
2693
+ if isinstance(view, PickFileAction):
2694
+ # read by the picker each time it opens
2695
+ self._refresh_dispatcher["extensions"] = lambda _view, _action, _value: None
2696
+
2697
+ def set_parent_rendered_object(self, parent: QWidget) -> None:
2698
+ self._parent_rendered_object = parent
2699
+
2700
+ def render(self) -> None:
2701
+ if self._rendered_object is None and self._parent_rendered_object:
2702
+ self._rendered_object = QAction(self._parent_rendered_object)
2703
+ self._view.triggered.add_qt_signal(self._rendered_object.triggered)
2704
+ self.refresh()
2705
+
2706
+ def refresh_attribute(self, attr_name: str, value: Any) -> None:
2707
+ # no `QAction` without a parent to show it, e.g. the entry of a page outside a
2708
+ # router: nothing to update, and `render` shows the view as it is then
2709
+ if self._rendered_object is not None:
2710
+ super().refresh_attribute(attr_name, value)
2711
+
2712
+ @staticmethod
2713
+ def _refresh_text(_view: ActionView, rendered_object: QAction, text: str) -> None:
2714
+ rendered_object.setText(text)
2715
+ rendered_object.setToolTip("") # back to Qt's own, the text without its "&"
2716
+ rendered_object.setToolTip(plain_tool_tip(rendered_object.toolTip()))
2717
+
2718
+ @staticmethod
2719
+ def _refresh_icon(_view: ActionView, rendered_object: QAction, icon: str | None) -> None:
2720
+ if icon is None:
2721
+ rendered_object.setIcon(QIcon())
2722
+ else:
2723
+ rendered_object.setIcon(QIcon(icon))
2724
+
2725
+ @staticmethod
2726
+ def _refresh_data(_view: ActionView, rendered_object: QAction, data: Any) -> None:
2727
+ rendered_object.setData(data)
2728
+
2729
+ @staticmethod
2730
+ def _refresh_shortcut(
2731
+ _view: ActionView, rendered_object: QAction, shortcut: str | None
2732
+ ) -> None:
2733
+ if shortcut is None:
2734
+ rendered_object.setShortcut(QKeySequence())
2735
+ else:
2736
+ rendered_object.setShortcut(QKeySequence(shortcut))
2737
+
2738
+ @staticmethod
2739
+ def _refresh_is_checkable(
2740
+ _view: ActionView, rendered_object: QAction, is_checkable: bool
2741
+ ) -> None:
2742
+ rendered_object.setCheckable(is_checkable)
2743
+
2744
+ @staticmethod
2745
+ def _refresh_is_checked(_view: ActionView, rendered_object: QAction, is_checked: bool) -> None:
2746
+ rendered_object.setChecked(is_checked)
2747
+
2748
+
2749
+ class QtSeparatorRenderer(QtWidgetRenderer[SeparatorAction, QAction]):
2750
+ def __init__(self, view: SeparatorAction) -> None:
2751
+ super().__init__(view)
2752
+ self._parent_rendered_object: QWidget | None = None
2753
+
2754
+ def set_parent_rendered_object(self, parent: QWidget) -> None:
2755
+ self._parent_rendered_object = parent
2756
+
2757
+ def render(self) -> None:
2758
+ if self._rendered_object is None and self._parent_rendered_object is not None:
2759
+ self._rendered_object = QAction(self._parent_rendered_object)
2760
+ self._rendered_object.setSeparator(True)
2761
+ self.refresh()
2762
+
2763
+
2764
+ class QtMenuRenderer(QtWidgetRenderer[Menu, QMenu]):
2765
+ def __init__(self, view: Menu) -> None:
2766
+ super().__init__(view)
2767
+ self._refresh_dispatcher.update({"title": self._refresh_title})
2768
+ self._parent_rendered_object: QWidget | None = None
2769
+
2770
+ def set_parent_rendered_object(self, parent: QWidget) -> None:
2771
+ self._parent_rendered_object = parent
2772
+
2773
+ def render(self) -> None:
2774
+ if self._rendered_object is None and self._parent_rendered_object is not None:
2775
+ self._rendered_object = QMenu(self._parent_rendered_object)
2776
+ for child_view in self._view.children_views:
2777
+ if isinstance(child_view, (ActionView, SeparatorAction)) and isinstance(
2778
+ child_view.renderer, (QtActionRenderer, QtSeparatorRenderer)
2779
+ ):
2780
+ child_view.renderer.set_parent_rendered_object(self._rendered_object)
2781
+ child_view.renderer.render()
2782
+ action = child_view.renderer.qt_object
2783
+ assert action is not None
2784
+ self._rendered_object.addAction(action)
2785
+ self.refresh()
2786
+
2787
+ @staticmethod
2788
+ def _refresh_title(_view: Menu, rendered_object: QMenu, title: str) -> None:
2789
+ rendered_object.setTitle(title)
2790
+
2791
+
2792
+ class QtToolBarRenderer(QtWidgetRenderer[ToolBar, QToolBar]):
2793
+ """A toolbar of its actions, docked at the top by the main window."""
2794
+
2795
+ def __init__(self, view: ToolBar) -> None:
2796
+ super().__init__(view)
2797
+ self._refresh_dispatcher.update({"title": self._refresh_title})
2798
+
2799
+ def render(self) -> None:
2800
+ if self._rendered_object is None:
2801
+ self._rendered_object = QToolBar()
2802
+ self._rendered_object.setMovable(False)
2803
+ self._rendered_object.toggleViewAction().setVisible(False) # no way to close it
2804
+ for child_view in self._view.children_views:
2805
+ if isinstance(child_view.renderer, (QtActionRenderer, QtSeparatorRenderer)):
2806
+ child_view.renderer.set_parent_rendered_object(self._rendered_object)
2807
+ child_view.renderer.render()
2808
+ action = child_view.renderer.qt_object
2809
+ assert action is not None
2810
+ self._rendered_object.addAction(action)
2811
+ self.refresh()
2812
+
2813
+ @staticmethod
2814
+ def _refresh_title(_view: ToolBar, rendered_object: QToolBar, title: str) -> None:
2815
+ rendered_object.setWindowTitle(title)
2816
+
2817
+
2818
+ #: renderer keys of the Qt backend, looked up by `composeui.core.registry.create_renderer`
2819
+ RENDERERS: dict[str, Callable[[Any], Renderer[Any, Any]]] = {
2820
+ "action": QtActionRenderer,
2821
+ "button": QtButtonRenderer,
2822
+ "chart": QtChartRenderer,
2823
+ "map": _map_renderer,
2824
+ "checkbox": QtCheckBoxRenderer,
2825
+ "combobox": QtComboBoxRenderer,
2826
+ "source_table": QtSourceTableRenderer,
2827
+ "date_input": QtDateInputRenderer,
2828
+ "file_path_input": QtFilePathInputRenderer,
2829
+ "dialog": QtDialogRenderer,
2830
+ "expander": QtExpanderRenderer,
2831
+ "float_input": QtFloatInputRenderer,
2832
+ "int_input": QtIntInputRenderer,
2833
+ "form": QtFormRenderer,
2834
+ "form_dialog": QtFormDialogRenderer,
2835
+ "form_label": QtFormLabelRenderer,
2836
+ "grid": QtGridRenderer,
2837
+ "group_form": QtGroupFormRenderer,
2838
+ "horizontal": QtHorizontalRenderer,
2839
+ "input": QtInputRenderer,
2840
+ "label": QtLabelRenderer,
2841
+ "list": QtListRenderer,
2842
+ "log": QtLogRenderer,
2843
+ "main": QtMainRenderer,
2844
+ "markdown": QtMarkdownRenderer,
2845
+ "menu": QtMenuRenderer,
2846
+ "metric": QtMetricRenderer,
2847
+ "page": QtPageRenderer,
2848
+ "progress_bar": QtProgressBarRenderer,
2849
+ "slider": QtSliderRenderer,
2850
+ "radio_group": QtRadioGroupRenderer,
2851
+ "router": QtRouterRenderer,
2852
+ "separator": QtSeparatorRenderer,
2853
+ "sidebar": QtSidebarRenderer,
2854
+ "splitter": QtSplitterRenderer,
2855
+ "status_bar": QtStatusBarRenderer,
2856
+ "table": QtTableRenderer,
2857
+ "tab_page": QtTabPageRenderer,
2858
+ "tabs": QtTabsRenderer,
2859
+ "toolbar": QtToolBarRenderer,
2860
+ "tree": QtTreeRenderer,
2861
+ "vertical": QtVerticalRenderer,
2862
+ }