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.
- composeui/__init__.py +11 -0
- composeui/core/__init__.py +0 -0
- composeui/core/application.py +368 -0
- composeui/core/chartcolors.py +23 -0
- composeui/core/chartimage.py +336 -0
- composeui/core/dataframesource.py +307 -0
- composeui/core/jobs.py +426 -0
- composeui/core/model.py +66 -0
- composeui/core/registry.py +66 -0
- composeui/core/reload.py +476 -0
- composeui/core/renderer.py +193 -0
- composeui/core/signal.py +358 -0
- composeui/core/sqlitemodel.py +1183 -0
- composeui/core/study.py +121 -0
- composeui/core/translation.py +221 -0
- composeui/core/translationcheck.py +254 -0
- composeui/core/view.py +5508 -0
- composeui/images/composeui.svg +15 -0
- composeui/locales/fr.json +73 -0
- composeui/mypy.py +46 -0
- composeui/py.typed +0 -0
- composeui/qtcore/__init__.py +0 -0
- composeui/qtcore/maprenderer.py +115 -0
- composeui/qtcore/qtapplication.py +222 -0
- composeui/qtcore/qtrenderer.py +2862 -0
- composeui/qtcore/widgets/__init__.py +0 -0
- composeui/qtcore/widgets/map.qml +123 -0
- composeui/qtcore/widgets/qchart.py +393 -0
- composeui/qtcore/widgets/qdatefield.py +101 -0
- composeui/qtcore/widgets/qexpander.py +60 -0
- composeui/qtcore/widgets/qfilepathfield.py +34 -0
- composeui/qtcore/widgets/qfiltertable.py +394 -0
- composeui/qtcore/widgets/qformdialog.py +64 -0
- composeui/qtcore/widgets/qformlabel.py +53 -0
- composeui/qtcore/widgets/qmap.py +68 -0
- composeui/qtcore/widgets/qmarkicon.py +37 -0
- composeui/qtcore/widgets/qmetric.py +44 -0
- composeui/qtcore/widgets/qplaintext.py +29 -0
- composeui/qtcore/widgets/qradiogroup.py +57 -0
- composeui/qtcore/widgets/qsidebar.py +116 -0
- composeui/qtcore/widgets/qsourcetable.py +216 -0
- composeui/qtcore/widgets/qsplitter.py +69 -0
- composeui/qtcore/widgets/qtexttable.py +703 -0
- composeui/qtcore/widgets/qvaluecombobox.py +41 -0
- composeui/qtcore/widgets/qvalueslider.py +64 -0
- composeui/qtcore/widgets/qwindowrelease.py +32 -0
- composeui/textualcore/__init__.py +0 -0
- composeui/textualcore/textualapplication.py +121 -0
- composeui/textualcore/textualrenderer.py +2893 -0
- composeui/textualcore/widgets/__init__.py +0 -0
- composeui/textualcore/widgets/composebutton.py +17 -0
- composeui/textualcore/widgets/composechart.py +212 -0
- composeui/textualcore/widgets/composecheckbox.py +35 -0
- composeui/textualcore/widgets/composecollapsible.py +41 -0
- composeui/textualcore/widgets/composecombobox.py +54 -0
- composeui/textualcore/widgets/composedateinput.py +325 -0
- composeui/textualcore/widgets/composedialog.py +90 -0
- composeui/textualcore/widgets/composefilepathinput.py +85 -0
- composeui/textualcore/widgets/composefiltertable.py +367 -0
- composeui/textualcore/widgets/composeformdialog.py +86 -0
- composeui/textualcore/widgets/composeinput.py +50 -0
- composeui/textualcore/widgets/composelist.py +20 -0
- composeui/textualcore/widgets/composemarkdown.py +17 -0
- composeui/textualcore/widgets/composemetric.py +60 -0
- composeui/textualcore/widgets/composeradioset.py +54 -0
- composeui/textualcore/widgets/composesidebar.py +111 -0
- composeui/textualcore/widgets/composeslider.py +144 -0
- composeui/textualcore/widgets/composesourcetable.py +288 -0
- composeui/textualcore/widgets/composesplitter.py +211 -0
- composeui/textualcore/widgets/composetabbedcontent.py +35 -0
- composeui/textualcore/widgets/composetable.py +476 -0
- composeui/textualcore/widgets/composetoolbar.py +62 -0
- composeui/textualcore/widgets/composetree.py +139 -0
- composeui/webcore/__init__.py +0 -0
- composeui/webcore/headers.py +57 -0
- composeui/webcore/icons.py +58 -0
- composeui/webcore/limits.py +55 -0
- composeui/webcore/origin.py +63 -0
- composeui/webcore/page.py +146 -0
- composeui/webcore/routes.py +303 -0
- composeui/webcore/session.py +436 -0
- composeui/webcore/static/app.css +2 -0
- composeui/webcore/static/cally.js +1312 -0
- composeui/webcore/static/daisyui.LICENSE +21 -0
- composeui/webcore/static/deckgl.LICENSE +19 -0
- composeui/webcore/static/deckgl.min.js +6423 -0
- composeui/webcore/static/htmx.min.js +1 -0
- composeui/webcore/static/page.js +1175 -0
- composeui/webcore/static/uPlot.LICENSE +21 -0
- composeui/webcore/static/uPlot.iife.min.js +2 -0
- composeui/webcore/webapplication.py +495 -0
- composeui/webcore/webrenderer.py +5113 -0
- composeui-0.1.0.dist-info/METADATA +222 -0
- composeui-0.1.0.dist-info/RECORD +97 -0
- composeui-0.1.0.dist-info/WHEEL +5 -0
- composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
- 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
|
+
}
|