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,2893 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import dataclasses
|
|
5
|
+
import functools
|
|
6
|
+
from collections import deque
|
|
7
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.containers import Container, Grid, Horizontal, Vertical, VerticalScroll
|
|
11
|
+
from textual.content import Content
|
|
12
|
+
from textual.types import NoActiveAppError
|
|
13
|
+
from textual.validation import Integer, Number
|
|
14
|
+
from textual.widgets import ContentSwitcher, Footer, Label, ProgressBar, Static, TabPane
|
|
15
|
+
from textual_fspicker import FileOpen, FileSave
|
|
16
|
+
from textual_fspicker.path_filters import Filter, Filters
|
|
17
|
+
from typing_extensions import Protocol
|
|
18
|
+
|
|
19
|
+
from composeui.core import chartimage
|
|
20
|
+
from composeui.core.registry import RendererType
|
|
21
|
+
from composeui.core.renderer import (
|
|
22
|
+
MainRenderer,
|
|
23
|
+
Renderer,
|
|
24
|
+
RendererContainer,
|
|
25
|
+
ViewLike,
|
|
26
|
+
)
|
|
27
|
+
from composeui.core.view import (
|
|
28
|
+
ActionView,
|
|
29
|
+
Align,
|
|
30
|
+
ButtonVariant,
|
|
31
|
+
CellKind,
|
|
32
|
+
ChartKind,
|
|
33
|
+
ChartView,
|
|
34
|
+
DialogKind,
|
|
35
|
+
DialogView,
|
|
36
|
+
ExpanderView,
|
|
37
|
+
FormLabelRowView,
|
|
38
|
+
FormRowView,
|
|
39
|
+
FormView,
|
|
40
|
+
GridPosition,
|
|
41
|
+
GridView,
|
|
42
|
+
GroupFormView,
|
|
43
|
+
HorizontalView,
|
|
44
|
+
ListView,
|
|
45
|
+
LogType,
|
|
46
|
+
MainView,
|
|
47
|
+
Menu,
|
|
48
|
+
MetricView,
|
|
49
|
+
OpenFileAction,
|
|
50
|
+
Orientation,
|
|
51
|
+
PageView,
|
|
52
|
+
PickFileAction,
|
|
53
|
+
ProgressBarView,
|
|
54
|
+
Router,
|
|
55
|
+
SaveAction,
|
|
56
|
+
SaveAsAction,
|
|
57
|
+
SelectionMode,
|
|
58
|
+
SeparatorAction,
|
|
59
|
+
SidebarView,
|
|
60
|
+
SliderMixin,
|
|
61
|
+
SourceTableByPosition,
|
|
62
|
+
SplitterView,
|
|
63
|
+
StatusBar,
|
|
64
|
+
TabPage,
|
|
65
|
+
TabView,
|
|
66
|
+
ToolBar,
|
|
67
|
+
TreeView,
|
|
68
|
+
TypedFormDialogView,
|
|
69
|
+
VerticalView,
|
|
70
|
+
View,
|
|
71
|
+
date_hint,
|
|
72
|
+
replace_cell,
|
|
73
|
+
)
|
|
74
|
+
from composeui.textualcore.widgets.composebutton import ComposeButton
|
|
75
|
+
from composeui.textualcore.widgets.composechart import ComposeChart
|
|
76
|
+
from composeui.textualcore.widgets.composecheckbox import ComposeCheckBox
|
|
77
|
+
from composeui.textualcore.widgets.composecollapsible import ComposeCollapsible
|
|
78
|
+
from composeui.textualcore.widgets.composecombobox import ComposeComboBox
|
|
79
|
+
from composeui.textualcore.widgets.composedateinput import ComposeDateInput
|
|
80
|
+
from composeui.textualcore.widgets.composedialog import ComposeDialog
|
|
81
|
+
from composeui.textualcore.widgets.composefilepathinput import ComposeFilePathInput
|
|
82
|
+
from composeui.textualcore.widgets.composefiltertable import ComposeFilterTable
|
|
83
|
+
from composeui.textualcore.widgets.composeformdialog import ComposeFormDialog
|
|
84
|
+
from composeui.textualcore.widgets.composeinput import ComposeInput
|
|
85
|
+
from composeui.textualcore.widgets.composelist import ComposeList
|
|
86
|
+
from composeui.textualcore.widgets.composemarkdown import ComposeMarkdown
|
|
87
|
+
from composeui.textualcore.widgets.composemetric import ComposeMetric
|
|
88
|
+
from composeui.textualcore.widgets.composeradioset import ComposeRadioSet
|
|
89
|
+
from composeui.textualcore.widgets.composesidebar import ComposeSidebar
|
|
90
|
+
from composeui.textualcore.widgets.composeslider import ComposeSlider
|
|
91
|
+
from composeui.textualcore.widgets.composesourcetable import ComposeDataFilterTable
|
|
92
|
+
from composeui.textualcore.widgets.composesplitter import ComposeSplitter
|
|
93
|
+
from composeui.textualcore.widgets.composetabbedcontent import ComposeTabbedContent
|
|
94
|
+
from composeui.textualcore.widgets.composetoolbar import (
|
|
95
|
+
ComposeToolbar,
|
|
96
|
+
ComposeToolbarButton,
|
|
97
|
+
ComposeToolbars,
|
|
98
|
+
ComposeToolbarSeparator,
|
|
99
|
+
)
|
|
100
|
+
from composeui.textualcore.widgets.composetree import ComposeTree
|
|
101
|
+
|
|
102
|
+
if TYPE_CHECKING:
|
|
103
|
+
import datetime
|
|
104
|
+
from collections.abc import Callable
|
|
105
|
+
from pathlib import Path
|
|
106
|
+
|
|
107
|
+
from textual.widget import AwaitMount, Widget
|
|
108
|
+
from textual.widgets.button import ButtonVariant as ButtonVariantName
|
|
109
|
+
|
|
110
|
+
from composeui.core.signal import Signal, SignalNoArgs
|
|
111
|
+
from composeui.core.view import (
|
|
112
|
+
CellStyle,
|
|
113
|
+
CellWidget,
|
|
114
|
+
HeaderGroup,
|
|
115
|
+
NodeMark,
|
|
116
|
+
ReferenceLine,
|
|
117
|
+
Series,
|
|
118
|
+
TreeNode,
|
|
119
|
+
TreePath,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
TView = TypeVar("TView", bound=ViewLike)
|
|
123
|
+
TWidget = TypeVar("TWidget", bound="Widget")
|
|
124
|
+
TValue = TypeVar("TValue")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _has_any_extension(filepath: Path, extensions: tuple[str, ...]) -> bool:
|
|
128
|
+
return any(filepath.name.endswith(extension) for extension in extensions)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _app_of(widget: Widget) -> App[Any] | None:
|
|
132
|
+
"""Get the app running `widget`, or None before it is mounted."""
|
|
133
|
+
try:
|
|
134
|
+
return widget.app
|
|
135
|
+
except NoActiveAppError:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _call_on_app_thread(app: App[Any] | None, callback: Callable[[], None]) -> None:
|
|
140
|
+
"""Marshal `callback` onto the thread running `app`'s event loop.
|
|
141
|
+
|
|
142
|
+
`App.call_from_thread` raises `RuntimeError` both when the app isn't running yet
|
|
143
|
+
and when it's called from the app's own thread (nothing to marshal in either
|
|
144
|
+
case) - both fall back to calling directly, matching `Renderer`'s default.
|
|
145
|
+
"""
|
|
146
|
+
if app is None:
|
|
147
|
+
callback()
|
|
148
|
+
return
|
|
149
|
+
try:
|
|
150
|
+
app.call_from_thread(callback)
|
|
151
|
+
except RuntimeError:
|
|
152
|
+
callback()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
#: the class a row's field widget has while the row is invalid (`FormRowView.is_invalid`),
|
|
156
|
+
#: styled by the composeui widgets. Not Textual's own `-invalid`: `Input` resets that one
|
|
157
|
+
#: from its validators on every keystroke
|
|
158
|
+
ROW_INVALID_CLASS = "-row-invalid"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TextualWidgetRenderer(Renderer[TView, TWidget]):
|
|
162
|
+
#: whether the widget is a form row's field, showing the row as invalid
|
|
163
|
+
_marks_invalid: ClassVar[bool] = False
|
|
164
|
+
|
|
165
|
+
def __init__(self, view: TView) -> None:
|
|
166
|
+
super().__init__(view)
|
|
167
|
+
self._refresh_dispatcher.update(
|
|
168
|
+
{
|
|
169
|
+
"is_visible": self._refresh_visible,
|
|
170
|
+
"is_enabled": self._refresh_enabled,
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
if self._marks_invalid and isinstance(view, FormRowView):
|
|
174
|
+
# only a row has these: the same renderer also draws a standalone field
|
|
175
|
+
self._refresh_dispatcher.update(
|
|
176
|
+
{
|
|
177
|
+
name: self._invalid_refresher(name)
|
|
178
|
+
for name in ("log_message", "log_type", "has_form_error")
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def widget(self) -> TWidget | None:
|
|
184
|
+
return self._rendered_object
|
|
185
|
+
|
|
186
|
+
def call_on_main_thread(self, callback: Callable[[], None]) -> None:
|
|
187
|
+
_call_on_app_thread(self._get_app(), callback)
|
|
188
|
+
|
|
189
|
+
def _get_app(self) -> App[Any] | None:
|
|
190
|
+
if self._rendered_object is None:
|
|
191
|
+
return None
|
|
192
|
+
return _app_of(self._rendered_object)
|
|
193
|
+
|
|
194
|
+
def refresh(self) -> None:
|
|
195
|
+
# Textual widgets aren't thread-safe (see `App.call_from_thread`'s own
|
|
196
|
+
# docstring) - state can legitimately change from a non-main thread (e.g.
|
|
197
|
+
# `MainView.load_state` running as part of a worker-dispatched file-open
|
|
198
|
+
# action, see `_ComposeUIApp.action_trigger`). `call_on_main_thread` already
|
|
199
|
+
# falls back to a direct call when we're already on the right thread, so this
|
|
200
|
+
# is a no-op in the common case.
|
|
201
|
+
self.call_on_main_thread(self._refresh_if_mounted)
|
|
202
|
+
|
|
203
|
+
def refresh_attribute(self, attr_name: str, value: Any) -> None:
|
|
204
|
+
self.call_on_main_thread(
|
|
205
|
+
functools.partial(self._refresh_attribute_if_mounted, attr_name, value)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _is_waiting_to_mount(self) -> bool:
|
|
209
|
+
"""Tell whether the app runs but hasn't mounted the widget yet, e.g. on a screen not shown.
|
|
210
|
+
|
|
211
|
+
Some widgets refuse their state until mounted (a `Select` its options); whatever
|
|
212
|
+
mounts them later gives them their state then: the app's own mount, a list's new
|
|
213
|
+
item, a form dialog's screen.
|
|
214
|
+
"""
|
|
215
|
+
widget = self._rendered_object
|
|
216
|
+
if widget is None or widget.is_attached:
|
|
217
|
+
return False
|
|
218
|
+
app = _app_of(widget)
|
|
219
|
+
return app is not None and app.is_running
|
|
220
|
+
|
|
221
|
+
def _refresh_if_mounted(self) -> None:
|
|
222
|
+
if not self._is_waiting_to_mount():
|
|
223
|
+
super().refresh()
|
|
224
|
+
|
|
225
|
+
def _refresh_attribute_if_mounted(self, attr_name: str, value: Any) -> None:
|
|
226
|
+
if not self._is_waiting_to_mount():
|
|
227
|
+
super().refresh_attribute(attr_name, value)
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _refresh_visible(_view: TView, rendered_object: TWidget, value: bool) -> None:
|
|
231
|
+
rendered_object.display = value
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _refresh_enabled(_view: TView, rendered_object: TWidget, value: bool) -> None:
|
|
235
|
+
rendered_object.disabled = not value
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _invalid_refresher(name: str) -> Callable[[TView, TWidget, Any], None]:
|
|
239
|
+
"""Mark the field once the row's attribute `name` changes to the given value."""
|
|
240
|
+
|
|
241
|
+
def refresh(view: TView, rendered_object: TWidget, value: Any) -> None:
|
|
242
|
+
is_invalid = isinstance(view, FormRowView) and view.is_invalid_once(name, value)
|
|
243
|
+
rendered_object.set_class(is_invalid, ROW_INVALID_CLASS)
|
|
244
|
+
|
|
245
|
+
return refresh
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
TNoopView = TypeVar("TNoopView", bound=ViewLike)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _TextualNoopRenderer(Renderer[TNoopView, TNoopView]):
|
|
252
|
+
"""A renderer with no widget of its own - just a placeholder in the view tree.
|
|
253
|
+
|
|
254
|
+
Used for `Menu`/`ActionView`/`SeparatorAction`, which don't render as widgets under
|
|
255
|
+
Textual (see `_ComposeUIApp`/`TextualMainRenderer.open_file`). Still needs
|
|
256
|
+
`is_visible`/`is_enabled` dispatch entries (even as no-ops) because `Menu.
|
|
257
|
+
__attrs_post_init__` sets `is_visible = False` and `notify_renderer` would otherwise
|
|
258
|
+
raise `MissingRefreshAttributeFunctionError` the first time either is set again.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def __init__(self, view: TNoopView) -> None:
|
|
262
|
+
super().__init__(view)
|
|
263
|
+
self._refresh_dispatcher.update(
|
|
264
|
+
{
|
|
265
|
+
"is_visible": self._noop,
|
|
266
|
+
"is_enabled": self._noop,
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def render(self) -> None:
|
|
271
|
+
if self._rendered_object is None:
|
|
272
|
+
self._rendered_object = self._view
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _noop(_view: TNoopView, _rendered_object: TNoopView, _value: Any) -> None:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class TextualActionRenderer(_TextualNoopRenderer[ActionView]):
|
|
280
|
+
"""No widget of its own: key bindings + `Footer` entries, or toolbar buttons.
|
|
281
|
+
|
|
282
|
+
`_ComposeUIApp` turns actions with a shortcut into key bindings, and a toolbar, or a
|
|
283
|
+
router's for its pages' actions, shows them as buttons.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
def __init__(self, view: ActionView) -> None:
|
|
287
|
+
super().__init__(view)
|
|
288
|
+
#: set once `_register_actions` has bound the action to a key
|
|
289
|
+
self.app: _ComposeUIApp | None = None
|
|
290
|
+
#: set by the toolbar showing the action (`TextualToolBarRenderer`, or
|
|
291
|
+
#: `TextualRouterRenderer` for a page's action): its button
|
|
292
|
+
self.button: ComposeToolbarButton | None = None
|
|
293
|
+
self._refresh_dispatcher.update(
|
|
294
|
+
{
|
|
295
|
+
name: functools.partial(self._refresh_label, name)
|
|
296
|
+
for name in ("text", "is_checkable", "is_checked")
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
self._refresh_dispatcher.update(
|
|
300
|
+
{
|
|
301
|
+
"is_visible": self._refresh_button_visible,
|
|
302
|
+
"is_enabled": self._refresh_button_enabled,
|
|
303
|
+
"shortcut": self._refresh_shortcut,
|
|
304
|
+
# nothing shows them: a toolbar button shows its text, the app reads data
|
|
305
|
+
"icon": self._noop,
|
|
306
|
+
"data": self._noop,
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
if isinstance(view, PickFileAction):
|
|
310
|
+
# read by the picker each time it opens
|
|
311
|
+
self._refresh_dispatcher["extensions"] = self._noop
|
|
312
|
+
|
|
313
|
+
def _refresh_label(
|
|
314
|
+
self, name: str, view: ActionView, _rendered_object: ActionView, value: Any
|
|
315
|
+
) -> None:
|
|
316
|
+
# called before the view stores `value`: the other fields come from the view
|
|
317
|
+
state: dict[str, Any] = {
|
|
318
|
+
"text": view.text,
|
|
319
|
+
"is_checkable": view.is_checkable,
|
|
320
|
+
"is_checked": view.is_checked,
|
|
321
|
+
name: value,
|
|
322
|
+
}
|
|
323
|
+
if self.button is not None:
|
|
324
|
+
button = self.button
|
|
325
|
+
_call_on_app_thread(
|
|
326
|
+
_app_of(button), functools.partial(_show_on_button, button, **state)
|
|
327
|
+
)
|
|
328
|
+
if self.app is None:
|
|
329
|
+
return
|
|
330
|
+
label = _action_label(**state)
|
|
331
|
+
_call_on_app_thread(self.app, functools.partial(self.app.refresh_action, view, label))
|
|
332
|
+
|
|
333
|
+
def _refresh_shortcut(
|
|
334
|
+
self, view: ActionView, _rendered_object: ActionView, value: str | None
|
|
335
|
+
) -> None:
|
|
336
|
+
if self.app is None:
|
|
337
|
+
return # not running yet: `_register_actions` binds the shortcut set by then
|
|
338
|
+
app, previous = self.app, view.shortcut # called before the view stores `value`
|
|
339
|
+
_call_on_app_thread(app, functools.partial(app.rebind_action, view, previous, value))
|
|
340
|
+
|
|
341
|
+
def _refresh_button_visible(
|
|
342
|
+
self, _view: ActionView, _rendered_object: ActionView, value: bool
|
|
343
|
+
) -> None:
|
|
344
|
+
if self.button is not None:
|
|
345
|
+
button = self.button
|
|
346
|
+
_call_on_app_thread(_app_of(button), lambda: setattr(button, "display", value))
|
|
347
|
+
|
|
348
|
+
def _refresh_button_enabled(
|
|
349
|
+
self, _view: ActionView, _rendered_object: ActionView, value: bool
|
|
350
|
+
) -> None:
|
|
351
|
+
if self.button is not None:
|
|
352
|
+
button = self.button
|
|
353
|
+
_call_on_app_thread(_app_of(button), lambda: setattr(button, "disabled", not value))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _show_on_button(
|
|
357
|
+
button: ComposeToolbarButton, *, text: str, is_checkable: bool, is_checked: bool
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Show an action on its toolbar button: its text, highlighted while checked."""
|
|
360
|
+
button.label = Content(text)
|
|
361
|
+
button.variant = "primary" if is_checkable and is_checked else "default"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _action_label(*, text: str, is_checkable: bool, is_checked: bool) -> str:
|
|
365
|
+
"""Describe an action in the footer, with a check mark like the Qt and web menus."""
|
|
366
|
+
return f"✓ {text}" if is_checkable and is_checked else text
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class TextualSeparatorRenderer(_TextualNoopRenderer[SeparatorAction]):
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class TextualMenuRenderer(_TextualNoopRenderer[Menu]):
|
|
374
|
+
"""No visual widget: a `Menu`'s children register themselves directly with the app."""
|
|
375
|
+
|
|
376
|
+
def __init__(self, view: Menu) -> None:
|
|
377
|
+
super().__init__(view)
|
|
378
|
+
# nowhere to show it: its actions are key bindings
|
|
379
|
+
self._refresh_dispatcher.update({"title": self._noop})
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class TextualDialogRenderer(Renderer[DialogView, DialogView]):
|
|
383
|
+
"""No widget in the screen: a modal screen pushed while the dialog is open.
|
|
384
|
+
|
|
385
|
+
A screen popped from the app can't be pushed again, so each opening pushes a new
|
|
386
|
+
one, built from the view.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
def __init__(self, view: DialogView) -> None:
|
|
390
|
+
super().__init__(view)
|
|
391
|
+
#: set by `TextualMainRenderer` once it has built the app
|
|
392
|
+
self.app: _ComposeUIApp | None = None
|
|
393
|
+
#: the screen showing the dialog, while open
|
|
394
|
+
self.screen: ComposeDialog | None = None
|
|
395
|
+
self._refresh_dispatcher.update(
|
|
396
|
+
{
|
|
397
|
+
"is_visible": self._refresh_visible,
|
|
398
|
+
"is_enabled": self._refresh_enabled,
|
|
399
|
+
"title": self._refresh_title,
|
|
400
|
+
"text": self._refresh_text,
|
|
401
|
+
"kind": self._refresh_kind,
|
|
402
|
+
"accept_text": self._refresh_accept_text,
|
|
403
|
+
"reject_text": self._refresh_reject_text,
|
|
404
|
+
}
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def render(self) -> None:
|
|
408
|
+
if self._rendered_object is None:
|
|
409
|
+
self._rendered_object = self._view
|
|
410
|
+
|
|
411
|
+
def call_on_main_thread(self, callback: Callable[[], None]) -> None:
|
|
412
|
+
_call_on_app_thread(self.app, callback)
|
|
413
|
+
|
|
414
|
+
def refresh(self) -> None:
|
|
415
|
+
# see `TextualWidgetRenderer.refresh`
|
|
416
|
+
self.call_on_main_thread(super().refresh)
|
|
417
|
+
|
|
418
|
+
def refresh_attribute(self, attr_name: str, value: Any) -> None:
|
|
419
|
+
self.call_on_main_thread(functools.partial(super().refresh_attribute, attr_name, value))
|
|
420
|
+
|
|
421
|
+
def _refresh_visible(self, view: DialogView, _rendered_object: DialogView, value: bool) -> None:
|
|
422
|
+
app = self.app
|
|
423
|
+
# opened before the app runs: its mount refreshes the tree again
|
|
424
|
+
if app is None or not app.is_running:
|
|
425
|
+
return
|
|
426
|
+
if value and self.screen is None:
|
|
427
|
+
screen = ComposeDialog(view)
|
|
428
|
+
self.screen = screen
|
|
429
|
+
app.push_screen(screen, functools.partial(self._answer, screen))
|
|
430
|
+
elif not value and self.screen is not None:
|
|
431
|
+
screen, self.screen = self.screen, None
|
|
432
|
+
# current under another screen too: not only once popped, which `_answer` sees
|
|
433
|
+
if screen.is_current: # pragma: no branch
|
|
434
|
+
screen.dismiss() # no longer `self.screen`: `_answer` ignores it
|
|
435
|
+
|
|
436
|
+
def _answer(self, screen: ComposeDialog, answer: bool | None) -> None:
|
|
437
|
+
if screen is not self.screen:
|
|
438
|
+
return # closed by the app, not the user
|
|
439
|
+
self.screen = None
|
|
440
|
+
if answer is None:
|
|
441
|
+
self._view.dismiss()
|
|
442
|
+
elif answer:
|
|
443
|
+
self._view.accept()
|
|
444
|
+
else:
|
|
445
|
+
self._view.reject()
|
|
446
|
+
|
|
447
|
+
def _refresh_enabled(
|
|
448
|
+
self, _view: DialogView, _rendered_object: DialogView, value: bool
|
|
449
|
+
) -> None:
|
|
450
|
+
if self.screen is not None:
|
|
451
|
+
self.screen.set_enabled(is_enabled=value)
|
|
452
|
+
|
|
453
|
+
def _refresh_title(self, _view: DialogView, _rendered_object: DialogView, value: str) -> None:
|
|
454
|
+
if self.screen is not None:
|
|
455
|
+
self.screen.title_label.update(value)
|
|
456
|
+
self.screen.title_label.display = value != ""
|
|
457
|
+
|
|
458
|
+
def _refresh_text(self, _view: DialogView, _rendered_object: DialogView, value: str) -> None:
|
|
459
|
+
if self.screen is not None:
|
|
460
|
+
self.screen.text_label.update(value)
|
|
461
|
+
|
|
462
|
+
def _refresh_kind(
|
|
463
|
+
self, _view: DialogView, _rendered_object: DialogView, value: DialogKind
|
|
464
|
+
) -> None:
|
|
465
|
+
if self.screen is not None:
|
|
466
|
+
self.screen.reject_button.display = value is DialogKind.CONFIRM
|
|
467
|
+
|
|
468
|
+
def _refresh_accept_text(
|
|
469
|
+
self, _view: DialogView, _rendered_object: DialogView, value: str
|
|
470
|
+
) -> None:
|
|
471
|
+
if self.screen is not None:
|
|
472
|
+
self.screen.accept_button.label = Content(value)
|
|
473
|
+
|
|
474
|
+
def _refresh_reject_text(
|
|
475
|
+
self, _view: DialogView, _rendered_object: DialogView, value: str
|
|
476
|
+
) -> None:
|
|
477
|
+
if self.screen is not None:
|
|
478
|
+
self.screen.reject_button.label = Content(value)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
TForm = TypeVar("TForm", bound=FormView)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class TextualFormDialogRenderer(TextualDialogRenderer, Generic[TForm]):
|
|
485
|
+
"""A modal screen holding the form, installed once: pushed open, popped closed.
|
|
486
|
+
|
|
487
|
+
The form's widgets are built once, so its screen is too, unlike a message's. OK is
|
|
488
|
+
disabled while a check of the form fails; the view closes the dialog once it accepts
|
|
489
|
+
the answer, so OK on a form refusing it leaves the screen up.
|
|
490
|
+
"""
|
|
491
|
+
|
|
492
|
+
def __init__(self, view: TypedFormDialogView[TForm]) -> None:
|
|
493
|
+
super().__init__(view)
|
|
494
|
+
self._form_view = view
|
|
495
|
+
self.form_screen: ComposeFormDialog | None = None
|
|
496
|
+
self._refresh_dispatcher["is_accept_enabled"] = self._refresh_is_accept_enabled
|
|
497
|
+
|
|
498
|
+
def render(self) -> None:
|
|
499
|
+
if self._rendered_object is None:
|
|
500
|
+
super().render()
|
|
501
|
+
form = self._form_view.form.renderer
|
|
502
|
+
assert isinstance(form, TextualWidgetRenderer)
|
|
503
|
+
assert form.widget is not None
|
|
504
|
+
self.form_screen = ComposeFormDialog(form.widget)
|
|
505
|
+
self.form_screen.answered.connect(self._answer_form)
|
|
506
|
+
# its form's widgets mount with it, first pushed: their state only then
|
|
507
|
+
self.form_screen.mounted.connect(self._refresh_screen)
|
|
508
|
+
|
|
509
|
+
def _refresh_screen(self) -> None:
|
|
510
|
+
_refresh_tree(self._form_view.form)
|
|
511
|
+
self.refresh() # the screen's title, text and buttons
|
|
512
|
+
|
|
513
|
+
def _answer_form(self, answer: str) -> None:
|
|
514
|
+
{"accept": self._view.accept, "reject": self._view.reject}.get(answer, self._view.dismiss)()
|
|
515
|
+
|
|
516
|
+
def _refresh_visible(self, view: DialogView, _rendered_object: DialogView, value: bool) -> None:
|
|
517
|
+
app, screen = self.app, self.form_screen
|
|
518
|
+
# opened before the app runs: its mount refreshes the tree again
|
|
519
|
+
if app is None or not app.is_running:
|
|
520
|
+
return
|
|
521
|
+
assert screen is not None
|
|
522
|
+
if value and not screen.is_current:
|
|
523
|
+
name = f"cui-{id(view)}-dialog"
|
|
524
|
+
if not app.is_screen_installed(screen):
|
|
525
|
+
app.install_screen(screen, name)
|
|
526
|
+
app.push_screen(name)
|
|
527
|
+
elif not value and screen.is_current:
|
|
528
|
+
app.pop_screen()
|
|
529
|
+
|
|
530
|
+
def _refresh_enabled(
|
|
531
|
+
self, _view: DialogView, _rendered_object: DialogView, value: bool
|
|
532
|
+
) -> None:
|
|
533
|
+
assert self.form_screen is not None
|
|
534
|
+
self.form_screen.reject_button.disabled = not value
|
|
535
|
+
self.form_screen.accept_button.disabled = not (value and self._form_view.is_accept_enabled)
|
|
536
|
+
|
|
537
|
+
def _refresh_title(self, _view: DialogView, _rendered_object: DialogView, value: str) -> None:
|
|
538
|
+
assert self.form_screen is not None
|
|
539
|
+
self.form_screen.title_label.update(value)
|
|
540
|
+
self.form_screen.title_label.display = value != ""
|
|
541
|
+
|
|
542
|
+
def _refresh_text(self, _view: DialogView, _rendered_object: DialogView, value: str) -> None:
|
|
543
|
+
assert self.form_screen is not None
|
|
544
|
+
self.form_screen.text_label.update(value)
|
|
545
|
+
self.form_screen.text_label.display = value != ""
|
|
546
|
+
|
|
547
|
+
def _refresh_kind(self, _view: DialogView, _rendered_object: DialogView, _value: Any) -> None:
|
|
548
|
+
pass # always OK and Cancel: a form is confirmed or not
|
|
549
|
+
|
|
550
|
+
def _refresh_accept_text(
|
|
551
|
+
self, _view: DialogView, _rendered_object: DialogView, value: str
|
|
552
|
+
) -> None:
|
|
553
|
+
assert self.form_screen is not None
|
|
554
|
+
self.form_screen.accept_button.label = Content(value)
|
|
555
|
+
|
|
556
|
+
def _refresh_reject_text(
|
|
557
|
+
self, _view: DialogView, _rendered_object: DialogView, value: str
|
|
558
|
+
) -> None:
|
|
559
|
+
assert self.form_screen is not None
|
|
560
|
+
self.form_screen.reject_button.label = Content(value)
|
|
561
|
+
|
|
562
|
+
def _refresh_is_accept_enabled(
|
|
563
|
+
self, _view: DialogView, _rendered_object: DialogView, value: bool
|
|
564
|
+
) -> None:
|
|
565
|
+
assert self.form_screen is not None
|
|
566
|
+
self.form_screen.accept_button.disabled = not (value and self._view.is_enabled)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
class InputViewLike(ViewLike, Protocol):
|
|
570
|
+
placeholder: str
|
|
571
|
+
text: str
|
|
572
|
+
text_changed: Signal[str]
|
|
573
|
+
text_edited: Signal[str]
|
|
574
|
+
editing_finished: SignalNoArgs
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
class TextualInputRenderer(TextualWidgetRenderer[InputViewLike, ComposeInput]):
|
|
578
|
+
_marks_invalid = True
|
|
579
|
+
|
|
580
|
+
def __init__(self, view: InputViewLike) -> None:
|
|
581
|
+
super().__init__(view)
|
|
582
|
+
self._refresh_dispatcher.update(
|
|
583
|
+
{
|
|
584
|
+
"text": self._refresh_text,
|
|
585
|
+
"placeholder": self._refresh_placeholder,
|
|
586
|
+
}
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
def render(self) -> None:
|
|
590
|
+
if self._rendered_object is None:
|
|
591
|
+
self._rendered_object = ComposeInput()
|
|
592
|
+
self._rendered_object.value_changed.connect(self._update_text)
|
|
593
|
+
self._rendered_object.value_submitted.connect(self._editing_finished)
|
|
594
|
+
|
|
595
|
+
@staticmethod
|
|
596
|
+
def _refresh_text(_view: InputViewLike, rendered_object: ComposeInput, value: str) -> None:
|
|
597
|
+
rendered_object.value = value
|
|
598
|
+
|
|
599
|
+
@staticmethod
|
|
600
|
+
def _refresh_placeholder(
|
|
601
|
+
_view: InputViewLike, rendered_object: ComposeInput, value: str
|
|
602
|
+
) -> None:
|
|
603
|
+
rendered_object.placeholder = value
|
|
604
|
+
|
|
605
|
+
def _update_text(self, text: str) -> None:
|
|
606
|
+
with self.stop_refresh():
|
|
607
|
+
self._view.text = text
|
|
608
|
+
self._view.text_changed.emit(text)
|
|
609
|
+
self._view.text_edited.emit(text)
|
|
610
|
+
|
|
611
|
+
def _editing_finished(self) -> None:
|
|
612
|
+
self._view.editing_finished.emit()
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
class FloatInputViewLike(ViewLike, Protocol):
|
|
616
|
+
placeholder: str
|
|
617
|
+
value: float | None
|
|
618
|
+
minimum: float | None
|
|
619
|
+
maximum: float | None
|
|
620
|
+
decimals: int | None
|
|
621
|
+
text_changed: Signal[str]
|
|
622
|
+
text_edited: Signal[str]
|
|
623
|
+
editing_finished: SignalNoArgs
|
|
624
|
+
|
|
625
|
+
def shown_text(self, value: float | None, decimals: int | None) -> str: ...
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
class TextualFloatInputRenderer(TextualWidgetRenderer[FloatInputViewLike, ComposeInput]):
|
|
629
|
+
_marks_invalid = True
|
|
630
|
+
|
|
631
|
+
def __init__(self, view: FloatInputViewLike) -> None:
|
|
632
|
+
super().__init__(view)
|
|
633
|
+
self._refresh_dispatcher.update(
|
|
634
|
+
{
|
|
635
|
+
"value": self._refresh_value,
|
|
636
|
+
"placeholder": self._refresh_placeholder,
|
|
637
|
+
"minimum": self._refresh_minimum,
|
|
638
|
+
"maximum": self._refresh_maximum,
|
|
639
|
+
# shown, not enforced: Textual's `Number` validator only bounds min/max,
|
|
640
|
+
# with no equivalent of Qt's `QDoubleValidator.setDecimals`
|
|
641
|
+
"decimals": self._refresh_decimals,
|
|
642
|
+
}
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
def render(self) -> None:
|
|
646
|
+
if self._rendered_object is None:
|
|
647
|
+
self._rendered_object = ComposeInput()
|
|
648
|
+
self._rendered_object.value_changed.connect(self._update_text)
|
|
649
|
+
self._rendered_object.value_submitted.connect(self._editing_finished)
|
|
650
|
+
|
|
651
|
+
@staticmethod
|
|
652
|
+
def _refresh_value(
|
|
653
|
+
view: FloatInputViewLike, rendered_object: ComposeInput, value: float | None
|
|
654
|
+
) -> None:
|
|
655
|
+
rendered_object.value = view.shown_text(value, view.decimals)
|
|
656
|
+
|
|
657
|
+
@staticmethod
|
|
658
|
+
def _refresh_decimals(
|
|
659
|
+
view: FloatInputViewLike, rendered_object: ComposeInput, value: int | None
|
|
660
|
+
) -> None:
|
|
661
|
+
rendered_object.value = view.shown_text(view.value, value)
|
|
662
|
+
|
|
663
|
+
@staticmethod
|
|
664
|
+
def _refresh_placeholder(
|
|
665
|
+
_view: FloatInputViewLike, rendered_object: ComposeInput, value: str
|
|
666
|
+
) -> None:
|
|
667
|
+
rendered_object.placeholder = value
|
|
668
|
+
|
|
669
|
+
@staticmethod
|
|
670
|
+
def _refresh_minimum(
|
|
671
|
+
_view: FloatInputViewLike, rendered_object: ComposeInput, value: float | None
|
|
672
|
+
) -> None:
|
|
673
|
+
rendered_object.validators = [
|
|
674
|
+
v for v in rendered_object.validators if not isinstance(v, Number)
|
|
675
|
+
]
|
|
676
|
+
rendered_object.validators.append(Number(minimum=value, maximum=_view.maximum))
|
|
677
|
+
|
|
678
|
+
@staticmethod
|
|
679
|
+
def _refresh_maximum(
|
|
680
|
+
_view: FloatInputViewLike, rendered_object: ComposeInput, value: float | None
|
|
681
|
+
) -> None:
|
|
682
|
+
rendered_object.validators = [
|
|
683
|
+
v for v in rendered_object.validators if not isinstance(v, Number)
|
|
684
|
+
]
|
|
685
|
+
rendered_object.validators.append(Number(minimum=_view.minimum, maximum=value))
|
|
686
|
+
|
|
687
|
+
def _update_text(self, text: str) -> None:
|
|
688
|
+
# Textual reports the text the renderer set too: as shown for the view's value,
|
|
689
|
+
# maybe rounded, it isn't the user's and leaves the value as it is.
|
|
690
|
+
# Transient invalid text while typing (e.g. "-", "1.") is ignored.
|
|
691
|
+
if text != self._view.shown_text(self._view.value, self._view.decimals):
|
|
692
|
+
with self.stop_refresh(), contextlib.suppress(ValueError):
|
|
693
|
+
self._view.value = float(text) if text != "" else None
|
|
694
|
+
self._view.text_changed.emit(text)
|
|
695
|
+
self._view.text_edited.emit(text)
|
|
696
|
+
|
|
697
|
+
def _editing_finished(self) -> None:
|
|
698
|
+
self._view.editing_finished.emit()
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
class IntInputViewLike(ViewLike, Protocol):
|
|
702
|
+
placeholder: str
|
|
703
|
+
value: int | None
|
|
704
|
+
minimum: int | None
|
|
705
|
+
maximum: int | None
|
|
706
|
+
text_changed: Signal[str]
|
|
707
|
+
text_edited: Signal[str]
|
|
708
|
+
editing_finished: SignalNoArgs
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class TextualIntInputRenderer(TextualWidgetRenderer[IntInputViewLike, ComposeInput]):
|
|
712
|
+
"""An input taking only a whole number; Textual has no spin box.
|
|
713
|
+
|
|
714
|
+
As on the float input, a value out of `minimum`/`maximum` is marked invalid, not refused.
|
|
715
|
+
"""
|
|
716
|
+
|
|
717
|
+
_marks_invalid = True
|
|
718
|
+
|
|
719
|
+
def __init__(self, view: IntInputViewLike) -> None:
|
|
720
|
+
super().__init__(view)
|
|
721
|
+
self._refresh_dispatcher.update(
|
|
722
|
+
{
|
|
723
|
+
"value": self._refresh_value,
|
|
724
|
+
"placeholder": self._refresh_placeholder,
|
|
725
|
+
"minimum": self._refresh_range,
|
|
726
|
+
"maximum": self._refresh_range,
|
|
727
|
+
}
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
def render(self) -> None:
|
|
731
|
+
if self._rendered_object is None:
|
|
732
|
+
# `type="integer"` lets in only what can start an int: digits and a sign
|
|
733
|
+
self._rendered_object = ComposeInput(type="integer")
|
|
734
|
+
self._rendered_object.value_changed.connect(self._update_text)
|
|
735
|
+
self._rendered_object.value_submitted.connect(self._editing_finished)
|
|
736
|
+
|
|
737
|
+
@staticmethod
|
|
738
|
+
def _refresh_value(
|
|
739
|
+
_view: IntInputViewLike, rendered_object: ComposeInput, value: int | None
|
|
740
|
+
) -> None:
|
|
741
|
+
rendered_object.value = str(value) if value is not None else ""
|
|
742
|
+
|
|
743
|
+
@staticmethod
|
|
744
|
+
def _refresh_placeholder(
|
|
745
|
+
_view: IntInputViewLike, rendered_object: ComposeInput, value: str
|
|
746
|
+
) -> None:
|
|
747
|
+
rendered_object.placeholder = value
|
|
748
|
+
|
|
749
|
+
@staticmethod
|
|
750
|
+
def _refresh_range(view: IntInputViewLike, rendered_object: ComposeInput, _value: Any) -> None:
|
|
751
|
+
# `type="integer"` adds an `Integer` validator of its own, without a range
|
|
752
|
+
rendered_object.validators = [
|
|
753
|
+
v for v in rendered_object.validators if not isinstance(v, Number)
|
|
754
|
+
]
|
|
755
|
+
rendered_object.validators.append(Integer(minimum=view.minimum, maximum=view.maximum))
|
|
756
|
+
|
|
757
|
+
def _update_text(self, text: str) -> None:
|
|
758
|
+
# transient invalid text while typing (e.g. "-"); ignore it
|
|
759
|
+
with self.stop_refresh(), contextlib.suppress(ValueError):
|
|
760
|
+
self._view.value = int(text) if text != "" else None
|
|
761
|
+
self._view.text_changed.emit(text)
|
|
762
|
+
self._view.text_edited.emit(text)
|
|
763
|
+
|
|
764
|
+
def _editing_finished(self) -> None:
|
|
765
|
+
self._view.editing_finished.emit()
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
class DateInputViewLike(ViewLike, Protocol):
|
|
769
|
+
placeholder: str
|
|
770
|
+
value: datetime.date | None
|
|
771
|
+
minimum: datetime.date | None
|
|
772
|
+
maximum: datetime.date | None
|
|
773
|
+
date_format: str
|
|
774
|
+
month_names: list[str]
|
|
775
|
+
day_names: list[str]
|
|
776
|
+
pick_text: str
|
|
777
|
+
text_changed: Signal[str]
|
|
778
|
+
text_edited: Signal[str]
|
|
779
|
+
editing_finished: SignalNoArgs
|
|
780
|
+
value_changed: Signal[datetime.date | None]
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
class TextualDateInputRenderer(TextualWidgetRenderer[DateInputViewLike, ComposeDateInput]):
|
|
784
|
+
"""A field typing the date as the view's `date_format` says, beside a calendar button.
|
|
785
|
+
|
|
786
|
+
Textual has no date picker: the calendar is a screen of its own, its months and days
|
|
787
|
+
named as the view's `month_names` and `day_names`, so in the main view's language.
|
|
788
|
+
"""
|
|
789
|
+
|
|
790
|
+
_marks_invalid = True
|
|
791
|
+
|
|
792
|
+
def __init__(self, view: DateInputViewLike) -> None:
|
|
793
|
+
super().__init__(view)
|
|
794
|
+
self._refresh_dispatcher.update(
|
|
795
|
+
{
|
|
796
|
+
"value": self._refresh_value,
|
|
797
|
+
"placeholder": self._refresh_placeholder,
|
|
798
|
+
"minimum": self._refresh_minimum,
|
|
799
|
+
"maximum": self._refresh_maximum,
|
|
800
|
+
"date_format": self._refresh_date_format,
|
|
801
|
+
"month_names": self._refresh_month_names,
|
|
802
|
+
"day_names": self._refresh_day_names,
|
|
803
|
+
"pick_text": self._refresh_pick_text,
|
|
804
|
+
# its calendar's keys move by month, its title naming the month shown
|
|
805
|
+
"previous_month_text": lambda _view, _rendered_object, _value: None,
|
|
806
|
+
"next_month_text": lambda _view, _rendered_object, _value: None,
|
|
807
|
+
}
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
def render(self) -> None:
|
|
811
|
+
if self._rendered_object is None:
|
|
812
|
+
self._rendered_object = ComposeDateInput()
|
|
813
|
+
self._rendered_object.date_edited.connect(self._update_value)
|
|
814
|
+
self._rendered_object.input.value_changed.connect(self._update_text)
|
|
815
|
+
self._rendered_object.input.value_submitted.connect(self._view.editing_finished.emit)
|
|
816
|
+
|
|
817
|
+
@staticmethod
|
|
818
|
+
def _refresh_value(
|
|
819
|
+
_view: DateInputViewLike, rendered_object: ComposeDateInput, value: datetime.date | None
|
|
820
|
+
) -> None:
|
|
821
|
+
rendered_object.set_date(value)
|
|
822
|
+
|
|
823
|
+
@staticmethod
|
|
824
|
+
def _refresh_placeholder(
|
|
825
|
+
view: DateInputViewLike, rendered_object: ComposeDateInput, value: str
|
|
826
|
+
) -> None:
|
|
827
|
+
rendered_object.input.placeholder = date_hint(value, view.date_format)
|
|
828
|
+
|
|
829
|
+
@staticmethod
|
|
830
|
+
def _refresh_minimum(
|
|
831
|
+
_view: DateInputViewLike, rendered_object: ComposeDateInput, value: datetime.date | None
|
|
832
|
+
) -> None:
|
|
833
|
+
rendered_object.minimum = value
|
|
834
|
+
rendered_object.input.validate(rendered_object.input.value)
|
|
835
|
+
|
|
836
|
+
@staticmethod
|
|
837
|
+
def _refresh_maximum(
|
|
838
|
+
_view: DateInputViewLike, rendered_object: ComposeDateInput, value: datetime.date | None
|
|
839
|
+
) -> None:
|
|
840
|
+
rendered_object.maximum = value
|
|
841
|
+
rendered_object.input.validate(rendered_object.input.value)
|
|
842
|
+
|
|
843
|
+
@staticmethod
|
|
844
|
+
def _refresh_date_format(
|
|
845
|
+
view: DateInputViewLike, rendered_object: ComposeDateInput, value: str
|
|
846
|
+
) -> None:
|
|
847
|
+
rendered_object.set_date_format(value)
|
|
848
|
+
rendered_object.input.placeholder = date_hint(view.placeholder, value)
|
|
849
|
+
|
|
850
|
+
@staticmethod
|
|
851
|
+
def _refresh_month_names(
|
|
852
|
+
_view: DateInputViewLike, rendered_object: ComposeDateInput, value: list[str]
|
|
853
|
+
) -> None:
|
|
854
|
+
rendered_object.month_names = list(value)
|
|
855
|
+
|
|
856
|
+
@staticmethod
|
|
857
|
+
def _refresh_day_names(
|
|
858
|
+
_view: DateInputViewLike, rendered_object: ComposeDateInput, value: list[str]
|
|
859
|
+
) -> None:
|
|
860
|
+
rendered_object.day_names = list(value)
|
|
861
|
+
|
|
862
|
+
@staticmethod
|
|
863
|
+
def _refresh_pick_text(
|
|
864
|
+
_view: DateInputViewLike, rendered_object: ComposeDateInput, value: str
|
|
865
|
+
) -> None:
|
|
866
|
+
rendered_object.button.tooltip = Content(value)
|
|
867
|
+
|
|
868
|
+
def _update_value(self, value: datetime.date | None) -> None:
|
|
869
|
+
with self.stop_refresh():
|
|
870
|
+
self._view.value = value
|
|
871
|
+
self._view.value_changed.emit(value)
|
|
872
|
+
|
|
873
|
+
def _update_text(self, text: str) -> None:
|
|
874
|
+
self._view.text_changed.emit(text)
|
|
875
|
+
self._view.text_edited.emit(text)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
class FilePathInputViewLike(InputViewLike, Protocol):
|
|
879
|
+
extensions: dict[str, tuple[str, ...]]
|
|
880
|
+
has_reload: bool
|
|
881
|
+
browse_text: str
|
|
882
|
+
reload_text: str
|
|
883
|
+
path_picked: Signal[Path]
|
|
884
|
+
reload_clicked: SignalNoArgs
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
class TextualFilePathInputRenderer(
|
|
888
|
+
TextualWidgetRenderer[FilePathInputViewLike, ComposeFilePathInput]
|
|
889
|
+
):
|
|
890
|
+
"""A field typing a file's path, then a "…" button opening textual-fspicker's picker."""
|
|
891
|
+
|
|
892
|
+
_marks_invalid = True
|
|
893
|
+
|
|
894
|
+
def __init__(self, view: FilePathInputViewLike) -> None:
|
|
895
|
+
super().__init__(view)
|
|
896
|
+
self._refresh_dispatcher.update(
|
|
897
|
+
{
|
|
898
|
+
"text": self._refresh_text,
|
|
899
|
+
"placeholder": self._refresh_placeholder,
|
|
900
|
+
"extensions": self._refresh_extensions,
|
|
901
|
+
"has_reload": self._refresh_has_reload,
|
|
902
|
+
"browse_text": self._refresh_browse_text,
|
|
903
|
+
"reload_text": self._refresh_reload_text,
|
|
904
|
+
}
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
def render(self) -> None:
|
|
908
|
+
if self._rendered_object is None:
|
|
909
|
+
self._rendered_object = ComposeFilePathInput()
|
|
910
|
+
self._rendered_object.input.value_changed.connect(self._update_text)
|
|
911
|
+
self._rendered_object.input.value_submitted.connect(self._view.editing_finished.emit)
|
|
912
|
+
self._rendered_object.path_picked.connect(self._pick)
|
|
913
|
+
self._rendered_object.reload_pressed.connect(self._view.reload_clicked.emit)
|
|
914
|
+
|
|
915
|
+
@staticmethod
|
|
916
|
+
def _refresh_text(
|
|
917
|
+
_view: FilePathInputViewLike, rendered_object: ComposeFilePathInput, value: str
|
|
918
|
+
) -> None:
|
|
919
|
+
rendered_object.input.value = value
|
|
920
|
+
|
|
921
|
+
@staticmethod
|
|
922
|
+
def _refresh_placeholder(
|
|
923
|
+
_view: FilePathInputViewLike, rendered_object: ComposeFilePathInput, value: str
|
|
924
|
+
) -> None:
|
|
925
|
+
rendered_object.input.placeholder = value
|
|
926
|
+
|
|
927
|
+
@staticmethod
|
|
928
|
+
def _refresh_extensions(
|
|
929
|
+
_view: FilePathInputViewLike,
|
|
930
|
+
rendered_object: ComposeFilePathInput,
|
|
931
|
+
value: dict[str, tuple[str, ...]],
|
|
932
|
+
) -> None:
|
|
933
|
+
rendered_object.extensions = dict(value)
|
|
934
|
+
|
|
935
|
+
@staticmethod
|
|
936
|
+
def _refresh_has_reload(
|
|
937
|
+
_view: FilePathInputViewLike, rendered_object: ComposeFilePathInput, value: bool
|
|
938
|
+
) -> None:
|
|
939
|
+
rendered_object.reload_button.display = value
|
|
940
|
+
|
|
941
|
+
@staticmethod
|
|
942
|
+
def _refresh_browse_text(
|
|
943
|
+
_view: FilePathInputViewLike, rendered_object: ComposeFilePathInput, value: str
|
|
944
|
+
) -> None:
|
|
945
|
+
rendered_object.browse_button.tooltip = Content(value)
|
|
946
|
+
rendered_object.picker_title = value
|
|
947
|
+
|
|
948
|
+
@staticmethod
|
|
949
|
+
def _refresh_reload_text(
|
|
950
|
+
_view: FilePathInputViewLike, rendered_object: ComposeFilePathInput, value: str
|
|
951
|
+
) -> None:
|
|
952
|
+
rendered_object.reload_button.tooltip = Content(value)
|
|
953
|
+
|
|
954
|
+
def _update_text(self, text: str) -> None:
|
|
955
|
+
with self.stop_refresh():
|
|
956
|
+
self._view.text = text
|
|
957
|
+
self._view.text_changed.emit(text)
|
|
958
|
+
self._view.text_edited.emit(text)
|
|
959
|
+
|
|
960
|
+
def _pick(self, path: Path) -> None:
|
|
961
|
+
# the field's change arrives later, as a message: the view has the path first
|
|
962
|
+
with self.stop_refresh():
|
|
963
|
+
self._view.text = str(path)
|
|
964
|
+
self._view.path_picked.emit(path)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
class SliderViewLike(ViewLike, Protocol):
|
|
968
|
+
value: int
|
|
969
|
+
minimum: int
|
|
970
|
+
maximum: int
|
|
971
|
+
step: int
|
|
972
|
+
value_changed: Signal[int]
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
class TextualSliderRenderer(TextualWidgetRenderer[SliderViewLike, ComposeSlider]):
|
|
976
|
+
_marks_invalid = True
|
|
977
|
+
|
|
978
|
+
def __init__(self, view: SliderViewLike) -> None:
|
|
979
|
+
super().__init__(view)
|
|
980
|
+
self._refresh_dispatcher.update(
|
|
981
|
+
{
|
|
982
|
+
"value": self._refresh_value,
|
|
983
|
+
"minimum": self._refresh_minimum,
|
|
984
|
+
"maximum": self._refresh_maximum,
|
|
985
|
+
"step": self._refresh_step,
|
|
986
|
+
}
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
def render(self) -> None:
|
|
990
|
+
if self._rendered_object is None:
|
|
991
|
+
self._rendered_object = ComposeSlider()
|
|
992
|
+
self._rendered_object.moved.connect(self._update_value)
|
|
993
|
+
|
|
994
|
+
@staticmethod
|
|
995
|
+
def _refresh_value(view: SliderViewLike, rendered_object: ComposeSlider, value: int) -> None:
|
|
996
|
+
TextualSliderRenderer._show(rendered_object, value, view.minimum, view.maximum, view.step)
|
|
997
|
+
|
|
998
|
+
@staticmethod
|
|
999
|
+
def _refresh_minimum(view: SliderViewLike, rendered_object: ComposeSlider, value: int) -> None:
|
|
1000
|
+
TextualSliderRenderer._show(rendered_object, view.value, value, view.maximum, view.step)
|
|
1001
|
+
|
|
1002
|
+
@staticmethod
|
|
1003
|
+
def _refresh_maximum(view: SliderViewLike, rendered_object: ComposeSlider, value: int) -> None:
|
|
1004
|
+
TextualSliderRenderer._show(rendered_object, view.value, view.minimum, value, view.step)
|
|
1005
|
+
|
|
1006
|
+
@staticmethod
|
|
1007
|
+
def _refresh_step(view: SliderViewLike, rendered_object: ComposeSlider, value: int) -> None:
|
|
1008
|
+
TextualSliderRenderer._show(rendered_object, view.value, view.minimum, view.maximum, value)
|
|
1009
|
+
|
|
1010
|
+
@staticmethod
|
|
1011
|
+
def _show(
|
|
1012
|
+
rendered_object: ComposeSlider, value: int, minimum: int, maximum: int, step: int
|
|
1013
|
+
) -> None:
|
|
1014
|
+
rendered_object.set_steps(
|
|
1015
|
+
minimum,
|
|
1016
|
+
step,
|
|
1017
|
+
SliderMixin.last_position(minimum, maximum, step),
|
|
1018
|
+
SliderMixin.position(value, minimum, maximum, step),
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
def _update_value(self, value: int) -> None:
|
|
1022
|
+
with self.stop_refresh():
|
|
1023
|
+
self._view.value = value
|
|
1024
|
+
self._view.value_changed.emit(value)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
class LabelViewLike(ViewLike, Protocol):
|
|
1028
|
+
text: str
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
class TextualLabelRenderer(TextualWidgetRenderer[LabelViewLike, Label]):
|
|
1032
|
+
def __init__(self, view: LabelViewLike) -> None:
|
|
1033
|
+
super().__init__(view)
|
|
1034
|
+
self._refresh_dispatcher.update(
|
|
1035
|
+
{
|
|
1036
|
+
"text": self._refresh_text,
|
|
1037
|
+
}
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
def render(self) -> None:
|
|
1041
|
+
if self._rendered_object is None:
|
|
1042
|
+
self._rendered_object = Label(markup=False)
|
|
1043
|
+
|
|
1044
|
+
@staticmethod
|
|
1045
|
+
def _refresh_text(_view: LabelViewLike, rendered_object: Label, value: str) -> None:
|
|
1046
|
+
rendered_object.update(value)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
class MarkdownViewLike(ViewLike, Protocol):
|
|
1050
|
+
text: str
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
class TextualMarkdownRenderer(TextualWidgetRenderer[MarkdownViewLike, ComposeMarkdown]):
|
|
1054
|
+
def __init__(self, view: MarkdownViewLike) -> None:
|
|
1055
|
+
super().__init__(view)
|
|
1056
|
+
self._refresh_dispatcher.update({"text": self._refresh_text})
|
|
1057
|
+
|
|
1058
|
+
def render(self) -> None:
|
|
1059
|
+
if self._rendered_object is None:
|
|
1060
|
+
self._rendered_object = ComposeMarkdown()
|
|
1061
|
+
|
|
1062
|
+
@staticmethod
|
|
1063
|
+
def _refresh_text(
|
|
1064
|
+
_view: MarkdownViewLike, rendered_object: ComposeMarkdown, value: str
|
|
1065
|
+
) -> None:
|
|
1066
|
+
rendered_object.set_text(value)
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
class TextualFormLabelRenderer(TextualWidgetRenderer[FormLabelRowView, Label]):
|
|
1070
|
+
"""A form row's label, then the row's help as a dimmed hint under it.
|
|
1071
|
+
|
|
1072
|
+
A hint rather than a tooltip, which only the mouse shows.
|
|
1073
|
+
"""
|
|
1074
|
+
|
|
1075
|
+
def __init__(self, view: FormLabelRowView) -> None:
|
|
1076
|
+
super().__init__(view)
|
|
1077
|
+
self._refresh_dispatcher.update(
|
|
1078
|
+
{
|
|
1079
|
+
"label": self._refresh_text,
|
|
1080
|
+
"help": self._refresh_help,
|
|
1081
|
+
}
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
def render(self) -> None:
|
|
1085
|
+
if self._rendered_object is None:
|
|
1086
|
+
self._rendered_object = Label()
|
|
1087
|
+
|
|
1088
|
+
@staticmethod
|
|
1089
|
+
def _refresh_text(view: FormLabelRowView, rendered_object: Label, value: str) -> None:
|
|
1090
|
+
rendered_object.update(TextualFormLabelRenderer._content(value, view.help))
|
|
1091
|
+
|
|
1092
|
+
@staticmethod
|
|
1093
|
+
def _refresh_help(view: FormLabelRowView, rendered_object: Label, value: str) -> None:
|
|
1094
|
+
rendered_object.update(TextualFormLabelRenderer._content(view.label, value))
|
|
1095
|
+
|
|
1096
|
+
@staticmethod
|
|
1097
|
+
def _content(label: str, help_text: str) -> Content:
|
|
1098
|
+
if help_text == "":
|
|
1099
|
+
return Content(label)
|
|
1100
|
+
return Content.assemble(label, "\n", (help_text, "dim"))
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
class CheckBoxViewLike(ViewLike, Protocol):
|
|
1104
|
+
text: str
|
|
1105
|
+
is_checked: bool
|
|
1106
|
+
toggled: Signal[bool]
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
class TextualCheckBoxRenderer(TextualWidgetRenderer[CheckBoxViewLike, ComposeCheckBox]):
|
|
1110
|
+
_marks_invalid = True
|
|
1111
|
+
|
|
1112
|
+
def __init__(self, view: CheckBoxViewLike) -> None:
|
|
1113
|
+
super().__init__(view)
|
|
1114
|
+
self._refresh_dispatcher.update(
|
|
1115
|
+
{
|
|
1116
|
+
"text": self._refresh_text,
|
|
1117
|
+
"is_checked": self._refresh_is_checked,
|
|
1118
|
+
}
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
def render(self) -> None:
|
|
1122
|
+
if self._rendered_object is None:
|
|
1123
|
+
self._rendered_object = ComposeCheckBox()
|
|
1124
|
+
self._rendered_object.toggled.connect(self._update_checked)
|
|
1125
|
+
|
|
1126
|
+
@staticmethod
|
|
1127
|
+
def _refresh_text(
|
|
1128
|
+
_view: CheckBoxViewLike, rendered_object: ComposeCheckBox, value: str
|
|
1129
|
+
) -> None:
|
|
1130
|
+
rendered_object.label = Content(value)
|
|
1131
|
+
|
|
1132
|
+
@staticmethod
|
|
1133
|
+
def _refresh_is_checked(
|
|
1134
|
+
_view: CheckBoxViewLike, rendered_object: ComposeCheckBox, value: bool
|
|
1135
|
+
) -> None:
|
|
1136
|
+
rendered_object.set_checked(value)
|
|
1137
|
+
|
|
1138
|
+
def _update_checked(self, is_checked: bool) -> None:
|
|
1139
|
+
with self.stop_refresh():
|
|
1140
|
+
self._view.is_checked = is_checked
|
|
1141
|
+
self._view.toggled.emit(is_checked)
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
class ButtonViewLike(ViewLike, Protocol):
|
|
1145
|
+
text: str
|
|
1146
|
+
variant: ButtonVariant
|
|
1147
|
+
clicked: SignalNoArgs
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
#: each button variant as Textual's
|
|
1151
|
+
_BUTTON_VARIANTS: dict[ButtonVariant, ButtonVariantName] = {
|
|
1152
|
+
ButtonVariant.PRIMARY: "primary",
|
|
1153
|
+
ButtonVariant.SECONDARY: "default",
|
|
1154
|
+
ButtonVariant.DANGER: "error",
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
class TextualButtonRenderer(TextualWidgetRenderer[ButtonViewLike, ComposeButton]):
|
|
1159
|
+
def __init__(self, view: ButtonViewLike) -> None:
|
|
1160
|
+
super().__init__(view)
|
|
1161
|
+
self._refresh_dispatcher.update(
|
|
1162
|
+
{"text": self._refresh_text, "variant": self._refresh_variant}
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
def render(self) -> None:
|
|
1166
|
+
if self._rendered_object is None:
|
|
1167
|
+
self._rendered_object = ComposeButton()
|
|
1168
|
+
self._rendered_object.clicked.connect(self._view.clicked.emit)
|
|
1169
|
+
|
|
1170
|
+
@staticmethod
|
|
1171
|
+
def _refresh_text(_view: ButtonViewLike, rendered_object: ComposeButton, value: str) -> None:
|
|
1172
|
+
rendered_object.label = Content(value)
|
|
1173
|
+
|
|
1174
|
+
@staticmethod
|
|
1175
|
+
def _refresh_variant(
|
|
1176
|
+
_view: ButtonViewLike, rendered_object: ComposeButton, value: ButtonVariant
|
|
1177
|
+
) -> None:
|
|
1178
|
+
rendered_object.variant = _BUTTON_VARIANTS[value]
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
class ProgressBarViewLike(ViewLike, Protocol):
|
|
1182
|
+
value: int
|
|
1183
|
+
maximum: int | None
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
class TextualProgressBarRenderer(TextualWidgetRenderer[ProgressBarViewLike, ProgressBar]):
|
|
1187
|
+
def __init__(self, view: ProgressBarViewLike) -> None:
|
|
1188
|
+
super().__init__(view)
|
|
1189
|
+
self._refresh_dispatcher.update(
|
|
1190
|
+
{"value": self._refresh_value, "maximum": self._refresh_maximum}
|
|
1191
|
+
)
|
|
1192
|
+
|
|
1193
|
+
def render(self) -> None:
|
|
1194
|
+
if self._rendered_object is None:
|
|
1195
|
+
# no time left: neither Qt nor the web estimates one
|
|
1196
|
+
self._rendered_object = ProgressBar(show_eta=False)
|
|
1197
|
+
|
|
1198
|
+
@staticmethod
|
|
1199
|
+
def _refresh_value(view: ProgressBarViewLike, rendered_object: ProgressBar, value: int) -> None:
|
|
1200
|
+
rendered_object.update(progress=ProgressBarView.clamp(value, view.maximum))
|
|
1201
|
+
|
|
1202
|
+
@staticmethod
|
|
1203
|
+
def _refresh_maximum(
|
|
1204
|
+
view: ProgressBarViewLike, rendered_object: ProgressBar, value: int | None
|
|
1205
|
+
) -> None:
|
|
1206
|
+
# no total is Textual's own bar for a task of unknown length
|
|
1207
|
+
rendered_object.update(total=value, progress=ProgressBarView.clamp(view.value, value))
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
class ChartViewLike(ViewLike, Protocol):
|
|
1211
|
+
kind: ChartKind
|
|
1212
|
+
title: str
|
|
1213
|
+
x_title: str
|
|
1214
|
+
y_title: str
|
|
1215
|
+
x_unit: str
|
|
1216
|
+
y_unit: str
|
|
1217
|
+
series: list[Series]
|
|
1218
|
+
reference_lines: list[ReferenceLine]
|
|
1219
|
+
categories: list[str]
|
|
1220
|
+
x_log: bool
|
|
1221
|
+
y_log: bool
|
|
1222
|
+
save_image_text: str
|
|
1223
|
+
height: int | None
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
#: the pixels of a terminal's row, about: a chart's height is in pixels
|
|
1227
|
+
PIXELS_PER_ROW = 16
|
|
1228
|
+
#: the rows a chart takes at least, to show its axes and a curve between them
|
|
1229
|
+
MIN_CHART_ROWS = 5
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
class TextualChartRenderer(TextualWidgetRenderer[ChartViewLike, ComposeChart]):
|
|
1233
|
+
def __init__(self, view: ChartViewLike) -> None:
|
|
1234
|
+
super().__init__(view)
|
|
1235
|
+
self._refresh_dispatcher.update(
|
|
1236
|
+
{
|
|
1237
|
+
"kind": self._refresh_kind,
|
|
1238
|
+
"title": self._refresh_title,
|
|
1239
|
+
"x_title": self._refresh_x_title,
|
|
1240
|
+
"x_unit": self._refresh_x_unit,
|
|
1241
|
+
"y_title": self._refresh_y_title,
|
|
1242
|
+
"y_unit": self._refresh_y_unit,
|
|
1243
|
+
"series": self._refresh_series,
|
|
1244
|
+
"reference_lines": self._refresh_reference_lines,
|
|
1245
|
+
"categories": self._refresh_categories,
|
|
1246
|
+
"x_log": self._refresh_x_log,
|
|
1247
|
+
"y_log": self._refresh_y_log,
|
|
1248
|
+
"save_image_text": self._refresh_save_image_text,
|
|
1249
|
+
"height": self._refresh_height,
|
|
1250
|
+
}
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
def render(self) -> None:
|
|
1254
|
+
if self._rendered_object is None:
|
|
1255
|
+
self._rendered_object = ComposeChart()
|
|
1256
|
+
self._rendered_object.image_path_chosen.connect(self._save_image)
|
|
1257
|
+
|
|
1258
|
+
@staticmethod
|
|
1259
|
+
def _refresh_kind(
|
|
1260
|
+
_view: ChartViewLike, rendered_object: ComposeChart, value: ChartKind
|
|
1261
|
+
) -> None:
|
|
1262
|
+
rendered_object.kind = value
|
|
1263
|
+
rendered_object.refresh()
|
|
1264
|
+
|
|
1265
|
+
# each refreshed with its new value, the view still holding the old one
|
|
1266
|
+
@staticmethod
|
|
1267
|
+
def _refresh_title(_view: ChartViewLike, rendered_object: ComposeChart, value: str) -> None:
|
|
1268
|
+
rendered_object.chart_title = value
|
|
1269
|
+
rendered_object.image_name = chartimage.image_name(value, "pdf")
|
|
1270
|
+
rendered_object.refresh()
|
|
1271
|
+
|
|
1272
|
+
@staticmethod
|
|
1273
|
+
def _refresh_x_title(view: ChartViewLike, rendered_object: ComposeChart, value: str) -> None:
|
|
1274
|
+
rendered_object.x_label = ChartView.axis_label(value, view.x_unit)
|
|
1275
|
+
rendered_object.refresh()
|
|
1276
|
+
|
|
1277
|
+
@staticmethod
|
|
1278
|
+
def _refresh_x_unit(view: ChartViewLike, rendered_object: ComposeChart, value: str) -> None:
|
|
1279
|
+
rendered_object.x_label = ChartView.axis_label(view.x_title, value)
|
|
1280
|
+
rendered_object.refresh()
|
|
1281
|
+
|
|
1282
|
+
@staticmethod
|
|
1283
|
+
def _refresh_y_title(view: ChartViewLike, rendered_object: ComposeChart, value: str) -> None:
|
|
1284
|
+
rendered_object.y_label = ChartView.axis_label(value, view.y_unit)
|
|
1285
|
+
rendered_object.refresh()
|
|
1286
|
+
|
|
1287
|
+
@staticmethod
|
|
1288
|
+
def _refresh_y_unit(view: ChartViewLike, rendered_object: ComposeChart, value: str) -> None:
|
|
1289
|
+
rendered_object.y_label = ChartView.axis_label(view.y_title, value)
|
|
1290
|
+
rendered_object.refresh()
|
|
1291
|
+
|
|
1292
|
+
@staticmethod
|
|
1293
|
+
def _refresh_series(
|
|
1294
|
+
_view: ChartViewLike, rendered_object: ComposeChart, value: list[Series]
|
|
1295
|
+
) -> None:
|
|
1296
|
+
rendered_object.series = value
|
|
1297
|
+
rendered_object.refresh()
|
|
1298
|
+
|
|
1299
|
+
@staticmethod
|
|
1300
|
+
def _refresh_reference_lines(
|
|
1301
|
+
_view: ChartViewLike, rendered_object: ComposeChart, value: list[ReferenceLine]
|
|
1302
|
+
) -> None:
|
|
1303
|
+
rendered_object.reference_lines = value
|
|
1304
|
+
rendered_object.refresh()
|
|
1305
|
+
|
|
1306
|
+
@staticmethod
|
|
1307
|
+
def _refresh_categories(
|
|
1308
|
+
_view: ChartViewLike, rendered_object: ComposeChart, value: list[str]
|
|
1309
|
+
) -> None:
|
|
1310
|
+
rendered_object.categories = value
|
|
1311
|
+
rendered_object.refresh()
|
|
1312
|
+
|
|
1313
|
+
@staticmethod
|
|
1314
|
+
def _refresh_x_log(_view: ChartViewLike, rendered_object: ComposeChart, value: bool) -> None:
|
|
1315
|
+
rendered_object.x_log = value
|
|
1316
|
+
rendered_object.refresh()
|
|
1317
|
+
|
|
1318
|
+
@staticmethod
|
|
1319
|
+
def _refresh_y_log(_view: ChartViewLike, rendered_object: ComposeChart, value: bool) -> None:
|
|
1320
|
+
rendered_object.y_log = value
|
|
1321
|
+
rendered_object.refresh()
|
|
1322
|
+
|
|
1323
|
+
@staticmethod
|
|
1324
|
+
def _refresh_save_image_text(
|
|
1325
|
+
_view: ChartViewLike, rendered_object: ComposeChart, value: str
|
|
1326
|
+
) -> None:
|
|
1327
|
+
rendered_object.set_save_image_text(value)
|
|
1328
|
+
|
|
1329
|
+
@staticmethod
|
|
1330
|
+
def _refresh_height(
|
|
1331
|
+
_view: ChartViewLike, rendered_object: ComposeChart, value: int | None
|
|
1332
|
+
) -> None:
|
|
1333
|
+
# None: the widget's own CSS height; its minimum too, under a smaller height
|
|
1334
|
+
rows = None if value is None else max(MIN_CHART_ROWS, round(value / PIXELS_PER_ROW))
|
|
1335
|
+
rendered_object.styles.height = rows
|
|
1336
|
+
rendered_object.styles.min_height = rows
|
|
1337
|
+
|
|
1338
|
+
def _save_image(self, path: Path) -> None:
|
|
1339
|
+
"""Save the whole chart's image: a terminal has no zoom."""
|
|
1340
|
+
assert isinstance(self._view, ChartView)
|
|
1341
|
+
chartimage.save_chart_image(self._view, path)
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
class MetricViewLike(ViewLike, Protocol):
|
|
1345
|
+
label: str
|
|
1346
|
+
value: str
|
|
1347
|
+
delta: float | None
|
|
1348
|
+
delta_text: str
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
class TextualMetricRenderer(TextualWidgetRenderer[MetricViewLike, ComposeMetric]):
|
|
1352
|
+
def __init__(self, view: MetricViewLike) -> None:
|
|
1353
|
+
super().__init__(view)
|
|
1354
|
+
self._refresh_dispatcher.update(
|
|
1355
|
+
{
|
|
1356
|
+
"label": self._refresh_label,
|
|
1357
|
+
"value": self._refresh_value,
|
|
1358
|
+
"delta": self._refresh_delta,
|
|
1359
|
+
"delta_text": self._refresh_delta_text,
|
|
1360
|
+
}
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
def render(self) -> None:
|
|
1364
|
+
if self._rendered_object is None:
|
|
1365
|
+
self._rendered_object = ComposeMetric()
|
|
1366
|
+
|
|
1367
|
+
@staticmethod
|
|
1368
|
+
def _refresh_label(_view: MetricViewLike, rendered_object: ComposeMetric, value: str) -> None:
|
|
1369
|
+
rendered_object.label.update(value)
|
|
1370
|
+
|
|
1371
|
+
@staticmethod
|
|
1372
|
+
def _refresh_value(_view: MetricViewLike, rendered_object: ComposeMetric, value: str) -> None:
|
|
1373
|
+
rendered_object.value.update(value)
|
|
1374
|
+
|
|
1375
|
+
@staticmethod
|
|
1376
|
+
def _refresh_delta(
|
|
1377
|
+
view: MetricViewLike, rendered_object: ComposeMetric, value: float | None
|
|
1378
|
+
) -> None:
|
|
1379
|
+
TextualMetricRenderer._show_badge(rendered_object, value, view.delta_text)
|
|
1380
|
+
|
|
1381
|
+
@staticmethod
|
|
1382
|
+
def _refresh_delta_text(
|
|
1383
|
+
view: MetricViewLike, rendered_object: ComposeMetric, value: str
|
|
1384
|
+
) -> None:
|
|
1385
|
+
TextualMetricRenderer._show_badge(rendered_object, view.delta, value)
|
|
1386
|
+
|
|
1387
|
+
@staticmethod
|
|
1388
|
+
def _show_badge(rendered_object: ComposeMetric, delta: float | None, delta_text: str) -> None:
|
|
1389
|
+
if delta is None:
|
|
1390
|
+
rendered_object.set_delta("", 0)
|
|
1391
|
+
else:
|
|
1392
|
+
sign = (delta > 0) - (delta < 0)
|
|
1393
|
+
rendered_object.set_delta(MetricView.badge(delta, delta_text), sign)
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
class SourceTableViewLike(ViewLike, Protocol):
|
|
1397
|
+
texts: dict[str, str]
|
|
1398
|
+
export_filename: str
|
|
1399
|
+
sort_column: str | None
|
|
1400
|
+
sort_descending: bool
|
|
1401
|
+
search: str
|
|
1402
|
+
filters: dict[str, str]
|
|
1403
|
+
filterable_columns: list[str]
|
|
1404
|
+
filter_texts: dict[str, str]
|
|
1405
|
+
|
|
1406
|
+
@property
|
|
1407
|
+
def headers(self) -> list[str]: ...
|
|
1408
|
+
@property
|
|
1409
|
+
def column_names(self) -> list[str]: ...
|
|
1410
|
+
@property
|
|
1411
|
+
def row_count(self) -> int: ...
|
|
1412
|
+
def rows(self, start: int, stop: int) -> list[tuple[Any, list[str]]]: ...
|
|
1413
|
+
def sort_by(self, column: str) -> None: ...
|
|
1414
|
+
def is_selected(self, row_id: Any) -> bool: ...
|
|
1415
|
+
def select_at(self, position: int) -> None: ...
|
|
1416
|
+
def extend_to(self, position: int) -> None: ...
|
|
1417
|
+
def activate_at(self, position: int) -> None: ...
|
|
1418
|
+
@property
|
|
1419
|
+
def count_text(self) -> str: ...
|
|
1420
|
+
def header(self, column: str) -> str: ...
|
|
1421
|
+
def filter_error(self, column: str, text: str) -> str | None: ...
|
|
1422
|
+
def filter_takes_pattern(self, column: str) -> bool: ...
|
|
1423
|
+
def filter_placeholder(self, column: str, *, is_pattern: bool = False) -> str: ...
|
|
1424
|
+
def filter_chip(self, column: str) -> str: ...
|
|
1425
|
+
def selection_text(self) -> str: ...
|
|
1426
|
+
def export_csv(self, path: Path) -> None: ...
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
class TextualSourceTableRenderer(
|
|
1430
|
+
TextualWidgetRenderer[SourceTableViewLike, ComposeDataFilterTable]
|
|
1431
|
+
):
|
|
1432
|
+
"""A large table: Textual asks the view for the lines it draws, as the user scrolls.
|
|
1433
|
+
|
|
1434
|
+
A click on a header sorts by its column (`SourceTableView.sort_by`). A click on a row,
|
|
1435
|
+
or Space on the cursor's, selects it (`select_at`), Shift extending the selection
|
|
1436
|
+
(`extend_to`); a double click or Enter activates it (`activate_at`).
|
|
1437
|
+
|
|
1438
|
+
Its filter bar (`ComposeFilterBar`) searches it, and filters the columns of
|
|
1439
|
+
`filterable_columns`: the bar knows them by their position among the source's.
|
|
1440
|
+
Ctrl+C copies the selected rows to the clipboard (`SourceTableView.selection_text`),
|
|
1441
|
+
through the terminal. Its key `e` asks where to write the rows as CSV (`export_csv`).
|
|
1442
|
+
While a job orders the rows (`is_ordering`), Textual's loading indicator covers them.
|
|
1443
|
+
"""
|
|
1444
|
+
|
|
1445
|
+
def __init__(self, view: SourceTableViewLike) -> None:
|
|
1446
|
+
super().__init__(view)
|
|
1447
|
+
self._refresh_dispatcher.update(
|
|
1448
|
+
{
|
|
1449
|
+
"source": self._refresh_source,
|
|
1450
|
+
"row_count": self._reset,
|
|
1451
|
+
"column_formats": self._repaint,
|
|
1452
|
+
"filter_texts": self._refresh_filter_texts,
|
|
1453
|
+
"filterable_columns": self._refresh_filterable_columns,
|
|
1454
|
+
"search": self._refresh_search,
|
|
1455
|
+
"filters": self._refresh_filters,
|
|
1456
|
+
# shown with the order they give, which the view sends as `row_count`
|
|
1457
|
+
"sort_column": lambda _view, _table, _value: None,
|
|
1458
|
+
"sort_descending": lambda _view, _table, _value: None,
|
|
1459
|
+
# each click asks the view, which knows the mode
|
|
1460
|
+
"selection_mode": lambda _view, _table, _value: None,
|
|
1461
|
+
"selected_ids": self._repaint,
|
|
1462
|
+
"texts": self._refresh_texts,
|
|
1463
|
+
"export_filename": self._refresh_export_filename,
|
|
1464
|
+
"is_ordering": self._refresh_is_ordering,
|
|
1465
|
+
}
|
|
1466
|
+
)
|
|
1467
|
+
|
|
1468
|
+
def render(self) -> None:
|
|
1469
|
+
if self._rendered_object is None:
|
|
1470
|
+
self._rendered_object = ComposeDataFilterTable(self._view)
|
|
1471
|
+
table, bar = self._rendered_object.table, self._rendered_object.bar
|
|
1472
|
+
columns = SourceTableByPosition(self._view)
|
|
1473
|
+
table.header_clicked.connect(columns.sort_by)
|
|
1474
|
+
table.row_clicked.connect(
|
|
1475
|
+
lambda position, is_extended: columns.select(position, is_extended=is_extended)
|
|
1476
|
+
)
|
|
1477
|
+
table.row_activated.connect(self._view.activate_at)
|
|
1478
|
+
table.copy_requested.connect(self._copy)
|
|
1479
|
+
table.export_path_chosen.connect(self._view.export_csv)
|
|
1480
|
+
bar.check = columns.filter_error
|
|
1481
|
+
bar.placeholder = lambda column, is_pattern: columns.filter_placeholder(
|
|
1482
|
+
column, is_pattern=is_pattern
|
|
1483
|
+
)
|
|
1484
|
+
bar.takes_pattern = columns.filter_takes_pattern
|
|
1485
|
+
bar.search_edited.connect(self._update_search)
|
|
1486
|
+
bar.filter_added.connect(columns.add_filter)
|
|
1487
|
+
bar.filter_removed.connect(columns.remove_filter)
|
|
1488
|
+
bar.filters_cleared.connect(columns.clear_filters)
|
|
1489
|
+
|
|
1490
|
+
@classmethod
|
|
1491
|
+
def _refresh_source(
|
|
1492
|
+
cls, view: SourceTableViewLike, rendered_object: ComposeDataFilterTable, value: Any
|
|
1493
|
+
) -> None:
|
|
1494
|
+
"""Show the new source's columns in the bar, then its rows."""
|
|
1495
|
+
cls._refresh_filterable_columns(view, rendered_object, view.filterable_columns)
|
|
1496
|
+
cls._refresh_filters(view, rendered_object, view.filters)
|
|
1497
|
+
cls._reset(view, rendered_object, value)
|
|
1498
|
+
|
|
1499
|
+
@staticmethod
|
|
1500
|
+
def _reset(
|
|
1501
|
+
view: SourceTableViewLike, rendered_object: ComposeDataFilterTable, _value: Any
|
|
1502
|
+
) -> None:
|
|
1503
|
+
"""Show a new order of the rows: the sort mark, the count, and no row if none."""
|
|
1504
|
+
sort = SourceTableByPosition(view).sort
|
|
1505
|
+
rendered_object.table.set_sort(sort)
|
|
1506
|
+
rendered_object.table.reset()
|
|
1507
|
+
rendered_object.bar.set_count(view.count_text)
|
|
1508
|
+
rendered_object.empty.display = view.row_count == 0 and view.count_text != ""
|
|
1509
|
+
|
|
1510
|
+
@staticmethod
|
|
1511
|
+
def _refresh_export_filename(
|
|
1512
|
+
_view: SourceTableViewLike, rendered_object: ComposeDataFilterTable, value: str
|
|
1513
|
+
) -> None:
|
|
1514
|
+
rendered_object.table.export_filename = value
|
|
1515
|
+
|
|
1516
|
+
@staticmethod
|
|
1517
|
+
def _refresh_is_ordering(
|
|
1518
|
+
_view: SourceTableViewLike, rendered_object: ComposeDataFilterTable, value: bool
|
|
1519
|
+
) -> None:
|
|
1520
|
+
rendered_object.table.loading = value
|
|
1521
|
+
|
|
1522
|
+
@staticmethod
|
|
1523
|
+
def _refresh_texts(
|
|
1524
|
+
_view: SourceTableViewLike, rendered_object: ComposeDataFilterTable, value: dict[str, str]
|
|
1525
|
+
) -> None:
|
|
1526
|
+
# "empty": the web's; here the headers stay, over no row
|
|
1527
|
+
rendered_object.table.set_export_text(value.get("export", ""))
|
|
1528
|
+
|
|
1529
|
+
@staticmethod
|
|
1530
|
+
def _refresh_filter_texts(
|
|
1531
|
+
view: SourceTableViewLike, rendered_object: ComposeDataFilterTable, value: dict[str, str]
|
|
1532
|
+
) -> None:
|
|
1533
|
+
rendered_object.bar.set_texts(value)
|
|
1534
|
+
rendered_object.bar.set_count(view.count_text)
|
|
1535
|
+
rendered_object.empty.update(value.get("no_match", ""))
|
|
1536
|
+
|
|
1537
|
+
@staticmethod
|
|
1538
|
+
def _refresh_filterable_columns(
|
|
1539
|
+
view: SourceTableViewLike, rendered_object: ComposeDataFilterTable, value: list[str]
|
|
1540
|
+
) -> None:
|
|
1541
|
+
columns = SourceTableByPosition(view).bar_columns(value)
|
|
1542
|
+
rendered_object.bar.set_columns(columns)
|
|
1543
|
+
rendered_object.bar.display = len(columns) > 0
|
|
1544
|
+
|
|
1545
|
+
@staticmethod
|
|
1546
|
+
def _refresh_search(
|
|
1547
|
+
_view: SourceTableViewLike, rendered_object: ComposeDataFilterTable, value: str
|
|
1548
|
+
) -> None:
|
|
1549
|
+
rendered_object.bar.set_search(value)
|
|
1550
|
+
|
|
1551
|
+
@staticmethod
|
|
1552
|
+
def _refresh_filters(
|
|
1553
|
+
view: SourceTableViewLike, rendered_object: ComposeDataFilterTable, value: dict[str, str]
|
|
1554
|
+
) -> None:
|
|
1555
|
+
rendered_object.bar.set_filters(SourceTableByPosition(view).chips(value))
|
|
1556
|
+
|
|
1557
|
+
def _copy(self) -> None:
|
|
1558
|
+
app = self._get_app()
|
|
1559
|
+
assert app is not None
|
|
1560
|
+
app.copy_to_clipboard(self._view.selection_text())
|
|
1561
|
+
|
|
1562
|
+
def _update_search(self, text: str) -> None:
|
|
1563
|
+
with self.stop_refresh():
|
|
1564
|
+
self._view.search = text
|
|
1565
|
+
|
|
1566
|
+
@staticmethod
|
|
1567
|
+
def _repaint(
|
|
1568
|
+
_view: SourceTableViewLike, rendered_object: ComposeDataFilterTable, _value: Any
|
|
1569
|
+
) -> None:
|
|
1570
|
+
rendered_object.table.repaint()
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
class TableViewLike(ViewLike, Protocol):
|
|
1574
|
+
headers: list[str]
|
|
1575
|
+
filter_texts: dict[str, str]
|
|
1576
|
+
selection_mode: SelectionMode
|
|
1577
|
+
rows: list[list[str]]
|
|
1578
|
+
sortable_columns: list[int]
|
|
1579
|
+
filterable_columns: list[int]
|
|
1580
|
+
sort_column: int | None
|
|
1581
|
+
sort_descending: bool
|
|
1582
|
+
search: str
|
|
1583
|
+
filters: dict[int, str]
|
|
1584
|
+
selected_rows: list[int]
|
|
1585
|
+
shown_rows: list[int]
|
|
1586
|
+
selection_changed: Signal[list[int]]
|
|
1587
|
+
editable_columns: list[int]
|
|
1588
|
+
cell_edited: Signal[int, int, str]
|
|
1589
|
+
cell_widgets: dict[int, CellWidget]
|
|
1590
|
+
cell_clicked: Signal[int, int]
|
|
1591
|
+
cell_styles: dict[tuple[int, int], CellStyle]
|
|
1592
|
+
header_groups: list[HeaderGroup]
|
|
1593
|
+
row_headers: list[str]
|
|
1594
|
+
|
|
1595
|
+
def emit_cell_edited(self, row: int, column: int, text: str) -> None: ...
|
|
1596
|
+
def mark_text(self, mark: NodeMark) -> str: ...
|
|
1597
|
+
def shown_cell_styles(self) -> dict[tuple[int, int], CellStyle]: ...
|
|
1598
|
+
def column_checked(self, column: int) -> bool | None: ...
|
|
1599
|
+
def check_all(self, column: int, *, is_checked: bool) -> None: ...
|
|
1600
|
+
def sort_by(self, column: int) -> None: ...
|
|
1601
|
+
def filter_error(self, column: int, text: str) -> str | None: ...
|
|
1602
|
+
def filter_placeholder(self, column: int, *, is_pattern: bool = False) -> str: ...
|
|
1603
|
+
def filter_takes_pattern(self, column: int) -> bool: ...
|
|
1604
|
+
def filter_chip(self, column: int) -> str: ...
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
class TextualTableRenderer(TextualWidgetRenderer[TableViewLike, ComposeFilterTable]):
|
|
1608
|
+
"""A table under its filter bar; the table shows `shown_rows`, in their order.
|
|
1609
|
+
|
|
1610
|
+
The widgets know the rows only as shown: their indexes map back to `rows` through
|
|
1611
|
+
`shown_rows`.
|
|
1612
|
+
"""
|
|
1613
|
+
|
|
1614
|
+
def __init__(self, view: TableViewLike) -> None:
|
|
1615
|
+
super().__init__(view)
|
|
1616
|
+
self._refresh_dispatcher.update(
|
|
1617
|
+
{
|
|
1618
|
+
"headers": self._refresh_headers,
|
|
1619
|
+
"filter_texts": self._refresh_filter_texts,
|
|
1620
|
+
"selection_mode": self._refresh_selection_mode,
|
|
1621
|
+
# shown with `shown_rows`, which the view always sends next
|
|
1622
|
+
"rows": lambda _view, _table, _value: None,
|
|
1623
|
+
"sortable_columns": self._refresh_sort,
|
|
1624
|
+
"filterable_columns": self._refresh_filterable_columns,
|
|
1625
|
+
"sort_column": self._refresh_sort,
|
|
1626
|
+
"sort_descending": self._refresh_sort,
|
|
1627
|
+
"search": self._refresh_search,
|
|
1628
|
+
"filters": self._refresh_filters,
|
|
1629
|
+
"selected_rows": self._refresh_selected_rows,
|
|
1630
|
+
"shown_rows": self._refresh_shown_rows,
|
|
1631
|
+
"editable_columns": self._refresh_editable_columns,
|
|
1632
|
+
"cell_widgets": self._refresh_cell_widgets,
|
|
1633
|
+
"cell_styles": self._refresh_looks,
|
|
1634
|
+
"header_groups": self._refresh_header_groups,
|
|
1635
|
+
"row_headers": self._refresh_looks,
|
|
1636
|
+
# a terminal's cell has no tooltip: the mark's glyph says it
|
|
1637
|
+
"done_text": lambda _view, _table, _value: None,
|
|
1638
|
+
"warning_text": lambda _view, _table, _value: None,
|
|
1639
|
+
"error_text": lambda _view, _table, _value: None,
|
|
1640
|
+
}
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
def render(self) -> None:
|
|
1644
|
+
if self._rendered_object is None:
|
|
1645
|
+
self._rendered_object = ComposeFilterTable()
|
|
1646
|
+
table, bar = self._rendered_object.table, self._rendered_object.bar
|
|
1647
|
+
table.selected_rows_changed.connect(self._update_selected_rows)
|
|
1648
|
+
table.cell_edited.connect(self._update_cell)
|
|
1649
|
+
table.cell_clicked.connect(self._click_cell)
|
|
1650
|
+
table.check_all_clicked.connect(self._check_all)
|
|
1651
|
+
table.header_clicked.connect(self._sort_by)
|
|
1652
|
+
bar.check = self._view.filter_error
|
|
1653
|
+
bar.placeholder = lambda column, is_pattern: self._view.filter_placeholder(
|
|
1654
|
+
column, is_pattern=is_pattern
|
|
1655
|
+
)
|
|
1656
|
+
bar.takes_pattern = self._view.filter_takes_pattern
|
|
1657
|
+
bar.search_edited.connect(self._update_search)
|
|
1658
|
+
bar.filter_added.connect(self._add_filter)
|
|
1659
|
+
bar.filter_removed.connect(self._remove_filter)
|
|
1660
|
+
bar.filters_cleared.connect(self._clear_filters)
|
|
1661
|
+
|
|
1662
|
+
@classmethod
|
|
1663
|
+
def _refresh_headers(
|
|
1664
|
+
cls, view: TableViewLike, rendered_object: ComposeFilterTable, _value: list[str]
|
|
1665
|
+
) -> None:
|
|
1666
|
+
cls._refresh_shown_rows(view, rendered_object, view.shown_rows)
|
|
1667
|
+
cls._refresh_filterable_columns(view, rendered_object, view.filterable_columns)
|
|
1668
|
+
cls._refresh_filters(view, rendered_object, view.filters)
|
|
1669
|
+
|
|
1670
|
+
@staticmethod
|
|
1671
|
+
def _refresh_filter_texts(
|
|
1672
|
+
view: TableViewLike, rendered_object: ComposeFilterTable, value: dict[str, str]
|
|
1673
|
+
) -> None:
|
|
1674
|
+
rendered_object.bar.set_texts(value)
|
|
1675
|
+
rendered_object.bar.set_count(_count_text(view, view.shown_rows))
|
|
1676
|
+
rendered_object.empty.update(value.get("no_match", ""))
|
|
1677
|
+
|
|
1678
|
+
@staticmethod
|
|
1679
|
+
def _refresh_selection_mode(
|
|
1680
|
+
_view: TableViewLike, rendered_object: ComposeFilterTable, value: SelectionMode
|
|
1681
|
+
) -> None:
|
|
1682
|
+
rendered_object.table.set_multiple_selection(is_multiple=value is SelectionMode.MULTIPLE)
|
|
1683
|
+
|
|
1684
|
+
@staticmethod
|
|
1685
|
+
def _refresh_sort(
|
|
1686
|
+
view: TableViewLike, rendered_object: ComposeFilterTable, _value: Any
|
|
1687
|
+
) -> None:
|
|
1688
|
+
is_sorted = view.sort_column is not None and view.sort_column in view.sortable_columns
|
|
1689
|
+
sort = (view.sort_column, view.sort_descending) if is_sorted else None
|
|
1690
|
+
rendered_object.table.set_sort(cast("tuple[int, bool] | None", sort))
|
|
1691
|
+
|
|
1692
|
+
@staticmethod
|
|
1693
|
+
def _refresh_filterable_columns(
|
|
1694
|
+
view: TableViewLike, rendered_object: ComposeFilterTable, value: list[int]
|
|
1695
|
+
) -> None:
|
|
1696
|
+
columns = [(column, view.headers[column]) for column in value if column < len(view.headers)]
|
|
1697
|
+
rendered_object.bar.set_columns(columns)
|
|
1698
|
+
rendered_object.bar.display = len(columns) > 0
|
|
1699
|
+
|
|
1700
|
+
@staticmethod
|
|
1701
|
+
def _refresh_search(
|
|
1702
|
+
_view: TableViewLike, rendered_object: ComposeFilterTable, value: str
|
|
1703
|
+
) -> None:
|
|
1704
|
+
rendered_object.bar.set_search(value)
|
|
1705
|
+
|
|
1706
|
+
@staticmethod
|
|
1707
|
+
def _refresh_filters(
|
|
1708
|
+
view: TableViewLike, rendered_object: ComposeFilterTable, value: dict[int, str]
|
|
1709
|
+
) -> None:
|
|
1710
|
+
rendered_object.bar.set_filters(
|
|
1711
|
+
[
|
|
1712
|
+
(column, view.filter_chip(column), text, view.headers[column])
|
|
1713
|
+
for column, text in sorted(value.items())
|
|
1714
|
+
if column < len(view.headers)
|
|
1715
|
+
]
|
|
1716
|
+
)
|
|
1717
|
+
|
|
1718
|
+
@staticmethod
|
|
1719
|
+
def _positions(view: TableViewLike, rows: list[int]) -> list[int]:
|
|
1720
|
+
"""Get where `rows` show, in order."""
|
|
1721
|
+
positions = {row: position for position, row in enumerate(view.shown_rows)}
|
|
1722
|
+
return sorted(positions[row] for row in rows if row in positions)
|
|
1723
|
+
|
|
1724
|
+
@classmethod
|
|
1725
|
+
def _refresh_selected_rows(
|
|
1726
|
+
cls, view: TableViewLike, rendered_object: ComposeFilterTable, value: list[int]
|
|
1727
|
+
) -> None:
|
|
1728
|
+
rendered_object.table.set_selected_rows(cls._positions(view, value))
|
|
1729
|
+
|
|
1730
|
+
@classmethod
|
|
1731
|
+
def _refresh_shown_rows(
|
|
1732
|
+
cls, view: TableViewLike, rendered_object: ComposeFilterTable, value: list[int]
|
|
1733
|
+
) -> None:
|
|
1734
|
+
rendered_object.table.fill(
|
|
1735
|
+
view.headers,
|
|
1736
|
+
[view.rows[row] for row in value],
|
|
1737
|
+
cls._positions(view, view.selected_rows),
|
|
1738
|
+
view.shown_cell_styles(),
|
|
1739
|
+
[view.row_headers[row] if row < len(view.row_headers) else "" for row in value]
|
|
1740
|
+
if len(view.row_headers) > 0
|
|
1741
|
+
else [],
|
|
1742
|
+
)
|
|
1743
|
+
rendered_object.bar.set_count(_count_text(view, value))
|
|
1744
|
+
rendered_object.empty.display = len(value) == 0 and len(view.rows) > 0
|
|
1745
|
+
cls._refresh_checked_columns(view, rendered_object)
|
|
1746
|
+
|
|
1747
|
+
@staticmethod
|
|
1748
|
+
def _refresh_checked_columns(
|
|
1749
|
+
view: TableViewLike,
|
|
1750
|
+
rendered_object: ComposeFilterTable,
|
|
1751
|
+
*,
|
|
1752
|
+
cell_widgets: dict[int, CellWidget] | None = None,
|
|
1753
|
+
) -> None:
|
|
1754
|
+
"""Show each check-all box; with the new widgets, which the view doesn't hold yet."""
|
|
1755
|
+
widgets = view.cell_widgets if cell_widgets is None else cell_widgets
|
|
1756
|
+
rendered_object.table.set_checked_columns(
|
|
1757
|
+
{
|
|
1758
|
+
column: view.column_checked(column)
|
|
1759
|
+
for column, widget in widgets.items()
|
|
1760
|
+
if widget.kind is CellKind.CHECK_BOX and widget.check_all
|
|
1761
|
+
}
|
|
1762
|
+
)
|
|
1763
|
+
|
|
1764
|
+
@staticmethod
|
|
1765
|
+
def _refresh_editable_columns(
|
|
1766
|
+
_view: TableViewLike, rendered_object: ComposeFilterTable, value: list[int]
|
|
1767
|
+
) -> None:
|
|
1768
|
+
rendered_object.table.editable_columns = value
|
|
1769
|
+
|
|
1770
|
+
@classmethod
|
|
1771
|
+
def _refresh_cell_widgets(
|
|
1772
|
+
cls, view: TableViewLike, rendered_object: ComposeFilterTable, value: dict[int, CellWidget]
|
|
1773
|
+
) -> None:
|
|
1774
|
+
rendered_object.table.set_cell_widgets(value)
|
|
1775
|
+
cls._refresh_checked_columns(view, rendered_object, cell_widgets=value)
|
|
1776
|
+
|
|
1777
|
+
@classmethod
|
|
1778
|
+
def _refresh_looks(
|
|
1779
|
+
cls, view: TableViewLike, rendered_object: ComposeFilterTable, _value: Any
|
|
1780
|
+
) -> None:
|
|
1781
|
+
cls._refresh_shown_rows(view, rendered_object, view.shown_rows)
|
|
1782
|
+
|
|
1783
|
+
@staticmethod
|
|
1784
|
+
def _refresh_header_groups(
|
|
1785
|
+
_view: TableViewLike, rendered_object: ComposeFilterTable, value: list[HeaderGroup]
|
|
1786
|
+
) -> None:
|
|
1787
|
+
rendered_object.table.set_header_groups(value)
|
|
1788
|
+
|
|
1789
|
+
def _update_selected_rows(self, positions: list[int]) -> None:
|
|
1790
|
+
shown = self._view.shown_rows
|
|
1791
|
+
rows = sorted(shown[position] for position in positions)
|
|
1792
|
+
with self.stop_refresh():
|
|
1793
|
+
self._view.selected_rows = rows
|
|
1794
|
+
self._view.selection_changed.emit(rows)
|
|
1795
|
+
|
|
1796
|
+
def _update_cell(self, position: int, column: int, text: str) -> None:
|
|
1797
|
+
rows, row = self._view.rows, self._view.shown_rows[position]
|
|
1798
|
+
self._view.emit_cell_edited(row, column, text)
|
|
1799
|
+
if self._view.rows is rows:
|
|
1800
|
+
with self.stop_refresh():
|
|
1801
|
+
self._view.rows = replace_cell(rows, row, column, text)
|
|
1802
|
+
assert self._rendered_object is not None
|
|
1803
|
+
# a check-all box follows its column's
|
|
1804
|
+
self._refresh_checked_columns(self._view, self._rendered_object)
|
|
1805
|
+
|
|
1806
|
+
def _click_cell(self, position: int, column: int) -> None:
|
|
1807
|
+
self._view.cell_clicked.emit(self._view.shown_rows[position], column)
|
|
1808
|
+
|
|
1809
|
+
def _check_all(self, column: int) -> None:
|
|
1810
|
+
if column in self._view.editable_columns:
|
|
1811
|
+
self._view.check_all(column, is_checked=self._view.column_checked(column) is not True)
|
|
1812
|
+
|
|
1813
|
+
def _sort_by(self, column: int) -> None:
|
|
1814
|
+
if column in self._view.sortable_columns:
|
|
1815
|
+
self._view.sort_by(column)
|
|
1816
|
+
|
|
1817
|
+
def _update_search(self, text: str) -> None:
|
|
1818
|
+
with self.stop_refresh():
|
|
1819
|
+
self._view.search = text
|
|
1820
|
+
|
|
1821
|
+
def _add_filter(self, column: int, text: str) -> None:
|
|
1822
|
+
self._view.filters = {**self._view.filters, column: text}
|
|
1823
|
+
|
|
1824
|
+
def _remove_filter(self, column: int) -> None:
|
|
1825
|
+
self._view.filters = {
|
|
1826
|
+
other: text for other, text in self._view.filters.items() if other != column
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
def _clear_filters(self) -> None:
|
|
1830
|
+
self._view.filters = {}
|
|
1831
|
+
|
|
1832
|
+
|
|
1833
|
+
def _count_text(view: TableViewLike, shown: list[int]) -> str:
|
|
1834
|
+
"""Count the rows shown, while the search or the filters hide some."""
|
|
1835
|
+
if len(shown) == len(view.rows):
|
|
1836
|
+
return ""
|
|
1837
|
+
return view.filter_texts.get("count", "").format(shown=len(shown), total=len(view.rows))
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
class ValuesViewLike(ViewLike, Protocol, Generic[TValue]):
|
|
1841
|
+
value: TValue | None
|
|
1842
|
+
values: dict[str, TValue]
|
|
1843
|
+
value_changed: Signal[TValue | None]
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
class TextualRadioGroupRenderer(
|
|
1847
|
+
TextualWidgetRenderer[ValuesViewLike[TValue], ComposeRadioSet[TValue]], Generic[TValue]
|
|
1848
|
+
):
|
|
1849
|
+
_marks_invalid = True
|
|
1850
|
+
|
|
1851
|
+
def __init__(self, view: ValuesViewLike[TValue]) -> None:
|
|
1852
|
+
super().__init__(view)
|
|
1853
|
+
self._refresh_dispatcher.update(
|
|
1854
|
+
{
|
|
1855
|
+
"value": self._refresh_value,
|
|
1856
|
+
"values": self._refresh_values,
|
|
1857
|
+
}
|
|
1858
|
+
)
|
|
1859
|
+
|
|
1860
|
+
def render(self) -> None:
|
|
1861
|
+
if self._rendered_object is None:
|
|
1862
|
+
self._rendered_object = ComposeRadioSet(self._view.values)
|
|
1863
|
+
self._rendered_object.value_changed.connect(self._update_value)
|
|
1864
|
+
|
|
1865
|
+
@staticmethod
|
|
1866
|
+
def _refresh_value(
|
|
1867
|
+
_view: ValuesViewLike[TValue], rendered_object: ComposeRadioSet[TValue], value: Any
|
|
1868
|
+
) -> None:
|
|
1869
|
+
# `set_values`'s rebuild (remove_children/mount) is asynchronous; selecting
|
|
1870
|
+
# synchronously right after races with it and corrupts RadioSet's own
|
|
1871
|
+
# bookkeeping (see `_refresh_values`) - defer until the rebuild has landed.
|
|
1872
|
+
rendered_object.call_after_refresh(rendered_object.set_value, value)
|
|
1873
|
+
|
|
1874
|
+
@staticmethod
|
|
1875
|
+
def _refresh_values(
|
|
1876
|
+
view: ValuesViewLike[TValue],
|
|
1877
|
+
rendered_object: ComposeRadioSet[TValue],
|
|
1878
|
+
values: dict[str, Any],
|
|
1879
|
+
) -> None:
|
|
1880
|
+
# rebuilding leaves nothing selected, even if `view.value` didn't itself change
|
|
1881
|
+
# (and so wouldn't otherwise trigger its own refresh) - reapply it explicitly
|
|
1882
|
+
rendered_object.set_values(values)
|
|
1883
|
+
rendered_object.call_after_refresh(rendered_object.set_value, view.value)
|
|
1884
|
+
|
|
1885
|
+
def _update_value(self, value: TValue | None) -> None:
|
|
1886
|
+
with self.stop_refresh():
|
|
1887
|
+
self._view.value = value
|
|
1888
|
+
self._view.value_changed.emit(value)
|
|
1889
|
+
|
|
1890
|
+
|
|
1891
|
+
class TextualComboBoxRenderer(
|
|
1892
|
+
TextualWidgetRenderer[ValuesViewLike[TValue], ComposeComboBox[TValue]], Generic[TValue]
|
|
1893
|
+
):
|
|
1894
|
+
_marks_invalid = True
|
|
1895
|
+
|
|
1896
|
+
def __init__(self, view: ValuesViewLike[TValue]) -> None:
|
|
1897
|
+
super().__init__(view)
|
|
1898
|
+
self._refresh_dispatcher.update(
|
|
1899
|
+
{
|
|
1900
|
+
"value": self._refresh_value,
|
|
1901
|
+
"values": self._refresh_values,
|
|
1902
|
+
}
|
|
1903
|
+
)
|
|
1904
|
+
|
|
1905
|
+
def render(self) -> None:
|
|
1906
|
+
if self._rendered_object is None:
|
|
1907
|
+
self._rendered_object = ComposeComboBox(self._view.values)
|
|
1908
|
+
self._rendered_object.value_changed.connect(self._update_value)
|
|
1909
|
+
|
|
1910
|
+
@staticmethod
|
|
1911
|
+
def _refresh_value(
|
|
1912
|
+
_view: ValuesViewLike[TValue], rendered_object: ComposeComboBox[TValue], value: Any
|
|
1913
|
+
) -> None:
|
|
1914
|
+
rendered_object.call_after_refresh(rendered_object.set_value, value)
|
|
1915
|
+
|
|
1916
|
+
@staticmethod
|
|
1917
|
+
def _refresh_values(
|
|
1918
|
+
view: ValuesViewLike[TValue],
|
|
1919
|
+
rendered_object: ComposeComboBox[TValue],
|
|
1920
|
+
values: dict[str, Any],
|
|
1921
|
+
) -> None:
|
|
1922
|
+
rendered_object.set_values(values)
|
|
1923
|
+
rendered_object.call_after_refresh(rendered_object.set_value, view.value)
|
|
1924
|
+
|
|
1925
|
+
def _update_value(self, value: TValue | None) -> None:
|
|
1926
|
+
with self.stop_refresh():
|
|
1927
|
+
self._view.value = value
|
|
1928
|
+
self._view.value_changed.emit(value)
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
class LogViewLike(ViewLike, Protocol):
|
|
1932
|
+
log_message: str
|
|
1933
|
+
log_type: LogType
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
class TextualLogRenderer(TextualWidgetRenderer[LogViewLike, Label]):
|
|
1937
|
+
def __init__(self, view: LogViewLike) -> None:
|
|
1938
|
+
super().__init__(view)
|
|
1939
|
+
self._refresh_dispatcher.update(
|
|
1940
|
+
{
|
|
1941
|
+
"log_message": self._refresh_log_message,
|
|
1942
|
+
"log_type": self._refresh_log_type,
|
|
1943
|
+
}
|
|
1944
|
+
)
|
|
1945
|
+
|
|
1946
|
+
def render(self) -> None:
|
|
1947
|
+
if self._rendered_object is None:
|
|
1948
|
+
self._rendered_object = Label(markup=False)
|
|
1949
|
+
# default `width: auto` sizes to the unwrapped text - fine for a short
|
|
1950
|
+
# label, but a long validation message would silently overflow past the
|
|
1951
|
+
# terminal width and get clipped instead of wrapping.
|
|
1952
|
+
self._rendered_object.styles.width = "1fr"
|
|
1953
|
+
|
|
1954
|
+
@staticmethod
|
|
1955
|
+
def _refresh_log_message(_view: LogViewLike, rendered_object: Label, value: str) -> None:
|
|
1956
|
+
rendered_object.display = value != ""
|
|
1957
|
+
rendered_object.update(value)
|
|
1958
|
+
|
|
1959
|
+
@staticmethod
|
|
1960
|
+
def _refresh_log_type(_view: LogViewLike, rendered_object: Label, value: LogType) -> None:
|
|
1961
|
+
if value == LogType.ERROR:
|
|
1962
|
+
rendered_object.styles.color = "red"
|
|
1963
|
+
elif value == LogType.WARNING:
|
|
1964
|
+
rendered_object.styles.color = "orange"
|
|
1965
|
+
else:
|
|
1966
|
+
rendered_object.styles.color = None
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
class TextualStatusBarRenderer(TextualWidgetRenderer[StatusBar, Static]):
|
|
1970
|
+
def __init__(self, view: StatusBar) -> None:
|
|
1971
|
+
super().__init__(view)
|
|
1972
|
+
self._refresh_dispatcher.update(
|
|
1973
|
+
{
|
|
1974
|
+
"message": self._refresh_message,
|
|
1975
|
+
}
|
|
1976
|
+
)
|
|
1977
|
+
|
|
1978
|
+
def render(self) -> None:
|
|
1979
|
+
if self._rendered_object is None:
|
|
1980
|
+
self._rendered_object = Static(markup=False)
|
|
1981
|
+
self._rendered_object.styles.dock = "bottom"
|
|
1982
|
+
|
|
1983
|
+
@staticmethod
|
|
1984
|
+
def _refresh_message(_view: StatusBar, rendered_object: Static, value: str) -> None:
|
|
1985
|
+
rendered_object.update(value)
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
class TextualFormRenderer(TextualWidgetRenderer[FormView, Vertical]):
|
|
1989
|
+
def render(self) -> None:
|
|
1990
|
+
if self._rendered_object is None:
|
|
1991
|
+
rows: list[Widget] = []
|
|
1992
|
+
for field_row in self._view.field_rows:
|
|
1993
|
+
if isinstance(field_row, FormRowView) and isinstance(
|
|
1994
|
+
field_row.renderer, RendererContainer
|
|
1995
|
+
):
|
|
1996
|
+
row_widgets: list[Widget] = []
|
|
1997
|
+
if isinstance(field_row, FormLabelRowView):
|
|
1998
|
+
row_widgets.append(self._get_label_widget(field_row.renderer))
|
|
1999
|
+
row_widgets.append(self._get_field_widget(field_row.renderer))
|
|
2000
|
+
row_widgets.append(self._get_log_widget(field_row.renderer))
|
|
2001
|
+
row = Vertical(*row_widgets)
|
|
2002
|
+
# `Vertical`'s default `height: 1fr` stretches to fill its parent
|
|
2003
|
+
# instead of sizing to its content, which quietly defeats
|
|
2004
|
+
# `VerticalScroll` (nothing ever appears to overflow the viewport).
|
|
2005
|
+
row.styles.height = "auto"
|
|
2006
|
+
rows.append(row)
|
|
2007
|
+
elif isinstance(field_row, FormView):
|
|
2008
|
+
assert isinstance(field_row.renderer, TextualWidgetRenderer)
|
|
2009
|
+
assert field_row.renderer.widget is not None
|
|
2010
|
+
rows.append(field_row.renderer.widget)
|
|
2011
|
+
# else a row whose field this backend doesn't register: left out
|
|
2012
|
+
log = self._view.log_view.renderer
|
|
2013
|
+
assert isinstance(log, TextualWidgetRenderer)
|
|
2014
|
+
assert log.widget is not None
|
|
2015
|
+
rows.append(log.widget)
|
|
2016
|
+
self._rendered_object = Vertical(*rows)
|
|
2017
|
+
self._rendered_object.styles.height = "auto"
|
|
2018
|
+
|
|
2019
|
+
@staticmethod
|
|
2020
|
+
def _get_field_widget(field_row_renderer: RendererContainer[View]) -> Widget:
|
|
2021
|
+
field_renderer = field_row_renderer.get_renderer("field")
|
|
2022
|
+
assert isinstance(field_renderer, TextualWidgetRenderer)
|
|
2023
|
+
assert field_renderer.widget is not None
|
|
2024
|
+
return field_renderer.widget
|
|
2025
|
+
|
|
2026
|
+
@staticmethod
|
|
2027
|
+
def _get_log_widget(field_row_renderer: RendererContainer[View]) -> Widget:
|
|
2028
|
+
log_renderer = field_row_renderer.get_renderer("log")
|
|
2029
|
+
assert isinstance(log_renderer, TextualLogRenderer)
|
|
2030
|
+
assert log_renderer.widget is not None
|
|
2031
|
+
return log_renderer.widget
|
|
2032
|
+
|
|
2033
|
+
@staticmethod
|
|
2034
|
+
def _get_label_widget(field_row_renderer: RendererContainer[View]) -> Widget:
|
|
2035
|
+
label_renderer = field_row_renderer.get_renderer("label")
|
|
2036
|
+
assert isinstance(label_renderer, TextualFormLabelRenderer)
|
|
2037
|
+
assert label_renderer.widget is not None
|
|
2038
|
+
return label_renderer.widget
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
class TextualGroupFormRenderer(TextualFormRenderer):
|
|
2042
|
+
def __init__(self, view: GroupFormView) -> None:
|
|
2043
|
+
super().__init__(view)
|
|
2044
|
+
self._refresh_dispatcher.update(
|
|
2045
|
+
{
|
|
2046
|
+
"title": self._refresh_title,
|
|
2047
|
+
}
|
|
2048
|
+
)
|
|
2049
|
+
|
|
2050
|
+
def render(self) -> None:
|
|
2051
|
+
is_first_render = self._rendered_object is None
|
|
2052
|
+
super().render()
|
|
2053
|
+
if is_first_render:
|
|
2054
|
+
assert self._rendered_object is not None
|
|
2055
|
+
# `border_title` has no effect without an actual border to attach it to -
|
|
2056
|
+
# without this, every group (e.g. pipeform.py's "Main Pipe"/"Incident Pipe")
|
|
2057
|
+
# renders as a plain, undecorated box indistinguishable from the rest of
|
|
2058
|
+
# the form, and adjacent groups run into each other with no separation.
|
|
2059
|
+
self._rendered_object.styles.border = ("round", "gray")
|
|
2060
|
+
self._rendered_object.styles.padding = (0, 1)
|
|
2061
|
+
|
|
2062
|
+
@staticmethod
|
|
2063
|
+
def _refresh_title(_view: FormView, rendered_object: Vertical, value: str) -> None:
|
|
2064
|
+
rendered_object.border_title = Content(value)
|
|
2065
|
+
|
|
2066
|
+
|
|
2067
|
+
class TextualPageRenderer(TextualWidgetRenderer[PageView, Vertical]):
|
|
2068
|
+
"""A page's views in a column, like the screen's own."""
|
|
2069
|
+
|
|
2070
|
+
def render(self) -> None:
|
|
2071
|
+
if self._rendered_object is None:
|
|
2072
|
+
widgets = [
|
|
2073
|
+
view.renderer.widget
|
|
2074
|
+
for view in self._view.content_views
|
|
2075
|
+
if isinstance(view.renderer, TextualWidgetRenderer)
|
|
2076
|
+
and view.renderer.widget is not None
|
|
2077
|
+
]
|
|
2078
|
+
self._rendered_object = Vertical(*widgets)
|
|
2079
|
+
self._rendered_object.styles.height = "auto"
|
|
2080
|
+
|
|
2081
|
+
|
|
2082
|
+
class TextualHorizontalRenderer(TextualWidgetRenderer[HorizontalView, Horizontal]):
|
|
2083
|
+
"""Views side by side, each as wide as its share of the row."""
|
|
2084
|
+
|
|
2085
|
+
def render(self) -> None:
|
|
2086
|
+
if self._rendered_object is None:
|
|
2087
|
+
widgets: list[Widget] = []
|
|
2088
|
+
for view, stretch in self._view.stretched_views:
|
|
2089
|
+
if (
|
|
2090
|
+
isinstance(view.renderer, TextualWidgetRenderer)
|
|
2091
|
+
and view.renderer.widget is not None
|
|
2092
|
+
):
|
|
2093
|
+
# no share: as wide as its content
|
|
2094
|
+
view.renderer.widget.styles.width = f"{stretch}fr" if stretch > 0 else "auto"
|
|
2095
|
+
widgets.append(view.renderer.widget)
|
|
2096
|
+
self._rendered_object = Horizontal(*widgets)
|
|
2097
|
+
self._rendered_object.styles.height = "auto"
|
|
2098
|
+
if self._view.align is Align.END:
|
|
2099
|
+
self._rendered_object.styles.align_horizontal = "right"
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
class TextualVerticalRenderer(TextualWidgetRenderer[VerticalView, Vertical]):
|
|
2103
|
+
"""Views one above the other, at the top of the column."""
|
|
2104
|
+
|
|
2105
|
+
def render(self) -> None:
|
|
2106
|
+
if self._rendered_object is None:
|
|
2107
|
+
widgets = [
|
|
2108
|
+
view.renderer.widget
|
|
2109
|
+
for view in self._view.children_views
|
|
2110
|
+
if isinstance(view.renderer, TextualWidgetRenderer)
|
|
2111
|
+
and view.renderer.widget is not None
|
|
2112
|
+
]
|
|
2113
|
+
self._rendered_object = Vertical(*widgets)
|
|
2114
|
+
self._rendered_object.styles.height = "auto"
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
class TextualSplitterRenderer(TextualWidgetRenderer[SplitterView, ComposeSplitter]):
|
|
2118
|
+
"""The panes in a row or a column, a handle between two the user drags or moves by keys."""
|
|
2119
|
+
|
|
2120
|
+
def __init__(self, view: SplitterView) -> None:
|
|
2121
|
+
super().__init__(view)
|
|
2122
|
+
self._refresh_dispatcher["sizes"] = self._refresh_sizes
|
|
2123
|
+
# names a border on the web only
|
|
2124
|
+
self._refresh_dispatcher["border_text"] = lambda _view, _splitter, _value: None
|
|
2125
|
+
|
|
2126
|
+
def render(self) -> None:
|
|
2127
|
+
if self._rendered_object is None:
|
|
2128
|
+
panes = [
|
|
2129
|
+
view.renderer.widget
|
|
2130
|
+
for view in self._view.panes
|
|
2131
|
+
if isinstance(view.renderer, TextualWidgetRenderer)
|
|
2132
|
+
and view.renderer.widget is not None
|
|
2133
|
+
]
|
|
2134
|
+
self._rendered_object = ComposeSplitter(
|
|
2135
|
+
*panes, is_vertical=self._view.orientation is Orientation.VERTICAL
|
|
2136
|
+
)
|
|
2137
|
+
self._rendered_object.resized.connect(self._update_sizes)
|
|
2138
|
+
|
|
2139
|
+
@staticmethod
|
|
2140
|
+
def _refresh_sizes(
|
|
2141
|
+
_view: SplitterView, rendered_object: ComposeSplitter, value: list[int]
|
|
2142
|
+
) -> None:
|
|
2143
|
+
rendered_object.set_sizes(value)
|
|
2144
|
+
|
|
2145
|
+
def _update_sizes(self, lengths: list[int]) -> None:
|
|
2146
|
+
sizes = self._view.shares_of(lengths)
|
|
2147
|
+
if sizes == self._view.sizes: # a click, or a move against a pane's minimum
|
|
2148
|
+
return
|
|
2149
|
+
with self.stop_refresh():
|
|
2150
|
+
self._view.sizes = sizes
|
|
2151
|
+
self._view.resized.emit(sizes)
|
|
2152
|
+
|
|
2153
|
+
|
|
2154
|
+
class TextualExpanderRenderer(TextualWidgetRenderer[ExpanderView, ComposeCollapsible]):
|
|
2155
|
+
"""Textual's collapsible: its title, then the views one above the other while expanded."""
|
|
2156
|
+
|
|
2157
|
+
def __init__(self, view: ExpanderView) -> None:
|
|
2158
|
+
super().__init__(view)
|
|
2159
|
+
self._refresh_dispatcher.update(
|
|
2160
|
+
{"title": self._refresh_title, "is_expanded": self._refresh_is_expanded}
|
|
2161
|
+
)
|
|
2162
|
+
|
|
2163
|
+
def render(self) -> None:
|
|
2164
|
+
if self._rendered_object is None:
|
|
2165
|
+
widgets = [
|
|
2166
|
+
view.renderer.widget
|
|
2167
|
+
for view in self._view.children_views
|
|
2168
|
+
if isinstance(view.renderer, TextualWidgetRenderer)
|
|
2169
|
+
and view.renderer.widget is not None
|
|
2170
|
+
]
|
|
2171
|
+
self._rendered_object = ComposeCollapsible(*widgets)
|
|
2172
|
+
self._rendered_object.toggled.connect(self._update_expanded)
|
|
2173
|
+
|
|
2174
|
+
@staticmethod
|
|
2175
|
+
def _refresh_title(
|
|
2176
|
+
_view: ExpanderView, rendered_object: ComposeCollapsible, value: str
|
|
2177
|
+
) -> None:
|
|
2178
|
+
rendered_object.title = value
|
|
2179
|
+
|
|
2180
|
+
@staticmethod
|
|
2181
|
+
def _refresh_is_expanded(
|
|
2182
|
+
_view: ExpanderView, rendered_object: ComposeCollapsible, value: bool
|
|
2183
|
+
) -> None:
|
|
2184
|
+
rendered_object.set_expanded(value)
|
|
2185
|
+
|
|
2186
|
+
def _update_expanded(self, is_expanded: bool) -> None:
|
|
2187
|
+
with self.stop_refresh():
|
|
2188
|
+
self._view.is_expanded = is_expanded
|
|
2189
|
+
self._view.toggled.emit(is_expanded)
|
|
2190
|
+
|
|
2191
|
+
|
|
2192
|
+
class TextualSidebarRenderer(TextualWidgetRenderer[SidebarView, ComposeSidebar]):
|
|
2193
|
+
"""A column on the screen's left: its title and an arrow retracting it, then its views."""
|
|
2194
|
+
|
|
2195
|
+
def __init__(self, view: SidebarView) -> None:
|
|
2196
|
+
super().__init__(view)
|
|
2197
|
+
self._refresh_dispatcher.update(
|
|
2198
|
+
{
|
|
2199
|
+
"title": self._refresh_title,
|
|
2200
|
+
"toggle_text": self._refresh_toggle_text,
|
|
2201
|
+
"is_expanded": self._refresh_is_expanded,
|
|
2202
|
+
}
|
|
2203
|
+
)
|
|
2204
|
+
|
|
2205
|
+
def render(self) -> None:
|
|
2206
|
+
if self._rendered_object is None:
|
|
2207
|
+
widgets = [
|
|
2208
|
+
view.renderer.widget
|
|
2209
|
+
for view in self._view.children_views
|
|
2210
|
+
if isinstance(view.renderer, TextualWidgetRenderer)
|
|
2211
|
+
and view.renderer.widget is not None
|
|
2212
|
+
]
|
|
2213
|
+
self._rendered_object = ComposeSidebar(*widgets)
|
|
2214
|
+
self._rendered_object.toggled.connect(self._update_expanded)
|
|
2215
|
+
|
|
2216
|
+
@staticmethod
|
|
2217
|
+
def _refresh_title(_view: SidebarView, rendered_object: ComposeSidebar, value: str) -> None:
|
|
2218
|
+
rendered_object.set_title(value)
|
|
2219
|
+
|
|
2220
|
+
@staticmethod
|
|
2221
|
+
def _refresh_toggle_text(
|
|
2222
|
+
_view: SidebarView, rendered_object: ComposeSidebar, value: str
|
|
2223
|
+
) -> None:
|
|
2224
|
+
rendered_object.toggle.tooltip = Content(value)
|
|
2225
|
+
|
|
2226
|
+
@staticmethod
|
|
2227
|
+
def _refresh_is_expanded(
|
|
2228
|
+
_view: SidebarView, rendered_object: ComposeSidebar, value: bool
|
|
2229
|
+
) -> None:
|
|
2230
|
+
rendered_object.set_expanded(value)
|
|
2231
|
+
|
|
2232
|
+
def _update_expanded(self, is_expanded: bool) -> None:
|
|
2233
|
+
with self.stop_refresh():
|
|
2234
|
+
self._view.is_expanded = is_expanded
|
|
2235
|
+
self._view.toggled.emit(is_expanded)
|
|
2236
|
+
|
|
2237
|
+
|
|
2238
|
+
class TextualListRenderer(TextualWidgetRenderer["ListView[Any]", ComposeList]):
|
|
2239
|
+
"""The records' views one above the other, added, moved and removed as they change.
|
|
2240
|
+
|
|
2241
|
+
Widgets only mount in a running app: the column is put in step with the records once
|
|
2242
|
+
it is mounted (`_refresh_tree` refreshes it then too), a record's widget built since
|
|
2243
|
+
mounted next to its neighbours, then given its view's state.
|
|
2244
|
+
"""
|
|
2245
|
+
|
|
2246
|
+
def __init__(self, view: ListView[Any]) -> None:
|
|
2247
|
+
super().__init__(view)
|
|
2248
|
+
self._refresh_dispatcher.update({"item_views": self._refresh_item_views})
|
|
2249
|
+
|
|
2250
|
+
def render(self) -> None:
|
|
2251
|
+
if self._rendered_object is None:
|
|
2252
|
+
self._rendered_object = ComposeList(*self._widgets(self._view.item_views))
|
|
2253
|
+
|
|
2254
|
+
@staticmethod
|
|
2255
|
+
def _widgets(views: list[View]) -> list[Widget]:
|
|
2256
|
+
"""Get the views' widgets, rendering the views built since the list was."""
|
|
2257
|
+
widgets: list[Widget] = []
|
|
2258
|
+
for view in views:
|
|
2259
|
+
if view.renderer is None:
|
|
2260
|
+
view.render(RendererType.TEXTUAL)
|
|
2261
|
+
if (
|
|
2262
|
+
isinstance(view.renderer, TextualWidgetRenderer)
|
|
2263
|
+
and view.renderer.widget is not None
|
|
2264
|
+
):
|
|
2265
|
+
widgets.append(view.renderer.widget)
|
|
2266
|
+
return widgets
|
|
2267
|
+
|
|
2268
|
+
@staticmethod
|
|
2269
|
+
def _refresh_item_views(_view: ListView[Any], column: ComposeList, value: list[View]) -> None:
|
|
2270
|
+
widgets = TextualListRenderer._widgets(value)
|
|
2271
|
+
if not column.is_attached:
|
|
2272
|
+
return # put in step once mounted
|
|
2273
|
+
for child in list(column.children):
|
|
2274
|
+
if child not in widgets:
|
|
2275
|
+
child.remove() # its record is gone
|
|
2276
|
+
previous: Widget | None = None
|
|
2277
|
+
for view, widget in zip(value, widgets):
|
|
2278
|
+
if widget.parent is column:
|
|
2279
|
+
if previous is not None:
|
|
2280
|
+
column.move_child(widget, after=previous)
|
|
2281
|
+
elif column.children[0] is not widget:
|
|
2282
|
+
column.move_child(widget, before=0)
|
|
2283
|
+
else:
|
|
2284
|
+
if previous is not None:
|
|
2285
|
+
mounting = column.mount(widget, after=previous)
|
|
2286
|
+
elif len(column.children) > 0:
|
|
2287
|
+
mounting = column.mount(widget, before=0)
|
|
2288
|
+
else:
|
|
2289
|
+
mounting = column.mount(widget)
|
|
2290
|
+
column.call_next(TextualListRenderer._refresh_once_mounted, mounting, view)
|
|
2291
|
+
previous = widget
|
|
2292
|
+
|
|
2293
|
+
@staticmethod
|
|
2294
|
+
async def _refresh_once_mounted(mounting: AwaitMount, view: View) -> None:
|
|
2295
|
+
"""Give `view`'s widgets its state once mounted: Textual's widgets take it only then."""
|
|
2296
|
+
await mounting
|
|
2297
|
+
_refresh_tree(view)
|
|
2298
|
+
|
|
2299
|
+
|
|
2300
|
+
class TextualTreeRenderer(TextualWidgetRenderer[TreeView, ComposeTree]):
|
|
2301
|
+
def __init__(self, view: TreeView) -> None:
|
|
2302
|
+
super().__init__(view)
|
|
2303
|
+
self._refresh_dispatcher.update(
|
|
2304
|
+
{
|
|
2305
|
+
"nodes": self._refresh_nodes,
|
|
2306
|
+
"expanded": self._refresh_expanded,
|
|
2307
|
+
"selected": self._refresh_selected,
|
|
2308
|
+
# a terminal shows the marks' glyphs, not their names
|
|
2309
|
+
"done_text": lambda _view, _rendered_object, _value: None,
|
|
2310
|
+
"warning_text": lambda _view, _rendered_object, _value: None,
|
|
2311
|
+
"error_text": lambda _view, _rendered_object, _value: None,
|
|
2312
|
+
}
|
|
2313
|
+
)
|
|
2314
|
+
|
|
2315
|
+
def render(self) -> None:
|
|
2316
|
+
if self._rendered_object is None:
|
|
2317
|
+
self._rendered_object = ComposeTree()
|
|
2318
|
+
self._rendered_object.selected.connect(self._update_selected)
|
|
2319
|
+
self._rendered_object.toggled.connect(self._update_expanded)
|
|
2320
|
+
|
|
2321
|
+
@staticmethod
|
|
2322
|
+
def _refresh_nodes(view: TreeView, rendered_object: ComposeTree, value: list[TreeNode]) -> None:
|
|
2323
|
+
# the view's, already kept to those still in the nodes
|
|
2324
|
+
rendered_object.set_nodes(value, view.expanded, view.selected)
|
|
2325
|
+
|
|
2326
|
+
@staticmethod
|
|
2327
|
+
def _refresh_expanded(
|
|
2328
|
+
_view: TreeView, rendered_object: ComposeTree, value: list[TreePath]
|
|
2329
|
+
) -> None:
|
|
2330
|
+
rendered_object.set_expanded(value)
|
|
2331
|
+
|
|
2332
|
+
@staticmethod
|
|
2333
|
+
def _refresh_selected(
|
|
2334
|
+
_view: TreeView, rendered_object: ComposeTree, value: TreePath | None
|
|
2335
|
+
) -> None:
|
|
2336
|
+
rendered_object.set_selected(value)
|
|
2337
|
+
|
|
2338
|
+
def _update_selected(self, path: TreePath) -> None:
|
|
2339
|
+
with self.stop_refresh():
|
|
2340
|
+
self._view.selected = path
|
|
2341
|
+
self._view.selection_changed.emit(path)
|
|
2342
|
+
|
|
2343
|
+
def _update_expanded(self, path: TreePath, is_expanded: bool) -> None:
|
|
2344
|
+
with self.stop_refresh():
|
|
2345
|
+
self._view.expanded = self._view.expanded_with(path, is_expanded=is_expanded)
|
|
2346
|
+
self._view.toggled.emit(path, is_expanded)
|
|
2347
|
+
|
|
2348
|
+
|
|
2349
|
+
class TextualTabPageRenderer(TextualWidgetRenderer[TabPage, TabPane]):
|
|
2350
|
+
"""A tab's views in a pane; its title, visibility and enabled state are its tab's."""
|
|
2351
|
+
|
|
2352
|
+
def __init__(self, view: TabPage) -> None:
|
|
2353
|
+
super().__init__(view)
|
|
2354
|
+
self._refresh_dispatcher.update(
|
|
2355
|
+
{
|
|
2356
|
+
"title": self._refresh_title,
|
|
2357
|
+
"is_visible": self._refresh_tab_visible,
|
|
2358
|
+
"is_enabled": self._refresh_tab_enabled,
|
|
2359
|
+
}
|
|
2360
|
+
)
|
|
2361
|
+
|
|
2362
|
+
def render(self) -> None:
|
|
2363
|
+
if self._rendered_object is None:
|
|
2364
|
+
widgets = [
|
|
2365
|
+
view.renderer.widget
|
|
2366
|
+
for view in self._view.children_views
|
|
2367
|
+
if isinstance(view.renderer, TextualWidgetRenderer)
|
|
2368
|
+
and view.renderer.widget is not None
|
|
2369
|
+
]
|
|
2370
|
+
# an id: the tabs tell their panes by it
|
|
2371
|
+
self._rendered_object = TabPane(
|
|
2372
|
+
Content(self._view.title), *widgets, id=f"cui-{id(self._view)}-tab"
|
|
2373
|
+
)
|
|
2374
|
+
|
|
2375
|
+
@staticmethod
|
|
2376
|
+
def _tabs(pane: TabPane) -> ComposeTabbedContent | None:
|
|
2377
|
+
"""Get the tabs `pane` is a tab of, once mounted."""
|
|
2378
|
+
if not pane.is_attached:
|
|
2379
|
+
return None
|
|
2380
|
+
return next((a for a in pane.ancestors if isinstance(a, ComposeTabbedContent)), None)
|
|
2381
|
+
|
|
2382
|
+
@staticmethod
|
|
2383
|
+
def _refresh_title(_view: TabPage, rendered_object: TabPane, value: str) -> None:
|
|
2384
|
+
tabs = TextualTabPageRenderer._tabs(rendered_object)
|
|
2385
|
+
if tabs is not None:
|
|
2386
|
+
tabs.get_tab(rendered_object).label = Content(value)
|
|
2387
|
+
|
|
2388
|
+
@staticmethod
|
|
2389
|
+
def _refresh_tab_visible(_view: TabPage, rendered_object: TabPane, value: bool) -> None:
|
|
2390
|
+
# not the pane's own display: only the current tab's pane shows
|
|
2391
|
+
tabs = TextualTabPageRenderer._tabs(rendered_object)
|
|
2392
|
+
if tabs is not None and rendered_object.id is not None:
|
|
2393
|
+
if value:
|
|
2394
|
+
tabs.show_tab(rendered_object.id)
|
|
2395
|
+
else:
|
|
2396
|
+
tabs.hide_tab(rendered_object.id)
|
|
2397
|
+
|
|
2398
|
+
@staticmethod
|
|
2399
|
+
def _refresh_tab_enabled(_view: TabPage, rendered_object: TabPane, value: bool) -> None:
|
|
2400
|
+
rendered_object.disabled = not value # its tab follows
|
|
2401
|
+
|
|
2402
|
+
|
|
2403
|
+
class TextualTabsRenderer(TextualWidgetRenderer[TabView, ComposeTabbedContent]):
|
|
2404
|
+
"""Textual's tabbed content: the tabs in a row, the current one's pane under them."""
|
|
2405
|
+
|
|
2406
|
+
def __init__(self, view: TabView) -> None:
|
|
2407
|
+
super().__init__(view)
|
|
2408
|
+
self._refresh_dispatcher["current_index"] = self._refresh_current_index
|
|
2409
|
+
|
|
2410
|
+
def render(self) -> None:
|
|
2411
|
+
if self._rendered_object is None:
|
|
2412
|
+
panes = [
|
|
2413
|
+
tab.renderer.widget
|
|
2414
|
+
for tab in self._view.tabs
|
|
2415
|
+
if isinstance(tab.renderer, TextualTabPageRenderer)
|
|
2416
|
+
and tab.renderer.widget is not None
|
|
2417
|
+
]
|
|
2418
|
+
# on the view's tab: Textual reports the first tab shown as picked
|
|
2419
|
+
tabs = self._view.tabs
|
|
2420
|
+
initial = self._pane_id(tabs[self._view.current_index]) if len(tabs) > 0 else ""
|
|
2421
|
+
self._rendered_object = ComposeTabbedContent(*panes, initial=initial)
|
|
2422
|
+
self._rendered_object.picked.connect(self._update_current_index)
|
|
2423
|
+
|
|
2424
|
+
@staticmethod
|
|
2425
|
+
def _pane_id(tab: TabPage) -> str:
|
|
2426
|
+
"""Get the id of `tab`'s pane, which the tabs tell it by."""
|
|
2427
|
+
renderer = tab.renderer
|
|
2428
|
+
assert isinstance(renderer, TextualTabPageRenderer)
|
|
2429
|
+
assert renderer.widget is not None
|
|
2430
|
+
assert renderer.widget.id is not None # given by the tab page's renderer
|
|
2431
|
+
return renderer.widget.id
|
|
2432
|
+
|
|
2433
|
+
@staticmethod
|
|
2434
|
+
def _refresh_current_index(
|
|
2435
|
+
view: TabView, rendered_object: ComposeTabbedContent, value: int
|
|
2436
|
+
) -> None:
|
|
2437
|
+
rendered_object.set_active(TextualTabsRenderer._pane_id(view.tabs[value]))
|
|
2438
|
+
|
|
2439
|
+
@staticmethod
|
|
2440
|
+
def _index(view: TabView, pane_id: str) -> int | None:
|
|
2441
|
+
"""Get the index of the tab whose pane is `pane_id`."""
|
|
2442
|
+
ids = [TextualTabsRenderer._pane_id(tab) for tab in view.tabs]
|
|
2443
|
+
return ids.index(pane_id) if pane_id in ids else None
|
|
2444
|
+
|
|
2445
|
+
def _update_current_index(self, pane: TabPane) -> None:
|
|
2446
|
+
index = self._index(self._view, pane.id or "")
|
|
2447
|
+
if index is None or index == self._view.current_index:
|
|
2448
|
+
return
|
|
2449
|
+
with self.stop_refresh():
|
|
2450
|
+
self._view.current_index = index
|
|
2451
|
+
self._view.current_changed.emit(index)
|
|
2452
|
+
|
|
2453
|
+
|
|
2454
|
+
class TextualGridRenderer(TextualWidgetRenderer[GridView, Grid]):
|
|
2455
|
+
"""Views in a grid's cells: equally wide columns, rows as tall as their views.
|
|
2456
|
+
|
|
2457
|
+
Textual's grid flows its widgets into the next free cell: each view sits in a cell
|
|
2458
|
+
widget, empty for cells no view takes, so a hidden view leaves its cell in place.
|
|
2459
|
+
"""
|
|
2460
|
+
|
|
2461
|
+
def render(self) -> None:
|
|
2462
|
+
if self._rendered_object is None:
|
|
2463
|
+
at_cell = {
|
|
2464
|
+
(position.row, position.column): (view, position)
|
|
2465
|
+
for view, position in self._view.positioned_views
|
|
2466
|
+
}
|
|
2467
|
+
taken: set[tuple[int, int]] = set()
|
|
2468
|
+
cells: list[Widget] = []
|
|
2469
|
+
for row in range(self._view.row_count):
|
|
2470
|
+
for column in range(self._view.column_count):
|
|
2471
|
+
if (row, column) in taken:
|
|
2472
|
+
continue
|
|
2473
|
+
view, position = at_cell.get((row, column), (None, GridPosition(row, column)))
|
|
2474
|
+
taken.update(position.cells)
|
|
2475
|
+
cell = Container()
|
|
2476
|
+
if (
|
|
2477
|
+
view is not None
|
|
2478
|
+
and isinstance(view.renderer, TextualWidgetRenderer)
|
|
2479
|
+
and view.renderer.widget is not None
|
|
2480
|
+
):
|
|
2481
|
+
# with no share of a row, as wide as its content
|
|
2482
|
+
view.renderer.widget.styles.width = (
|
|
2483
|
+
"1fr" if view.default_stretch > 0 else "auto"
|
|
2484
|
+
)
|
|
2485
|
+
cell = Container(view.renderer.widget)
|
|
2486
|
+
cell.styles.row_span = position.row_span
|
|
2487
|
+
cell.styles.column_span = position.column_span
|
|
2488
|
+
cell.styles.height = "auto"
|
|
2489
|
+
cells.append(cell)
|
|
2490
|
+
self._rendered_object = Grid(*cells)
|
|
2491
|
+
self._rendered_object.styles.grid_size_columns = self._view.column_count
|
|
2492
|
+
self._rendered_object.styles.grid_columns = "1fr"
|
|
2493
|
+
self._rendered_object.styles.grid_rows = "auto"
|
|
2494
|
+
self._rendered_object.styles.height = "auto"
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
class TextualRouterRenderer(TextualWidgetRenderer[Router, ContentSwitcher]):
|
|
2498
|
+
"""The pages, one shown at a time, and a toolbar of their actions docked at the top.
|
|
2499
|
+
|
|
2500
|
+
Each page sits in a slot of its own: the switcher shows and hides its slots, which
|
|
2501
|
+
leaves the page's own `is_visible` to the page.
|
|
2502
|
+
"""
|
|
2503
|
+
|
|
2504
|
+
def __init__(self, view: Router) -> None:
|
|
2505
|
+
super().__init__(view)
|
|
2506
|
+
#: the toolbar of the pages' actions, docked by the main renderer
|
|
2507
|
+
self.toolbar: ComposeToolbar | None = None
|
|
2508
|
+
self._refresh_dispatcher.update({"current_route": self._refresh_current_route})
|
|
2509
|
+
|
|
2510
|
+
def render(self) -> None:
|
|
2511
|
+
if self._rendered_object is None:
|
|
2512
|
+
buttons: list[Widget] = []
|
|
2513
|
+
slots: list[Widget] = []
|
|
2514
|
+
for page in self._view.pages:
|
|
2515
|
+
assert isinstance(page.action.renderer, TextualActionRenderer)
|
|
2516
|
+
button = ComposeToolbarButton()
|
|
2517
|
+
button.triggered.connect(page.action.triggered.emit)
|
|
2518
|
+
page.action.renderer.button = button
|
|
2519
|
+
buttons.append(button)
|
|
2520
|
+
page_widgets = (
|
|
2521
|
+
[page.renderer.widget]
|
|
2522
|
+
if isinstance(page.renderer, TextualPageRenderer)
|
|
2523
|
+
and page.renderer.widget is not None
|
|
2524
|
+
else []
|
|
2525
|
+
)
|
|
2526
|
+
slot = Vertical(*page_widgets, id=_slot_id(page.route))
|
|
2527
|
+
slot.styles.height = "auto"
|
|
2528
|
+
slots.append(slot)
|
|
2529
|
+
self.toolbar = ComposeToolbar(*buttons)
|
|
2530
|
+
self._rendered_object = ContentSwitcher(
|
|
2531
|
+
*slots, initial=_slot_id(self._view.current_route)
|
|
2532
|
+
)
|
|
2533
|
+
|
|
2534
|
+
@staticmethod
|
|
2535
|
+
def _refresh_current_route(_view: Router, rendered_object: ContentSwitcher, value: str) -> None:
|
|
2536
|
+
rendered_object.current = _slot_id(value)
|
|
2537
|
+
|
|
2538
|
+
|
|
2539
|
+
class TextualToolBarRenderer(TextualWidgetRenderer[ToolBar, ComposeToolbar]):
|
|
2540
|
+
"""A row of buttons, one per action, docked at the top by the main renderer."""
|
|
2541
|
+
|
|
2542
|
+
def __init__(self, view: ToolBar) -> None:
|
|
2543
|
+
super().__init__(view)
|
|
2544
|
+
self._refresh_dispatcher.update({"title": self._noop})
|
|
2545
|
+
|
|
2546
|
+
def render(self) -> None:
|
|
2547
|
+
if self._rendered_object is None:
|
|
2548
|
+
widgets: list[Widget] = []
|
|
2549
|
+
for child_view in self._view.children_views:
|
|
2550
|
+
if isinstance(child_view, SeparatorAction):
|
|
2551
|
+
widgets.append(ComposeToolbarSeparator())
|
|
2552
|
+
elif isinstance(child_view, ActionView) and isinstance(
|
|
2553
|
+
child_view.renderer, TextualActionRenderer
|
|
2554
|
+
):
|
|
2555
|
+
button = ComposeToolbarButton()
|
|
2556
|
+
button.triggered.connect(functools.partial(self._trigger, child_view))
|
|
2557
|
+
child_view.renderer.button = button
|
|
2558
|
+
widgets.append(button)
|
|
2559
|
+
self._rendered_object = ComposeToolbar(*widgets)
|
|
2560
|
+
|
|
2561
|
+
def _trigger(self, action_view: ActionView) -> None:
|
|
2562
|
+
app = self._get_app()
|
|
2563
|
+
assert isinstance(app, _ComposeUIApp)
|
|
2564
|
+
app.trigger(action_view)
|
|
2565
|
+
|
|
2566
|
+
@staticmethod
|
|
2567
|
+
def _noop(_view: ToolBar, _rendered_object: ComposeToolbar, _value: Any) -> None:
|
|
2568
|
+
pass # a row of buttons has no title to show
|
|
2569
|
+
|
|
2570
|
+
|
|
2571
|
+
def _slot_id(route: str) -> str:
|
|
2572
|
+
"""Get the id of a page's slot in its router's switcher (routes are valid ids)."""
|
|
2573
|
+
return f"page-{route}"
|
|
2574
|
+
|
|
2575
|
+
|
|
2576
|
+
def _refresh_tree(root: View) -> None:
|
|
2577
|
+
"""Push each view's current state into its already-built (default) widget.
|
|
2578
|
+
|
|
2579
|
+
Widgets are constructed bottom-up before the app runs (mirroring the Qt
|
|
2580
|
+
backend), but Textual reactives like `Input.value` need a running app to be
|
|
2581
|
+
*set* at all - so unlike Qt, the initial state can't be pushed in during that
|
|
2582
|
+
same construction pass. This walks the tree once the app has mounted, when it's
|
|
2583
|
+
finally safe to do so.
|
|
2584
|
+
"""
|
|
2585
|
+
views: deque[View] = deque([root])
|
|
2586
|
+
while views:
|
|
2587
|
+
current = views.popleft()
|
|
2588
|
+
if isinstance(current.renderer, (Renderer, RendererContainer)):
|
|
2589
|
+
current.renderer.refresh()
|
|
2590
|
+
views.extend(current.children_views)
|
|
2591
|
+
|
|
2592
|
+
|
|
2593
|
+
def _to_textual_key(shortcut: str) -> str:
|
|
2594
|
+
"""Convert a Qt-style shortcut (`"Ctrl+Shift+s"`) to Textual's key format (`"ctrl+shift+s"`)."""
|
|
2595
|
+
return "+".join(part.lower() for part in shortcut.split("+"))
|
|
2596
|
+
|
|
2597
|
+
|
|
2598
|
+
# Actions that can show a blocking file-picker modal - see `_ComposeUIApp.action_trigger`.
|
|
2599
|
+
_FILE_DIALOG_ACTIONS = (OpenFileAction, PickFileAction, SaveAction, SaveAsAction)
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
def _register_actions(app: _ComposeUIApp, root: View) -> None:
|
|
2603
|
+
"""Turn every `ActionView` with a shortcut into a key binding, discoverable via `Footer`.
|
|
2604
|
+
|
|
2605
|
+
Menus aren't rendered as widgets under Textual (see `TextualMenuRenderer`); their
|
|
2606
|
+
actions are exposed as app-wide key bindings instead, mirroring the "Menu/Action
|
|
2607
|
+
style: key bindings + Footer" scope chosen for this backend.
|
|
2608
|
+
"""
|
|
2609
|
+
views: deque[View] = deque([root])
|
|
2610
|
+
while views:
|
|
2611
|
+
current = views.popleft()
|
|
2612
|
+
if isinstance(current, ActionView):
|
|
2613
|
+
if current.shortcut is not None:
|
|
2614
|
+
app.bind_action(current, current.shortcut)
|
|
2615
|
+
# every action, a shortcut set later binding it then
|
|
2616
|
+
assert isinstance(current.renderer, TextualActionRenderer)
|
|
2617
|
+
current.renderer.app = app
|
|
2618
|
+
views.extend(current.children_views)
|
|
2619
|
+
|
|
2620
|
+
|
|
2621
|
+
class _ComposeUIApp(App[None]):
|
|
2622
|
+
def __init__(self, view: MainView, root: Widget) -> None:
|
|
2623
|
+
self._view = view
|
|
2624
|
+
self._root = root
|
|
2625
|
+
self.registered_actions: dict[str, ActionView] = {}
|
|
2626
|
+
#: called once the app is mounted, e.g. to start a timer, which needs it running
|
|
2627
|
+
self.mounted_callbacks: list[Callable[[], None]] = []
|
|
2628
|
+
super().__init__()
|
|
2629
|
+
|
|
2630
|
+
def compose(self) -> ComposeResult:
|
|
2631
|
+
yield self._root
|
|
2632
|
+
yield Footer()
|
|
2633
|
+
|
|
2634
|
+
def on_mount(self) -> None:
|
|
2635
|
+
self.title = self._view.title
|
|
2636
|
+
_refresh_tree(self._view)
|
|
2637
|
+
_register_actions(self, self._view)
|
|
2638
|
+
for callback in self.mounted_callbacks:
|
|
2639
|
+
callback()
|
|
2640
|
+
|
|
2641
|
+
async def action_quit(self) -> None:
|
|
2642
|
+
"""Quit as the Exit action does, as Textual's Ctrl+Q has priority over its key."""
|
|
2643
|
+
if self._view.has_file_menu:
|
|
2644
|
+
self.trigger(self._view.file_menu.exit_app) # what the app connected to it too
|
|
2645
|
+
else:
|
|
2646
|
+
self._view.close() # its jobs canceled, as by Exit
|
|
2647
|
+
|
|
2648
|
+
def action_trigger(self, action_id: str) -> None:
|
|
2649
|
+
self.trigger(self.registered_actions[action_id])
|
|
2650
|
+
|
|
2651
|
+
def trigger(self, action_view: ActionView) -> None:
|
|
2652
|
+
"""Trigger an action from its key binding or its toolbar button."""
|
|
2653
|
+
if isinstance(action_view, _FILE_DIALOG_ACTIONS):
|
|
2654
|
+
# `ask_open_filepath`/`ask_save_filepath` block waiting for a modal file
|
|
2655
|
+
# picker; blocking the app's own thread that way would freeze the very
|
|
2656
|
+
# loop that has to render and drive that modal, so this specific trigger
|
|
2657
|
+
# runs on a worker thread instead (`TextualMainRenderer.open_file`/
|
|
2658
|
+
# `save_file` bridge back via `call_from_thread`). Everything else
|
|
2659
|
+
# dispatches directly, matching Qt's immediate, synchronous `emit()`.
|
|
2660
|
+
self.run_worker(action_view.triggered.emit, thread=True)
|
|
2661
|
+
else:
|
|
2662
|
+
action_view.triggered.emit()
|
|
2663
|
+
|
|
2664
|
+
def bind_action(self, action_view: ActionView, shortcut: str) -> None:
|
|
2665
|
+
"""Bind `shortcut` to an action, its text shown in the footer."""
|
|
2666
|
+
action_id = str(id(action_view))
|
|
2667
|
+
self.registered_actions[action_id] = action_view
|
|
2668
|
+
self.bind(
|
|
2669
|
+
_to_textual_key(shortcut),
|
|
2670
|
+
f"trigger({action_id!r})",
|
|
2671
|
+
description=_action_label(
|
|
2672
|
+
text=action_view.text,
|
|
2673
|
+
is_checkable=action_view.is_checkable,
|
|
2674
|
+
is_checked=action_view.is_checked,
|
|
2675
|
+
),
|
|
2676
|
+
)
|
|
2677
|
+
|
|
2678
|
+
def rebind_action(
|
|
2679
|
+
self, action_view: ActionView, previous: str | None, shortcut: str | None
|
|
2680
|
+
) -> None:
|
|
2681
|
+
"""Bind an action to `shortcut` instead of `previous`; `None` for no key."""
|
|
2682
|
+
if previous is not None:
|
|
2683
|
+
key = _to_textual_key(previous)
|
|
2684
|
+
action = f"trigger({str(id(action_view))!r})"
|
|
2685
|
+
bindings = self._bindings.key_to_bindings.get(key, [])
|
|
2686
|
+
bindings[:] = [binding for binding in bindings if binding.action != action]
|
|
2687
|
+
if not bindings:
|
|
2688
|
+
self._bindings.key_to_bindings.pop(key, None)
|
|
2689
|
+
if shortcut is not None:
|
|
2690
|
+
self.bind_action(action_view, shortcut)
|
|
2691
|
+
self.refresh_bindings()
|
|
2692
|
+
|
|
2693
|
+
def refresh_action(self, action_view: ActionView, label: str) -> None:
|
|
2694
|
+
"""Show `label` as an action's description in the footer."""
|
|
2695
|
+
if action_view.shortcut is None:
|
|
2696
|
+
return
|
|
2697
|
+
action = f"trigger({str(id(action_view))!r})"
|
|
2698
|
+
# `bind` would add a second binding: replace the existing one in place instead
|
|
2699
|
+
bindings = self._bindings.get_bindings_for_key(_to_textual_key(action_view.shortcut))
|
|
2700
|
+
for index, binding in enumerate(bindings):
|
|
2701
|
+
if binding.action == action:
|
|
2702
|
+
bindings[index] = dataclasses.replace(binding, description=label)
|
|
2703
|
+
self.refresh_bindings()
|
|
2704
|
+
|
|
2705
|
+
|
|
2706
|
+
class TextualMainRenderer(MainRenderer[MainView, _ComposeUIApp]):
|
|
2707
|
+
def __init__(self, view: MainView) -> None:
|
|
2708
|
+
super().__init__(view)
|
|
2709
|
+
self._refresh_dispatcher.update(
|
|
2710
|
+
{
|
|
2711
|
+
"title": self._refresh_title,
|
|
2712
|
+
"is_visible": self._noop,
|
|
2713
|
+
"is_enabled": self._noop,
|
|
2714
|
+
"minimum_width": self._noop,
|
|
2715
|
+
"minimum_height": self._noop,
|
|
2716
|
+
"state_filepath": self._noop,
|
|
2717
|
+
}
|
|
2718
|
+
)
|
|
2719
|
+
|
|
2720
|
+
@property
|
|
2721
|
+
def app(self) -> _ComposeUIApp | None:
|
|
2722
|
+
return self._rendered_object
|
|
2723
|
+
|
|
2724
|
+
def render(self) -> None:
|
|
2725
|
+
if self._rendered_object is None:
|
|
2726
|
+
self._rendered_object = _ComposeUIApp(self._view, self._root_widget())
|
|
2727
|
+
views: deque[View] = deque(self._view.children_views)
|
|
2728
|
+
while views:
|
|
2729
|
+
view = views.popleft()
|
|
2730
|
+
if isinstance(view.renderer, TextualDialogRenderer):
|
|
2731
|
+
view.renderer.app = self._rendered_object
|
|
2732
|
+
views.extend(view.children_views)
|
|
2733
|
+
self.refresh()
|
|
2734
|
+
|
|
2735
|
+
def _root_widget(self) -> Widget:
|
|
2736
|
+
"""Lay out the screen: the toolbars and the status bar docked, the rest scrolling.
|
|
2737
|
+
|
|
2738
|
+
`StatusBar` is docked to the real bottom of the terminal (see
|
|
2739
|
+
`TextualStatusBarRenderer`) and the toolbars to the top; everything else goes in
|
|
2740
|
+
a `VerticalScroll` so content taller than the screen stays reachable instead of
|
|
2741
|
+
clipped, beside the sidebar, if any, scrolling on its own.
|
|
2742
|
+
"""
|
|
2743
|
+
scrollable: list[Widget] = []
|
|
2744
|
+
toolbars: list[Widget] = []
|
|
2745
|
+
docked: list[Widget] = []
|
|
2746
|
+
sidebars: list[Widget] = []
|
|
2747
|
+
# not a view no widget shows, e.g. a menu: Textual has its actions' keys instead
|
|
2748
|
+
shown = [
|
|
2749
|
+
(child_view, child_view.renderer.widget)
|
|
2750
|
+
for child_view in self._view.children_views
|
|
2751
|
+
if isinstance(child_view.renderer, TextualWidgetRenderer)
|
|
2752
|
+
and child_view.renderer.widget is not None
|
|
2753
|
+
]
|
|
2754
|
+
for child_view, widget in shown:
|
|
2755
|
+
if isinstance(child_view, StatusBar):
|
|
2756
|
+
docked.append(widget)
|
|
2757
|
+
elif isinstance(child_view, ToolBar):
|
|
2758
|
+
toolbars.append(widget)
|
|
2759
|
+
elif isinstance(child_view, SidebarView):
|
|
2760
|
+
sidebars.append(widget)
|
|
2761
|
+
elif isinstance(child_view.renderer, TextualRouterRenderer):
|
|
2762
|
+
assert child_view.renderer.toolbar is not None
|
|
2763
|
+
toolbars.append(child_view.renderer.toolbar)
|
|
2764
|
+
scrollable.append(widget)
|
|
2765
|
+
else:
|
|
2766
|
+
scrollable.append(widget)
|
|
2767
|
+
if len(toolbars) > 0:
|
|
2768
|
+
docked.append(ComposeToolbars(*toolbars))
|
|
2769
|
+
content: Widget = VerticalScroll(*scrollable)
|
|
2770
|
+
if len(sidebars) > 0:
|
|
2771
|
+
# beside the content, scrolling on its own: shown whatever the page
|
|
2772
|
+
content = Horizontal(*sidebars, content)
|
|
2773
|
+
return Vertical(content, *docked)
|
|
2774
|
+
|
|
2775
|
+
def call_on_main_thread(self, callback: Callable[[], None]) -> None:
|
|
2776
|
+
_call_on_app_thread(self._rendered_object, callback)
|
|
2777
|
+
|
|
2778
|
+
def refresh(self) -> None:
|
|
2779
|
+
# see `TextualWidgetRenderer.refresh` - same cross-thread hazard applies here.
|
|
2780
|
+
self.call_on_main_thread(super().refresh)
|
|
2781
|
+
|
|
2782
|
+
def refresh_attribute(self, attr_name: str, value: Any) -> None:
|
|
2783
|
+
self.call_on_main_thread(functools.partial(super().refresh_attribute, attr_name, value))
|
|
2784
|
+
|
|
2785
|
+
def close(self) -> None:
|
|
2786
|
+
assert self._rendered_object is not None
|
|
2787
|
+
self._rendered_object.exit()
|
|
2788
|
+
|
|
2789
|
+
def open_file(self) -> Path | None:
|
|
2790
|
+
"""Return an existing file selected by the user.
|
|
2791
|
+
|
|
2792
|
+
Must be called from a worker thread (see `_ComposeUIApp.action_trigger`):
|
|
2793
|
+
`call_from_thread` blocks the calling thread while the modal runs on the
|
|
2794
|
+
app's own thread, which would deadlock if they were the same thread.
|
|
2795
|
+
"""
|
|
2796
|
+
assert self._rendered_object is not None
|
|
2797
|
+
app = self._rendered_object
|
|
2798
|
+
|
|
2799
|
+
async def _show() -> Path | None:
|
|
2800
|
+
return await app.push_screen_wait(FileOpen(filters=self._build_filters()))
|
|
2801
|
+
|
|
2802
|
+
# mypy can't resolve `call_from_thread`'s `CallThreadReturnType` from an async
|
|
2803
|
+
# callback; the return type is verified manually via `_show`'s own annotation.
|
|
2804
|
+
result = cast("Path | None", app.call_from_thread(_show)) # type: ignore[arg-type]
|
|
2805
|
+
return self._apply_default_extension(result)
|
|
2806
|
+
|
|
2807
|
+
def save_file(self) -> Path | None:
|
|
2808
|
+
"""Return a file path selected by the user to save to.
|
|
2809
|
+
|
|
2810
|
+
See `open_file` for why this must be called from a worker thread.
|
|
2811
|
+
"""
|
|
2812
|
+
assert self._rendered_object is not None
|
|
2813
|
+
app = self._rendered_object
|
|
2814
|
+
|
|
2815
|
+
async def _show() -> Path | None:
|
|
2816
|
+
return await app.push_screen_wait(FileSave(filters=self._build_filters()))
|
|
2817
|
+
|
|
2818
|
+
# mypy can't resolve `call_from_thread`'s `CallThreadReturnType` from an async
|
|
2819
|
+
# callback; the return type is verified manually via `_show`'s own annotation.
|
|
2820
|
+
result = cast("Path | None", app.call_from_thread(_show)) # type: ignore[arg-type]
|
|
2821
|
+
return self._apply_default_extension(result)
|
|
2822
|
+
|
|
2823
|
+
def _build_filters(self) -> Filters:
|
|
2824
|
+
return Filters(
|
|
2825
|
+
*(
|
|
2826
|
+
Filter(name, functools.partial(_has_any_extension, extensions=extensions))
|
|
2827
|
+
for name, extensions in self._view.file_view.extensions.items()
|
|
2828
|
+
)
|
|
2829
|
+
)
|
|
2830
|
+
|
|
2831
|
+
def _apply_default_extension(self, filepath: Path | None) -> Path | None:
|
|
2832
|
+
if filepath is None:
|
|
2833
|
+
return None
|
|
2834
|
+
all_extensions: tuple[str, ...] = tuple(
|
|
2835
|
+
e for exts in self._view.file_view.extensions.values() for e in exts
|
|
2836
|
+
)
|
|
2837
|
+
if all_extensions and not _has_any_extension(filepath, all_extensions):
|
|
2838
|
+
filepath = filepath.with_name(filepath.name + all_extensions[0])
|
|
2839
|
+
return filepath
|
|
2840
|
+
|
|
2841
|
+
@staticmethod
|
|
2842
|
+
def _refresh_title(_view: MainView, rendered_object: _ComposeUIApp, value: str) -> None:
|
|
2843
|
+
rendered_object.title = value
|
|
2844
|
+
|
|
2845
|
+
@staticmethod
|
|
2846
|
+
def _noop(_view: MainView, _rendered_object: _ComposeUIApp, _value: Any) -> None:
|
|
2847
|
+
pass
|
|
2848
|
+
|
|
2849
|
+
|
|
2850
|
+
#: renderer keys of the Textual backend, looked up by `composeui.core.registry.create_renderer`
|
|
2851
|
+
RENDERERS: dict[str, Callable[[Any], Renderer[Any, Any]]] = {
|
|
2852
|
+
"action": TextualActionRenderer,
|
|
2853
|
+
"button": TextualButtonRenderer,
|
|
2854
|
+
"chart": TextualChartRenderer,
|
|
2855
|
+
"checkbox": TextualCheckBoxRenderer,
|
|
2856
|
+
"combobox": TextualComboBoxRenderer,
|
|
2857
|
+
"source_table": TextualSourceTableRenderer,
|
|
2858
|
+
"date_input": TextualDateInputRenderer,
|
|
2859
|
+
"file_path_input": TextualFilePathInputRenderer,
|
|
2860
|
+
"dialog": TextualDialogRenderer,
|
|
2861
|
+
"expander": TextualExpanderRenderer,
|
|
2862
|
+
"float_input": TextualFloatInputRenderer,
|
|
2863
|
+
"int_input": TextualIntInputRenderer,
|
|
2864
|
+
"form": TextualFormRenderer,
|
|
2865
|
+
"form_dialog": TextualFormDialogRenderer,
|
|
2866
|
+
"form_label": TextualFormLabelRenderer,
|
|
2867
|
+
"grid": TextualGridRenderer,
|
|
2868
|
+
"group_form": TextualGroupFormRenderer,
|
|
2869
|
+
"horizontal": TextualHorizontalRenderer,
|
|
2870
|
+
"input": TextualInputRenderer,
|
|
2871
|
+
"label": TextualLabelRenderer,
|
|
2872
|
+
"list": TextualListRenderer,
|
|
2873
|
+
"log": TextualLogRenderer,
|
|
2874
|
+
"main": TextualMainRenderer,
|
|
2875
|
+
"markdown": TextualMarkdownRenderer,
|
|
2876
|
+
"menu": TextualMenuRenderer,
|
|
2877
|
+
"metric": TextualMetricRenderer,
|
|
2878
|
+
"page": TextualPageRenderer,
|
|
2879
|
+
"progress_bar": TextualProgressBarRenderer,
|
|
2880
|
+
"slider": TextualSliderRenderer,
|
|
2881
|
+
"radio_group": TextualRadioGroupRenderer,
|
|
2882
|
+
"router": TextualRouterRenderer,
|
|
2883
|
+
"separator": TextualSeparatorRenderer,
|
|
2884
|
+
"sidebar": TextualSidebarRenderer,
|
|
2885
|
+
"splitter": TextualSplitterRenderer,
|
|
2886
|
+
"status_bar": TextualStatusBarRenderer,
|
|
2887
|
+
"table": TextualTableRenderer,
|
|
2888
|
+
"tab_page": TextualTabPageRenderer,
|
|
2889
|
+
"tabs": TextualTabsRenderer,
|
|
2890
|
+
"toolbar": TextualToolBarRenderer,
|
|
2891
|
+
"tree": TextualTreeRenderer,
|
|
2892
|
+
"vertical": TextualVerticalRenderer,
|
|
2893
|
+
}
|