composeui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. composeui/__init__.py +11 -0
  2. composeui/core/__init__.py +0 -0
  3. composeui/core/application.py +368 -0
  4. composeui/core/chartcolors.py +23 -0
  5. composeui/core/chartimage.py +336 -0
  6. composeui/core/dataframesource.py +307 -0
  7. composeui/core/jobs.py +426 -0
  8. composeui/core/model.py +66 -0
  9. composeui/core/registry.py +66 -0
  10. composeui/core/reload.py +476 -0
  11. composeui/core/renderer.py +193 -0
  12. composeui/core/signal.py +358 -0
  13. composeui/core/sqlitemodel.py +1183 -0
  14. composeui/core/study.py +121 -0
  15. composeui/core/translation.py +221 -0
  16. composeui/core/translationcheck.py +254 -0
  17. composeui/core/view.py +5508 -0
  18. composeui/images/composeui.svg +15 -0
  19. composeui/locales/fr.json +73 -0
  20. composeui/mypy.py +46 -0
  21. composeui/py.typed +0 -0
  22. composeui/qtcore/__init__.py +0 -0
  23. composeui/qtcore/maprenderer.py +115 -0
  24. composeui/qtcore/qtapplication.py +222 -0
  25. composeui/qtcore/qtrenderer.py +2862 -0
  26. composeui/qtcore/widgets/__init__.py +0 -0
  27. composeui/qtcore/widgets/map.qml +123 -0
  28. composeui/qtcore/widgets/qchart.py +393 -0
  29. composeui/qtcore/widgets/qdatefield.py +101 -0
  30. composeui/qtcore/widgets/qexpander.py +60 -0
  31. composeui/qtcore/widgets/qfilepathfield.py +34 -0
  32. composeui/qtcore/widgets/qfiltertable.py +394 -0
  33. composeui/qtcore/widgets/qformdialog.py +64 -0
  34. composeui/qtcore/widgets/qformlabel.py +53 -0
  35. composeui/qtcore/widgets/qmap.py +68 -0
  36. composeui/qtcore/widgets/qmarkicon.py +37 -0
  37. composeui/qtcore/widgets/qmetric.py +44 -0
  38. composeui/qtcore/widgets/qplaintext.py +29 -0
  39. composeui/qtcore/widgets/qradiogroup.py +57 -0
  40. composeui/qtcore/widgets/qsidebar.py +116 -0
  41. composeui/qtcore/widgets/qsourcetable.py +216 -0
  42. composeui/qtcore/widgets/qsplitter.py +69 -0
  43. composeui/qtcore/widgets/qtexttable.py +703 -0
  44. composeui/qtcore/widgets/qvaluecombobox.py +41 -0
  45. composeui/qtcore/widgets/qvalueslider.py +64 -0
  46. composeui/qtcore/widgets/qwindowrelease.py +32 -0
  47. composeui/textualcore/__init__.py +0 -0
  48. composeui/textualcore/textualapplication.py +121 -0
  49. composeui/textualcore/textualrenderer.py +2893 -0
  50. composeui/textualcore/widgets/__init__.py +0 -0
  51. composeui/textualcore/widgets/composebutton.py +17 -0
  52. composeui/textualcore/widgets/composechart.py +212 -0
  53. composeui/textualcore/widgets/composecheckbox.py +35 -0
  54. composeui/textualcore/widgets/composecollapsible.py +41 -0
  55. composeui/textualcore/widgets/composecombobox.py +54 -0
  56. composeui/textualcore/widgets/composedateinput.py +325 -0
  57. composeui/textualcore/widgets/composedialog.py +90 -0
  58. composeui/textualcore/widgets/composefilepathinput.py +85 -0
  59. composeui/textualcore/widgets/composefiltertable.py +367 -0
  60. composeui/textualcore/widgets/composeformdialog.py +86 -0
  61. composeui/textualcore/widgets/composeinput.py +50 -0
  62. composeui/textualcore/widgets/composelist.py +20 -0
  63. composeui/textualcore/widgets/composemarkdown.py +17 -0
  64. composeui/textualcore/widgets/composemetric.py +60 -0
  65. composeui/textualcore/widgets/composeradioset.py +54 -0
  66. composeui/textualcore/widgets/composesidebar.py +111 -0
  67. composeui/textualcore/widgets/composeslider.py +144 -0
  68. composeui/textualcore/widgets/composesourcetable.py +288 -0
  69. composeui/textualcore/widgets/composesplitter.py +211 -0
  70. composeui/textualcore/widgets/composetabbedcontent.py +35 -0
  71. composeui/textualcore/widgets/composetable.py +476 -0
  72. composeui/textualcore/widgets/composetoolbar.py +62 -0
  73. composeui/textualcore/widgets/composetree.py +139 -0
  74. composeui/webcore/__init__.py +0 -0
  75. composeui/webcore/headers.py +57 -0
  76. composeui/webcore/icons.py +58 -0
  77. composeui/webcore/limits.py +55 -0
  78. composeui/webcore/origin.py +63 -0
  79. composeui/webcore/page.py +146 -0
  80. composeui/webcore/routes.py +303 -0
  81. composeui/webcore/session.py +436 -0
  82. composeui/webcore/static/app.css +2 -0
  83. composeui/webcore/static/cally.js +1312 -0
  84. composeui/webcore/static/daisyui.LICENSE +21 -0
  85. composeui/webcore/static/deckgl.LICENSE +19 -0
  86. composeui/webcore/static/deckgl.min.js +6423 -0
  87. composeui/webcore/static/htmx.min.js +1 -0
  88. composeui/webcore/static/page.js +1175 -0
  89. composeui/webcore/static/uPlot.LICENSE +21 -0
  90. composeui/webcore/static/uPlot.iife.min.js +2 -0
  91. composeui/webcore/webapplication.py +495 -0
  92. composeui/webcore/webrenderer.py +5113 -0
  93. composeui-0.1.0.dist-info/METADATA +222 -0
  94. composeui-0.1.0.dist-info/RECORD +97 -0
  95. composeui-0.1.0.dist-info/WHEEL +5 -0
  96. composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
  97. composeui-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ }