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,90 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
4
+
5
+ from textual.binding import Binding
6
+ from textual.containers import Horizontal, Vertical
7
+ from textual.content import Content
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, Label, Static
10
+
11
+ from composeui.core.view import DialogKind
12
+
13
+ if TYPE_CHECKING:
14
+ from textual.app import ComposeResult
15
+ from textual.binding import BindingType
16
+
17
+ from composeui.core.view import DialogView
18
+
19
+
20
+ class ComposeDialog(ModalScreen[Optional[bool]]):
21
+ """A dialog's modal screen, dismissed with True (accepted), False (rejected) or None.
22
+
23
+ None is a dialog closed with Escape, without a button. Built from the dialog's view
24
+ as it opens.
25
+ """
26
+
27
+ DEFAULT_CSS = """
28
+ ComposeDialog {
29
+ align: center middle;
30
+ }
31
+ ComposeDialog > Vertical {
32
+ width: auto;
33
+ min-width: 40;
34
+ max-width: 80%;
35
+ height: auto;
36
+ padding: 1 2;
37
+ border: thick $primary 80%;
38
+ background: $surface;
39
+ }
40
+ ComposeDialog .title {
41
+ text-style: bold;
42
+ margin-bottom: 1;
43
+ }
44
+ ComposeDialog Horizontal {
45
+ width: 100%;
46
+ height: auto;
47
+ align-horizontal: right;
48
+ margin-top: 1;
49
+ }
50
+ ComposeDialog Button {
51
+ margin-left: 1;
52
+ }
53
+ """
54
+
55
+ BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "dismiss_dialog", "Close")]
56
+
57
+ def __init__(self, view: DialogView) -> None:
58
+ super().__init__()
59
+ self.title_label = Label(view.title, classes="title", markup=False)
60
+ self.title_label.display = view.title != ""
61
+ self.text_label = Static(view.text, markup=False)
62
+ self.accept_button = Button(Content(view.accept_text), variant="primary", id="accept")
63
+ self.reject_button = Button(Content(view.reject_text), id="reject")
64
+ self.reject_button.display = view.kind is DialogKind.CONFIRM
65
+ self.is_enabled = view.is_enabled
66
+
67
+ def compose(self) -> ComposeResult:
68
+ with Vertical():
69
+ yield self.title_label
70
+ yield self.text_label
71
+ with Horizontal():
72
+ yield self.reject_button
73
+ yield self.accept_button
74
+
75
+ def on_mount(self) -> None:
76
+ self.set_enabled(is_enabled=self.is_enabled)
77
+ self.accept_button.focus()
78
+
79
+ def set_enabled(self, *, is_enabled: bool) -> None:
80
+ self.is_enabled = is_enabled
81
+ self.accept_button.disabled = not is_enabled
82
+ self.reject_button.disabled = not is_enabled
83
+
84
+ def on_button_pressed(self, event: Button.Pressed) -> None:
85
+ event.stop()
86
+ self.dismiss(event.button is self.accept_button)
87
+
88
+ def action_dismiss_dialog(self) -> None:
89
+ if self.is_enabled:
90
+ self.dismiss(None)
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ from pathlib import Path
5
+
6
+ from textual.containers import Horizontal
7
+ from textual.widgets import Button
8
+ from textual_fspicker import FileOpen
9
+ from textual_fspicker.path_filters import Filter, Filters
10
+
11
+ from composeui.core.signal import Signal, SignalNoArgs
12
+ from composeui.textualcore.widgets.composeinput import ComposeInput
13
+
14
+
15
+ def _has_any_extension(path: Path, extensions: tuple[str, ...]) -> bool:
16
+ return any(path.name.endswith(extension) for extension in extensions)
17
+
18
+
19
+ class ComposeFilePathInput(Horizontal):
20
+ """A field typing a file's path, then a "…" button picking one, and a reload button.
21
+
22
+ The picker is textual-fspicker's, a screen of its own, opening in the directory of
23
+ the field's path, if any; `path_picked` reports the file picked, once the field shows
24
+ it. The reload button only reports its presses, and shows with `has_reload`.
25
+ """
26
+
27
+ DEFAULT_CSS = """
28
+ ComposeFilePathInput {
29
+ height: auto;
30
+ & > ComposeInput {
31
+ width: 1fr;
32
+ }
33
+ & > Button {
34
+ min-width: 5;
35
+ width: 5;
36
+ }
37
+ &.-row-invalid > ComposeInput {
38
+ border: tall $error 60%;
39
+ &:focus {
40
+ border: tall $error;
41
+ }
42
+ }
43
+ }
44
+ """
45
+
46
+ def __init__(self) -> None:
47
+ self.input = ComposeInput()
48
+ self.browse_button = Button("…")
49
+ self.reload_button = Button("↻")
50
+ self.reload_button.display = False
51
+ super().__init__(self.input, self.browse_button, self.reload_button)
52
+ #: the file types the picker offers, by name, as the view's `extensions`
53
+ self.extensions: dict[str, tuple[str, ...]] = {}
54
+ #: the picker's title, the view's `browse_text`
55
+ self.picker_title = ""
56
+ self.path_picked: Signal[Path] = Signal()
57
+ self.reload_pressed = SignalNoArgs()
58
+
59
+ def open_picker(self) -> None:
60
+ """Show the file picker in the directory of the field's path, else the current one."""
61
+ text = self.input.value.strip()
62
+ location = Path(text).parent if text != "" else Path()
63
+ if not location.is_dir(): # e.g. a path typed halfway
64
+ location = Path()
65
+ filters = Filters(
66
+ *(
67
+ Filter(name, functools.partial(_has_any_extension, extensions=extensions))
68
+ for name, extensions in self.extensions.items()
69
+ )
70
+ )
71
+ picker = FileOpen(location, self.picker_title or "Open", filters=filters)
72
+ self.app.push_screen(picker, self._pick)
73
+
74
+ def on_button_pressed(self, event: Button.Pressed) -> None:
75
+ event.stop()
76
+ if event.button is self.browse_button:
77
+ self.open_picker()
78
+ else:
79
+ self.reload_pressed.emit()
80
+
81
+ def _pick(self, path: Path | None) -> None:
82
+ if path is not None:
83
+ self.input.value = str(path)
84
+ self.path_picked.emit(path)
85
+ self.input.focus()
@@ -0,0 +1,367 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Callable
4
+
5
+ from textual.binding import Binding
6
+ from textual.containers import Horizontal, HorizontalScroll, Vertical
7
+ from textual.content import Content
8
+ from textual.widgets import Button, Input, Select, Static
9
+
10
+ from composeui.core.signal import Signal, SignalNoArgs
11
+ from composeui.core.view import SEARCH_DELAY_MS, as_pattern, split_pattern
12
+ from composeui.textualcore.widgets.composetable import ComposeTable
13
+
14
+ if TYPE_CHECKING:
15
+ from textual.app import ComposeResult
16
+ from textual.timer import Timer
17
+
18
+
19
+ #: a chip's remove button: a multiplication sign, as a close button often is
20
+ REMOVE = "\u00d7"
21
+ #: after a chip's text: a pencil, as its click edits the filter
22
+ EDIT = "\u270e"
23
+
24
+
25
+ class _ValueInput(Input):
26
+ """The filter editor's value field: Escape closes the editor."""
27
+
28
+ BINDINGS = [Binding("escape", "close_editor", "Close", show=False)] # noqa: RUF012
29
+
30
+ def __init__(self) -> None:
31
+ super().__init__(compact=True)
32
+ self.escaped = SignalNoArgs()
33
+
34
+ def action_close_editor(self) -> None:
35
+ self.escaped.emit()
36
+
37
+
38
+ class ComposeFilterChip(Horizontal):
39
+ """A filter, as its column's header then its text, opening it, and a button removing it.
40
+
41
+ The pencil after the text and the tooltip say a click edits it.
42
+ """
43
+
44
+ DEFAULT_CSS = """
45
+ ComposeFilterChip {
46
+ width: auto;
47
+ height: 1;
48
+ margin-right: 1;
49
+ }
50
+ """
51
+
52
+ def __init__(self, column: int, text: str, edit_text: str, remove_text: str) -> None:
53
+ super().__init__()
54
+ self.column = column
55
+ self.open_button = Button(
56
+ Content(f"{text} {EDIT}"),
57
+ compact=True,
58
+ classes="chip-text",
59
+ tooltip=Content(edit_text),
60
+ )
61
+ self.remove_button = Button(
62
+ REMOVE, compact=True, classes="chip-remove", tooltip=Content(remove_text)
63
+ )
64
+
65
+ def compose(self) -> ComposeResult:
66
+ yield self.open_button
67
+ yield self.remove_button
68
+
69
+
70
+ class ComposeFilterBar(Horizontal):
71
+ """A table's filter bar, on one line: a search, the filters as chips, the rows shown.
72
+
73
+ The chips scroll sideways when they don't fit.
74
+
75
+ "+ Filter" opens an editor in the bar, of a column, a value and an Add button, and in
76
+ a text column a toggle (`pattern`, primary while on) making the value a regular
77
+ expression: Enter or Add adds the filter, unless `check` says why its value can't
78
+ filter, marking the value field `-invalid` with the reason as its tooltip; Escape
79
+ closes it. A chip's
80
+ text opens the editor on its filter, its remove button removes it. The signals emit only for the
81
+ user: `search_edited` the text typed, `filter_added` a column and its text,
82
+ `filter_removed` a column, and `filters_cleared`. `search_edited` waits
83
+ `SEARCH_DELAY_MS` after the last key, but emits at once on Enter or once the search is
84
+ cleared.
85
+ """
86
+
87
+ DEFAULT_CSS = """
88
+ ComposeFilterBar {
89
+ height: 1;
90
+ margin-bottom: 1;
91
+ & > Input#search {
92
+ width: 20;
93
+ margin-right: 1;
94
+ }
95
+ & > Select {
96
+ width: 28;
97
+ }
98
+ & > Input#value {
99
+ width: 20;
100
+ }
101
+ & > Button {
102
+ margin-right: 1;
103
+ }
104
+ /* the chips take the room left, scrolling: Textual lays nothing out wrapping */
105
+ & > HorizontalScroll#chips {
106
+ width: 1fr;
107
+ height: 1;
108
+ scrollbar-size-horizontal: 0;
109
+ }
110
+ & > Static#count {
111
+ width: auto;
112
+ color: $text-muted;
113
+ }
114
+ }
115
+ """
116
+
117
+ def __init__(self) -> None:
118
+ super().__init__()
119
+ #: why a column's text can't filter it, or None if it can
120
+ self.check: Callable[[int, str], str | None] = lambda _column, _text: None
121
+ #: a hint at what filters a column, as a regular expression or not
122
+ self.placeholder: Callable[[int, bool], str] = lambda _column, _is_pattern: ""
123
+ #: whether a regular expression filters a column
124
+ self.takes_pattern: Callable[[int], bool] = lambda _column: False
125
+ self.search_edited: Signal[str] = Signal()
126
+ self.filter_added: Signal[int, str] = Signal()
127
+ self.filter_removed: Signal[int] = Signal()
128
+ self.filters_cleared: SignalNoArgs = SignalNoArgs()
129
+ self._search_timer: Timer | None = None
130
+ self._texts: dict[str, str] = {}
131
+ self._filters: list[tuple[int, str, str, str]] = []
132
+ #: the column whose chip takes the focus once shown, its filter just added
133
+ self._focused_chip: int | None = None
134
+ self.search = Input(id="search", compact=True)
135
+ self.add_filter = Button("", id="add-filter", compact=True)
136
+ self.column: Select[int] = Select([], id="column", compact=True, allow_blank=True)
137
+ self.pattern = Button(".*", id="pattern", compact=True)
138
+ self.value = _ValueInput()
139
+ self.value.id = "value"
140
+ self.value.escaped.connect(self.close_editor)
141
+ self.add = Button("", id="add", compact=True)
142
+ self.chips = HorizontalScroll(id="chips")
143
+ self.clear = Button("", id="clear", compact=True)
144
+ self.count = Static("", id="count", markup=False)
145
+ self._columns: list[tuple[int, str]] = []
146
+ self.close_editor()
147
+
148
+ def compose(self) -> ComposeResult:
149
+ yield self.search
150
+ yield self.add_filter
151
+ yield self.column
152
+ yield self.pattern
153
+ yield self.value
154
+ yield self.add
155
+ yield self.chips
156
+ yield self.clear
157
+ yield self.count
158
+
159
+ def on_mount(self) -> None:
160
+ self._offer_columns()
161
+ self._show_chips()
162
+
163
+ def set_texts(self, texts: dict[str, str]) -> None:
164
+ self._texts = texts
165
+ self.search.placeholder = texts.get("search", "")
166
+ self.add_filter.label = Content(texts.get("add_filter", ""))
167
+ self.add.label = Content(texts.get("add", ""))
168
+ self.clear.label = Content(texts.get("clear", ""))
169
+ self.column.tooltip = Content(texts.get("column", ""))
170
+ self.pattern.tooltip = Content(texts.get("pattern", ""))
171
+ self.value.tooltip = None
172
+ self._show_chips()
173
+
174
+ def set_columns(self, columns: list[tuple[int, str]]) -> None:
175
+ """Offer `columns`, each its index and its header, in the editor."""
176
+ self._columns = columns
177
+ if self.is_mounted:
178
+ self._offer_columns()
179
+
180
+ def _offer_columns(self) -> None:
181
+ """Offer the columns, the one picked still picked, else the first."""
182
+ selected = self.column.value
183
+ self.column.set_options((Content(header), column) for column, header in self._columns)
184
+ columns = [column for column, _ in self._columns]
185
+ if selected in columns:
186
+ self.column.value = selected
187
+ elif len(columns) > 0:
188
+ self.column.value = columns[0]
189
+
190
+ def set_search(self, text: str) -> None:
191
+ if self.search.value != text:
192
+ self._stop_search_timer() # the app's search, not the user's typed before
193
+ with self.search.prevent(Input.Changed):
194
+ self.search.value = text
195
+
196
+ def set_filters(self, filters: list[tuple[int, str, str, str]]) -> None:
197
+ """Show each filter as a chip: its column, the chip's text, the text typed, the header."""
198
+ self._filters = filters
199
+ self._show_chips()
200
+
201
+ def set_count(self, text: str) -> None:
202
+ self.count.update(text)
203
+ self.count.display = text != ""
204
+
205
+ def open_editor(self, column: int | None = None, text: str = "") -> None:
206
+ if column is not None and self.is_mounted:
207
+ self.column.value = column
208
+ text, is_pattern = split_pattern(text)
209
+ self.set_pattern(is_pattern=is_pattern)
210
+ with self.value.prevent(Input.Changed):
211
+ self.value.value = text
212
+ self._mark_invalid(None)
213
+ for widget in (self.column, self.value, self.add):
214
+ widget.display = True
215
+ self.add_filter.display = False
216
+ self._show_placeholder()
217
+ self.value.focus()
218
+
219
+ def close_editor(self) -> None:
220
+ had_focus = self.value.has_focus
221
+ for widget in (self.column, self.pattern, self.value, self.add):
222
+ widget.display = False
223
+ self.add_filter.display = True
224
+ if had_focus:
225
+ self.add_filter.focus()
226
+
227
+ def is_pattern(self) -> bool:
228
+ """Tell whether the value is a regular expression: the toggle is on."""
229
+ return self.pattern.variant == "primary"
230
+
231
+ def set_pattern(self, *, is_pattern: bool) -> None:
232
+ self.pattern.variant = "primary" if is_pattern else "default"
233
+ self._show_placeholder()
234
+
235
+ def is_editing(self) -> bool:
236
+ return bool(self.value.display)
237
+
238
+ def on_input_changed(self, event: Input.Changed) -> None:
239
+ event.stop()
240
+ if event.input is self.search:
241
+ self._stop_search_timer()
242
+ if event.value == "":
243
+ self._emit_search()
244
+ else:
245
+ self._search_timer = self.set_timer(SEARCH_DELAY_MS / 1000, self._emit_search)
246
+
247
+ def on_input_submitted(self, event: Input.Submitted) -> None:
248
+ event.stop()
249
+ if event.input is self.value:
250
+ self._add()
251
+ elif event.input is self.search and self._search_timer is not None:
252
+ self._emit_search()
253
+
254
+ def _stop_search_timer(self) -> None:
255
+ if self._search_timer is not None:
256
+ self._search_timer.stop()
257
+ self._search_timer = None
258
+
259
+ def _emit_search(self) -> None:
260
+ self._stop_search_timer()
261
+ self.search_edited.emit(self.search.value)
262
+
263
+ def on_select_changed(self, event: Select.Changed) -> None:
264
+ event.stop()
265
+ self._show_placeholder()
266
+
267
+ def on_button_pressed(self, event: Button.Pressed) -> None:
268
+ event.stop()
269
+ button = event.button
270
+ if button is self.add_filter:
271
+ self.open_editor()
272
+ elif button is self.add:
273
+ self._add()
274
+ elif button is self.pattern:
275
+ self.set_pattern(is_pattern=not self.is_pattern())
276
+ elif button is self.clear:
277
+ self.filters_cleared.emit()
278
+ else: # a chip's
279
+ chip = button.parent
280
+ assert isinstance(chip, ComposeFilterChip)
281
+ if button is chip.remove_button:
282
+ self.filter_removed.emit(chip.column)
283
+ else:
284
+ typed = next(
285
+ typed for column, _, typed, _ in self._filters if column == chip.column
286
+ )
287
+ self.open_editor(chip.column, typed)
288
+
289
+ def _show_chips(self) -> None:
290
+ self.clear.display = len(self._filters) >= 2 # noqa: PLR2004
291
+ if not self.is_mounted:
292
+ return
293
+ self.chips.remove_children()
294
+ remove, edit = self._texts.get("remove", "{header}"), self._texts.get("edit", "{header}")
295
+ chips = [
296
+ ComposeFilterChip(
297
+ column, text, edit.format(header=header), remove.format(header=header)
298
+ )
299
+ for column, text, _typed, header in self._filters
300
+ ]
301
+ self.chips.mount_all(chips)
302
+ for chip in chips:
303
+ if chip.column == self._focused_chip:
304
+ self.call_after_refresh(chip.open_button.focus)
305
+ self._focused_chip = None
306
+
307
+ def _add(self) -> None:
308
+ column = self.column.value
309
+ if not isinstance(column, int):
310
+ return
311
+ text = self.value.value
312
+ if self.is_pattern() and self.takes_pattern(column):
313
+ text = as_pattern(text)
314
+ reason = self.check(column, text)
315
+ if reason is not None:
316
+ self._mark_invalid(reason)
317
+ return
318
+ self.close_editor()
319
+ # its chip takes the focus: Enter opens it again
320
+ self._focused_chip = column
321
+ self.filter_added.emit(column, text)
322
+ self._focused_chip = None
323
+
324
+ def _mark_invalid(self, reason: str | None) -> None:
325
+ self.value.set_class(reason is not None, "-invalid")
326
+ self.value.tooltip = None if reason is None else Content(reason)
327
+
328
+ def _show_placeholder(self) -> None:
329
+ column = self.column.value
330
+ if not isinstance(column, int):
331
+ self.value.placeholder = ""
332
+ return
333
+ # a toggle only where a regular expression filters, while editing
334
+ takes_pattern = self.takes_pattern(column)
335
+ self.pattern.display = takes_pattern and bool(self.value.display)
336
+ self.value.placeholder = self.placeholder(column, takes_pattern and self.is_pattern())
337
+
338
+
339
+ class ComposeFilterTable(Vertical):
340
+ """A table of text under its filter bar, which shows only while a column filters.
341
+
342
+ When the filters hide every row, `empty` says so under the headers.
343
+ """
344
+
345
+ DEFAULT_CSS = """
346
+ ComposeFilterTable {
347
+ height: auto;
348
+ & > Static.empty {
349
+ color: $text-muted;
350
+ text-align: center;
351
+ width: 1fr;
352
+ }
353
+ }
354
+ """
355
+
356
+ def __init__(self) -> None:
357
+ super().__init__()
358
+ self.bar = ComposeFilterBar()
359
+ self.bar.display = False
360
+ self.table = ComposeTable()
361
+ self.empty = Static("", classes="empty", markup=False)
362
+ self.empty.display = False
363
+
364
+ def compose(self) -> ComposeResult:
365
+ yield self.bar
366
+ yield self.table
367
+ yield self.empty
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar
4
+
5
+ from textual.binding import Binding
6
+ from textual.containers import Horizontal, VerticalScroll
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Button, Label, Static
9
+
10
+ from composeui.core.signal import Signal, SignalNoArgs
11
+
12
+ if TYPE_CHECKING:
13
+ from textual.app import ComposeResult
14
+ from textual.binding import BindingType
15
+ from textual.widget import Widget
16
+
17
+
18
+ class ComposeFormDialog(ModalScreen[None]):
19
+ """A form dialog's modal screen: its title, text and form, then Cancel and OK.
20
+
21
+ Installed once and pushed at each opening, so its form keeps its widgets: a screen
22
+ dismissed can't be pushed again. It reports the user's answer (`answered`: "accept",
23
+ "reject", or "dismiss" for Escape) without closing; its renderer pops it once the
24
+ view closes the dialog, which stays open when its form refuses OK.
25
+ """
26
+
27
+ DEFAULT_CSS = """
28
+ ComposeFormDialog {
29
+ align: center middle;
30
+ }
31
+ ComposeFormDialog > VerticalScroll {
32
+ width: 70%;
33
+ max-width: 100;
34
+ height: auto;
35
+ max-height: 90%;
36
+ padding: 1 2;
37
+ border: thick $primary 80%;
38
+ background: $surface;
39
+ }
40
+ ComposeFormDialog .title {
41
+ text-style: bold;
42
+ margin-bottom: 1;
43
+ }
44
+ ComposeFormDialog Horizontal {
45
+ width: 100%;
46
+ height: auto;
47
+ align-horizontal: right;
48
+ margin-top: 1;
49
+ }
50
+ ComposeFormDialog Button {
51
+ margin-left: 1;
52
+ }
53
+ """
54
+
55
+ BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "dismiss_dialog", "Close")]
56
+
57
+ def __init__(self, form: Widget) -> None:
58
+ super().__init__()
59
+ self.title_label = Label(classes="title", markup=False)
60
+ self.text_label = Static(markup=False)
61
+ self.form = form
62
+ self.accept_button = Button("", variant="primary", id="accept")
63
+ self.reject_button = Button("", id="reject")
64
+ self.answered: Signal[str] = Signal()
65
+ #: the screen's widgets are mounted, its form's among them: once, as it's installed
66
+ self.mounted: SignalNoArgs = SignalNoArgs()
67
+
68
+ def compose(self) -> ComposeResult:
69
+ with VerticalScroll():
70
+ yield self.title_label
71
+ yield self.text_label
72
+ yield self.form
73
+ with Horizontal():
74
+ yield self.reject_button
75
+ yield self.accept_button
76
+
77
+ def on_mount(self) -> None:
78
+ self.mounted.emit()
79
+
80
+ def on_button_pressed(self, event: Button.Pressed) -> None:
81
+ event.stop()
82
+ self.answered.emit("accept" if event.button is self.accept_button else "reject")
83
+
84
+ def action_dismiss_dialog(self) -> None:
85
+ if not self.reject_button.disabled: # a disabled dialog can't be closed
86
+ self.answered.emit("dismiss")
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from textual.widgets import Input
6
+
7
+ from composeui.core.signal import Signal, SignalNoArgs
8
+
9
+ if TYPE_CHECKING:
10
+ from textual import events
11
+
12
+
13
+ class ComposeInput(Input):
14
+ """An `Input` that re-exposes its messages as plain composeui `Signal`s.
15
+
16
+ Textual dispatches `Input.Changed`/`Input.Submitted` by calling `on_input_changed`/
17
+ `on_input_submitted` on the widget itself (and its DOM ancestors); a renderer isn't
18
+ part of the DOM, so it can't bind to those directly. Bridging them to `Signal`s here
19
+ lets `TextualInputRenderer` connect like it would to any other composeui signal.
20
+ """
21
+
22
+ # a form row's field while the row is invalid (see `ROW_INVALID_CLASS`): as Input's
23
+ # own `-invalid`
24
+ DEFAULT_CSS = """
25
+ ComposeInput.-row-invalid {
26
+ border: tall $error 60%;
27
+ &:focus {
28
+ border: tall $error;
29
+ }
30
+ }
31
+ """
32
+
33
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
34
+ super().__init__(*args, **kwargs)
35
+ self.value_changed: Signal[str] = Signal()
36
+ self.value_submitted: SignalNoArgs = SignalNoArgs()
37
+
38
+ def on_input_changed(self, event: Input.Changed) -> None:
39
+ self.value_changed.emit(event.value)
40
+
41
+ def on_input_submitted(self, event: Input.Submitted) -> None:
42
+ self.value_submitted.emit()
43
+
44
+ def on_blur(self, event: events.Blur) -> None:
45
+ """Match Qt's `QLineEdit.editingFinished`, which fires on Enter *or* blur.
46
+
47
+ `Input.Submitted` alone only covers Enter; without this, tabbing/clicking
48
+ away from a field (instead of pressing Enter) never fires `editing_finished`.
49
+ """
50
+ self.value_submitted.emit()
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.containers import Vertical
4
+
5
+
6
+ class ComposeList(Vertical):
7
+ """A list's views one above the other, as tall as they are.
8
+
9
+ Its collapsed collapsibles (an expander per record) leave out the line under their
10
+ title, a lone one's room for its content: one line apart, many records stay in view.
11
+ """
12
+
13
+ DEFAULT_CSS = """
14
+ ComposeList {
15
+ height: auto;
16
+ & > Collapsible.-collapsed {
17
+ padding-bottom: 0;
18
+ }
19
+ }
20
+ """