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,476 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
4
+
5
+ from rich.text import Text
6
+ from textual.binding import Binding
7
+ from textual.containers import Vertical
8
+ from textual.coordinate import Coordinate
9
+ from textual.screen import ModalScreen
10
+ from textual.widgets import DataTable, Input, Label, OptionList
11
+
12
+ from composeui.core.signal import Signal
13
+ from composeui.core.view import (
14
+ CHECKED_TEXTS,
15
+ PLAIN_CELL,
16
+ CellKind,
17
+ CellStyle,
18
+ CellWidget,
19
+ HeaderGroup,
20
+ replace_cell,
21
+ )
22
+ from composeui.textualcore.widgets.composetree import MARKS
23
+
24
+ if TYPE_CHECKING:
25
+ from textual import events
26
+ from textual.app import ComposeResult
27
+ from textual.binding import BindingType
28
+
29
+ #: the first column's cell of a selected row, in a table selecting several rows
30
+ _CHECK = "✓"
31
+ #: `events.Click.chain` of a double click's second click
32
+ _DOUBLE_CLICK = 2
33
+ #: a check box cell or a check-all header, by whether it's checked (None: some rows)
34
+ _BOXES = {True: "\u2611", False: "\u2610", None: "\u25a3"}
35
+
36
+
37
+ class ComposeOptionPicker(ModalScreen[Optional[str]]):
38
+ """A modal listing a combobox cell's options, the cell's own highlighted.
39
+
40
+ Picking one (a click or Enter) dismisses it with its text, Escape with None.
41
+ """
42
+
43
+ DEFAULT_CSS = """
44
+ ComposeOptionPicker {
45
+ align: center middle;
46
+ }
47
+ ComposeOptionPicker > OptionList {
48
+ width: 40;
49
+ max-width: 90%;
50
+ height: auto;
51
+ max-height: 80%;
52
+ border: thick $primary 80%;
53
+ }
54
+ """
55
+
56
+ BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "dismiss_picker", "Close")]
57
+
58
+ def __init__(self, options: list[str], text: str) -> None:
59
+ super().__init__()
60
+ self.options = OptionList(*(Text(option) for option in options))
61
+ self._highlighted = options.index(text) if text in options else 0
62
+
63
+ def compose(self) -> ComposeResult:
64
+ yield self.options
65
+
66
+ def on_mount(self) -> None:
67
+ self.options.highlighted = self._highlighted
68
+ self.options.focus()
69
+
70
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
71
+ self.dismiss(str(event.option.prompt))
72
+
73
+ def action_dismiss_picker(self) -> None:
74
+ self.dismiss(None)
75
+
76
+
77
+ class ComposeRowEditor(ModalScreen[Optional[dict[int, str]]]):
78
+ """A modal editing a row's editable cells, one input each under its header.
79
+
80
+ Enter in any input dismisses it with every cell's text by column, Escape with None.
81
+ """
82
+
83
+ DEFAULT_CSS = """
84
+ ComposeRowEditor {
85
+ align: center middle;
86
+ }
87
+ ComposeRowEditor > Vertical {
88
+ width: 60;
89
+ max-width: 90%;
90
+ height: auto;
91
+ padding: 1 2;
92
+ border: thick $primary 80%;
93
+ background: $surface;
94
+ }
95
+ """
96
+
97
+ BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "dismiss_editor", "Close")]
98
+
99
+ def __init__(self, cells: dict[int, tuple[str, str]], focused: int) -> None:
100
+ """`cells` maps each editable column to its header and text."""
101
+ super().__init__()
102
+ self.inputs = {column: Input(text) for column, (_header, text) in cells.items()}
103
+ self._headers = {column: header for column, (header, _text) in cells.items()}
104
+ self._focused = focused
105
+
106
+ def compose(self) -> ComposeResult:
107
+ with Vertical():
108
+ for column, cell_input in self.inputs.items():
109
+ yield Label(self._headers[column], markup=False)
110
+ yield cell_input
111
+
112
+ def on_mount(self) -> None:
113
+ self.inputs[self._focused].focus()
114
+
115
+ def on_input_submitted(self, _event: Input.Submitted) -> None:
116
+ self.dismiss({column: cell.value for column, cell in self.inputs.items()})
117
+
118
+ def action_dismiss_editor(self) -> None:
119
+ self.dismiss(None)
120
+
121
+
122
+ class ComposeTable(DataTable[Text]):
123
+ """A table of text where the user selects rows, like Qt's `QTextTableView`.
124
+
125
+ Selecting one row, the row cursor is the selection: hidden while nothing is
126
+ selected, shown once the user clicks a row or presses Enter. Selecting several
127
+ (`set_multiple_selection`), the cursor only moves, and a click or Enter adds or
128
+ removes its row, checked in a first column of its own: the cursor can't show
129
+ several rows. `selected_rows_changed` emits the selected rows, in order, only when
130
+ the user changes them, re-emitting `DataTable`'s messages through a plain composeui
131
+ `Signal` a renderer can connect to (see `ComposeRadioSet`).
132
+
133
+ The user edits the cells of `editable_columns` in a `ComposeRowEditor`: F2 opens it
134
+ on the cursor's row, a double click on the clicked cell's. `cell_edited` emits each
135
+ cell the user changed: its row, its column (without the check column) and its text.
136
+
137
+ The columns of `cell_widgets` show widgets as text: a check box as a box, which a
138
+ click or Space (the cursor row's first) toggles when editable; a combobox as its text
139
+ and an arrow, a click (or F2, without a typed column) opening a `ComposeOptionPicker`
140
+ on it; a button as its text and the button's in brackets, and a link underlined,
141
+ `cell_clicked` emitting a click on either. A check-all column's header starts with
142
+ the box `set_checked_columns` shows, and its click emits `check_all_clicked`.
143
+
144
+ `header_clicked` emits the column whose header the user clicked; the header shows
145
+ the order `set_sort` gives (▲/▼), the table never sorting its rows itself.
146
+
147
+ `fill` gives how the cells look: a background, the text black or white on it; dim
148
+ and never edited when disabled; a mark before the text. A cell spanning rows shows
149
+ its text in its own, those it covers empty: a terminal has no cells across rows. The
150
+ rows' headers show on the left, unless there are none. With `set_header_groups`,
151
+ the header has two lines: a group's text over its first column, then each column's.
152
+ """
153
+
154
+ BINDINGS: ClassVar[list[BindingType]] = [
155
+ Binding("f2", "edit_row", "Edit"),
156
+ Binding("space", "toggle_check", "Check", show=False),
157
+ ]
158
+
159
+ def __init__(self) -> None:
160
+ super().__init__(cursor_type="row", show_cursor=False, zebra_stripes=True)
161
+ self.selected_rows_changed: Signal[list[int]] = Signal()
162
+ self.cell_edited: Signal[int, int, str] = Signal()
163
+ self.header_clicked: Signal[int] = Signal()
164
+ self.cell_clicked: Signal[int, int] = Signal()
165
+ self.check_all_clicked: Signal[int] = Signal()
166
+ self.editable_columns: list[int] = []
167
+ self.cell_widgets: dict[int, CellWidget] = {}
168
+ self._checked_columns: dict[int, bool | None] = {}
169
+ self._sort: tuple[int, bool] | None = None
170
+ self._selected_rows: list[int] = []
171
+ self._is_multiple = False
172
+ self._headers: list[str] = []
173
+ self._rows: list[list[str]] = []
174
+ self._styles: dict[tuple[int, int], CellStyle] = {}
175
+ self._row_headers: list[str] = []
176
+ self._groups: list[HeaderGroup] = []
177
+
178
+ def fill(
179
+ self,
180
+ headers: list[str],
181
+ rows: list[list[str]],
182
+ selected: list[int] | None = None,
183
+ styles: dict[tuple[int, int], CellStyle] | None = None,
184
+ row_headers: list[str] | None = None,
185
+ ) -> None:
186
+ """Rebuild the whole table, as plain text: a string cell would be read as markup.
187
+
188
+ `selected` replaces the selected rows, which the rows replaced may no longer hold;
189
+ `styles` how the cells look, by row and column, and `row_headers` the rows'
190
+ headers, likewise.
191
+ """
192
+ self._headers, self._rows = headers, rows
193
+ if selected is not None:
194
+ self._selected_rows = selected
195
+ if styles is not None:
196
+ self._styles = styles
197
+ if row_headers is not None:
198
+ self._row_headers = row_headers
199
+ self.clear(columns=True)
200
+ self.header_height = 2 if len(self._groups) > 0 else 1
201
+ check = [Text("")] if self._is_multiple else []
202
+ self.add_columns(*check, *(Text(label) for label in self._labels(headers)))
203
+ covered = {
204
+ (row + offset, column)
205
+ for (row, column), style in self._styles.items()
206
+ for offset in range(1, style.row_span)
207
+ }
208
+ for row, cells in enumerate(rows):
209
+ label = Text(self._row_headers[row]) if row < len(self._row_headers) else None
210
+ self.add_row(
211
+ *check,
212
+ *(
213
+ Text("") if (row, column) in covered else self._cell(row, column, cells)
214
+ for column in range(len(headers))
215
+ ),
216
+ label=label,
217
+ )
218
+ # clearing moved the cursor back to the first row
219
+ self.set_selected_rows(self._selected_rows)
220
+
221
+ def set_header_groups(self, groups: list[HeaderGroup]) -> None:
222
+ self._groups = groups
223
+ self.fill(self._headers, self._rows)
224
+
225
+ def set_cell_widgets(self, widgets: dict[int, CellWidget]) -> None:
226
+ self.cell_widgets = widgets
227
+ self.fill(self._headers, self._rows)
228
+
229
+ def set_checked_columns(self, checked: dict[int, bool | None]) -> None:
230
+ """Show each check-all column's box: checked, unchecked or partly (None)."""
231
+ if checked == self._checked_columns:
232
+ return
233
+ is_relabel = checked.keys() == self._checked_columns.keys()
234
+ self._checked_columns = checked
235
+ if not is_relabel:
236
+ self.fill(self._headers, self._rows)
237
+ return
238
+ # as wide as before, a box for a box: relabelled in place, as rebuilding the table
239
+ # would lose a click being handled, before the row it selects
240
+ columns = list(self.columns.values())[1 if self._is_multiple else 0 :]
241
+ for column, label in zip(columns, self._labels(self._headers)):
242
+ column.label = Text(label)
243
+ self._clear_caches()
244
+ self.refresh()
245
+
246
+ def _kind(self, column: int) -> CellKind | None:
247
+ widget = self.cell_widgets.get(column)
248
+ return None if widget is None else widget.kind
249
+
250
+ def _style(self, row: int, column: int) -> CellStyle:
251
+ return self._styles.get((row, column), PLAIN_CELL)
252
+
253
+ def _cell(self, row: int, column: int, cells: list[str]) -> Text:
254
+ """Show a cell: its mark, then its text or its widget's, as its style says."""
255
+ content = self._content(column, cells)
256
+ style = self._style(row, column)
257
+ if style.mark is not None:
258
+ glyph, mark_style = MARKS[style.mark]
259
+ content = Text.assemble((glyph, mark_style), " ", content)
260
+ if not style.is_enabled:
261
+ content.stylize("dim")
262
+ if style.background is not None:
263
+ content.stylize(f"{style.text_color} on {style.background}")
264
+ return content
265
+
266
+ def _content(self, column: int, cells: list[str]) -> Text:
267
+ """Show a cell's text, or its widget's."""
268
+ text = cells[column] if column < len(cells) else ""
269
+ widget = self.cell_widgets.get(column)
270
+ if widget is None:
271
+ return Text(text)
272
+ if widget.kind is CellKind.CHECK_BOX:
273
+ return Text(_BOXES[text.casefold() == "true"])
274
+ if widget.kind is CellKind.COMBOBOX:
275
+ return Text(f"{text} \u25be")
276
+ if widget.kind is CellKind.BUTTON:
277
+ return Text.assemble(text, " ", (f"[{widget.text}]", "bold"))
278
+ return Text(text, style="underline")
279
+
280
+ def set_sort(self, sort: tuple[int, bool] | None) -> None:
281
+ """Show the order: by a column, descending or not; None for the rows' own."""
282
+ if sort != self._sort:
283
+ self._sort = sort
284
+ self.fill(self._headers, self._rows)
285
+
286
+ def _labels(self, headers: list[str]) -> list[str]:
287
+ labels = [
288
+ f"{_BOXES[self._checked_columns[column]]} {header}"
289
+ if column in self._checked_columns
290
+ else header
291
+ for column, header in enumerate(headers)
292
+ ]
293
+ if self._sort is None or self._sort[0] >= len(headers):
294
+ return self._grouped(labels)
295
+ column, is_descending = self._sort
296
+ labels[column] = f"{labels[column]} {'▼' if is_descending else '▲'}"
297
+ return self._grouped(labels)
298
+
299
+ def _grouped(self, labels: list[str]) -> list[str]:
300
+ """Put each group's text over its first column's label, on a line of its own."""
301
+ if len(self._groups) == 0:
302
+ return labels
303
+ starts = {group.column: group.text for group in self._groups}
304
+ return [f"{starts.get(column, '')}\n{label}" for column, label in enumerate(labels)]
305
+
306
+ def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None:
307
+ column = event.column_index - (1 if self._is_multiple else 0)
308
+ if column in self._checked_columns:
309
+ self.check_all_clicked.emit(column)
310
+ elif column >= 0:
311
+ self.header_clicked.emit(column)
312
+
313
+ def set_multiple_selection(self, *, is_multiple: bool) -> None:
314
+ if is_multiple != self._is_multiple:
315
+ self._is_multiple = is_multiple
316
+ self.fill(self._headers, self._rows) # with or without the check column
317
+
318
+ def set_selected_rows(self, rows: list[int]) -> None:
319
+ self._selected_rows = rows
320
+ if self._is_multiple:
321
+ for row in range(self.row_count):
322
+ self._show_checked(row)
323
+ return
324
+ self.show_cursor = len(rows) > 0
325
+ if len(rows) > 0:
326
+ self.move_cursor(row=rows[0], animate=False)
327
+
328
+ def on_click(self, event: events.Click) -> None:
329
+ # runs before `DataTable`'s own click handler, which only moves a shown cursor
330
+ row = event.style.meta.get("row", -1)
331
+ if row < 0:
332
+ return
333
+ self.show_cursor = True
334
+ if self._is_multiple:
335
+ self._toggle(row)
336
+ column = event.style.meta.get("column", -1) - (1 if self._is_multiple else 0)
337
+ kind = self._kind(column)
338
+ if not self._style(row, column).is_enabled:
339
+ return
340
+ if kind in (CellKind.BUTTON, CellKind.LINK) and event.chain == 1:
341
+ self.cell_clicked.emit(row, column)
342
+ elif column in self._editable_columns(kind=kind):
343
+ if kind is CellKind.CHECK_BOX:
344
+ self._toggle_check(row, column)
345
+ elif kind is CellKind.COMBOBOX:
346
+ self._pick(row, column)
347
+ elif event.chain == _DOUBLE_CLICK:
348
+ self._edit(row, column)
349
+
350
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
351
+ if action == "edit_row":
352
+ is_editable = any(
353
+ len(self._editable_columns(kind=kind)) > 0 for kind in (None, CellKind.COMBOBOX)
354
+ )
355
+ return is_editable and self.show_cursor
356
+ if action == "toggle_check":
357
+ return len(self._editable_columns(kind=CellKind.CHECK_BOX)) > 0 and self.show_cursor
358
+ return super().check_action(action, parameters)
359
+
360
+ def action_edit_row(self) -> None:
361
+ row = self.cursor_row
362
+ typed = self._editable_columns(row=row)
363
+ if len(typed) > 0:
364
+ self._edit(row, typed[0])
365
+ else:
366
+ picked = self._editable_columns(kind=CellKind.COMBOBOX, row=row)
367
+ if len(picked) > 0:
368
+ self._pick(row, picked[0])
369
+
370
+ def action_toggle_check(self) -> None:
371
+ checked = self._editable_columns(kind=CellKind.CHECK_BOX, row=self.cursor_row)
372
+ if len(checked) > 0:
373
+ self._toggle_check(self.cursor_row, checked[0])
374
+
375
+ def _editable_columns(self, kind: CellKind | None = None, row: int | None = None) -> list[int]:
376
+ """Get the `editable_columns` whose cells show `kind`, in order: typed in if None.
377
+
378
+ Those whose cell in `row`, if given, isn't disabled.
379
+ """
380
+ return sorted(
381
+ column
382
+ for column in set(self.editable_columns)
383
+ if 0 <= column < len(self._headers)
384
+ and self._kind(column) is kind
385
+ and (row is None or self._style(row, column).is_enabled)
386
+ )
387
+
388
+ def _set_cell(self, row: int, column: int, text: str) -> None:
389
+ """Show a cell the user changed and emit it, unless the rows no longer have its row."""
390
+ if row >= len(self._rows):
391
+ return
392
+ self._rows = replace_cell(self._rows, row, column, text)
393
+ check = 1 if self._is_multiple else 0
394
+ self.update_cell_at(
395
+ Coordinate(row, column + check),
396
+ self._cell(row, column, self._rows[row]),
397
+ update_width=True,
398
+ )
399
+ self.cell_edited.emit(row, column, text)
400
+
401
+ def _toggle_check(self, row: int, column: int) -> None:
402
+ assert row < len(self._rows) # a row clicked, or the cursor's, shown only on a row
403
+ cells = self._rows[row]
404
+ is_checked = (cells[column] if column < len(cells) else "").casefold() == "true"
405
+ self._set_cell(row, column, CHECKED_TEXTS[not is_checked])
406
+
407
+ def _pick(self, row: int, column: int) -> None:
408
+ assert row < len(self._rows) # see `_toggle_check`
409
+ cells = self._rows[row]
410
+ text = cells[column] if column < len(cells) else ""
411
+ options = list(self.cell_widgets[column].options)
412
+ picker = ComposeOptionPicker([*options, *([] if text in options else [text])], text)
413
+
414
+ def apply(picked: str | None) -> None:
415
+ # the rows the picker was opened on, or the edit would land on another row
416
+ if picked is not None and picked != text and self._rows is rows:
417
+ self._set_cell(row, column, picked)
418
+
419
+ rows = self._rows
420
+ self.app.push_screen(picker, apply)
421
+
422
+ def _edit(self, row: int, focused: int) -> None:
423
+ assert row < len(self._rows) # see `_toggle_check`
424
+ cells = self._rows[row]
425
+ editor = ComposeRowEditor(
426
+ {
427
+ column: (self._headers[column], cells[column] if column < len(cells) else "")
428
+ for column in self._editable_columns(row=row)
429
+ },
430
+ focused,
431
+ )
432
+
433
+ def apply(texts: dict[int, str] | None) -> None:
434
+ # the rows the editor was opened on, or the edit would land on another row
435
+ if texts is None or self._rows is not rows:
436
+ return
437
+ for column, text in texts.items():
438
+ # an app answering an edit with fewer rows leaves none to edit
439
+ if text != (cells[column] if column < len(cells) else ""):
440
+ self._set_cell(row, column, text)
441
+
442
+ rows = self._rows
443
+ self.app.push_screen(editor, apply)
444
+
445
+ def action_select_cursor(self) -> None:
446
+ if self._is_multiple and self.show_cursor:
447
+ self._toggle(self.cursor_row)
448
+ self.show_cursor = True
449
+ super().action_select_cursor()
450
+
451
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
452
+ self._select(event.cursor_row)
453
+
454
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
455
+ self._select(event.cursor_row)
456
+
457
+ def _select(self, row: int) -> None:
458
+ # selecting several rows, only a click or Enter selects, never moving the cursor
459
+ if self._is_multiple:
460
+ return
461
+ # a message about a cursor since moved (e.g. by `fill`) or still hidden is stale
462
+ if self.show_cursor and row == self.cursor_row and self._selected_rows != [row]:
463
+ self._selected_rows = [row]
464
+ self.selected_rows_changed.emit([row])
465
+
466
+ def _toggle(self, row: int) -> None:
467
+ if row >= self.row_count:
468
+ return
469
+ selected = set(self._selected_rows) ^ {row}
470
+ self._selected_rows = sorted(selected)
471
+ self._show_checked(row)
472
+ self.selected_rows_changed.emit(self._selected_rows)
473
+
474
+ def _show_checked(self, row: int) -> None:
475
+ check = _CHECK if row in self._selected_rows else ""
476
+ self.update_cell_at(Coordinate(row, 0), Text(check), update_width=True)
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.containers import Horizontal, Vertical
4
+ from textual.widgets import Button, Static
5
+
6
+ from composeui.core.signal import SignalNoArgs
7
+
8
+
9
+ class ComposeToolbarButton(Button):
10
+ """A toolbar `Button` re-exposing its presses as a plain composeui `Signal`."""
11
+
12
+ def __init__(self) -> None:
13
+ super().__init__(compact=True)
14
+ self.triggered = SignalNoArgs()
15
+
16
+ def on_button_pressed(self, event: Button.Pressed) -> None:
17
+ event.stop()
18
+ self.triggered.emit()
19
+
20
+
21
+ class ComposeToolbarSeparator(Static):
22
+ """A thin bar between two groups of toolbar buttons."""
23
+
24
+ DEFAULT_CSS = """
25
+ ComposeToolbarSeparator {
26
+ width: auto;
27
+ margin-right: 1;
28
+ color: $text-muted;
29
+ }
30
+ """
31
+
32
+ def __init__(self) -> None:
33
+ super().__init__("│")
34
+
35
+
36
+ class ComposeToolbar(Horizontal):
37
+ """A row of toolbar buttons, in the `ComposeToolbars` docked at the top of the screen."""
38
+
39
+ DEFAULT_CSS = """
40
+ ComposeToolbar {
41
+ height: auto;
42
+ padding: 0 1;
43
+ background: $panel;
44
+ }
45
+ ComposeToolbar > ComposeToolbarButton {
46
+ margin-right: 1;
47
+ }
48
+ """
49
+
50
+
51
+ class ComposeToolbars(Vertical):
52
+ """The toolbars, one above the other, docked at the top of the screen.
53
+
54
+ Widgets docked to the same edge overlap: the toolbars share one dock instead.
55
+ """
56
+
57
+ DEFAULT_CSS = """
58
+ ComposeToolbars {
59
+ dock: top;
60
+ height: auto;
61
+ }
62
+ """
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar, Final
4
+
5
+ from rich.style import Style
6
+ from rich.text import Text
7
+ from textual.widgets import Tree
8
+
9
+ from composeui.core.signal import Signal
10
+ from composeui.core.view import NodeMark
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterable, Iterator
14
+
15
+ from textual.widgets.tree import TreeNode as TextualTreeNode
16
+
17
+ from composeui.core.view import TreeNode, TreePath
18
+
19
+
20
+ #: a `NodeMark` after its node's label: its glyph and color, as a log's are colored
21
+ MARKS: Final = {
22
+ NodeMark.DONE: ("✓", Style(color="green")),
23
+ NodeMark.WARNING: ("!", Style(color="orange1", bold=True)),
24
+ NodeMark.ERROR: ("✗", Style(color="red")),
25
+ }
26
+
27
+
28
+ class ComposeTree(Tree["TreePath"]):
29
+ """A `Tree` of a `TreeView`'s nodes, its root hidden, each node's data its path.
30
+
31
+ A node's label is bold or underlined as the node says, its mark after it; a terminal
32
+ shows no icon.
33
+
34
+ Enter or a click selects the node under the cursor, its label marked
35
+ (`tree--selected`), as the cursor moves on its own; Space or a click on its arrow
36
+ expands or collapses it. `selected` and `toggled` report the user's moves only:
37
+ `set_nodes`, `set_expanded` and `set_selected` keep quiet, as Qt's renderers block
38
+ their widget's signals while refreshing it.
39
+ """
40
+
41
+ COMPONENT_CLASSES: ClassVar[set[str]] = {*Tree.COMPONENT_CLASSES, "tree--selected"}
42
+
43
+ DEFAULT_CSS = """
44
+ ComposeTree {
45
+ height: auto;
46
+ max-height: 20;
47
+ & > .tree--selected {
48
+ color: $accent;
49
+ text-style: bold;
50
+ }
51
+ }
52
+ """
53
+
54
+ def __init__(self) -> None:
55
+ super().__init__("")
56
+ self.show_root = False
57
+ self.auto_expand = False
58
+ self.selected_path: TreePath | None = None
59
+ self._marks: dict[TreePath, NodeMark] = {}
60
+ self.selected: Signal[TreePath] = Signal()
61
+ self.toggled: Signal[TreePath, bool] = Signal()
62
+
63
+ def set_nodes(
64
+ self, nodes: Iterable[TreeNode], expanded: Iterable[TreePath], selected: TreePath | None
65
+ ) -> None:
66
+ self.clear()
67
+ self.selected_path = selected
68
+ self._marks = {}
69
+ is_expanded = set(expanded)
70
+
71
+ def add(
72
+ parent: TextualTreeNode[TreePath], nodes: Iterable[TreeNode], path: TreePath
73
+ ) -> None:
74
+ for node in nodes:
75
+ node_path = (*path, node.key)
76
+ if node.mark is not None:
77
+ self._marks[node_path] = node.mark
78
+ # a `Text`, not markup: the label is the app's data
79
+ label = Text(
80
+ node.label,
81
+ Style(bold=node.is_bold or None, underline=node.is_underlined or None),
82
+ )
83
+ if len(node.children) == 0:
84
+ parent.add_leaf(label, node_path)
85
+ else:
86
+ item = parent.add(label, node_path, expand=node_path in is_expanded)
87
+ add(item, node.children, node_path)
88
+
89
+ add(self.root, nodes, ())
90
+
91
+ def set_expanded(self, expanded: Iterable[TreePath]) -> None:
92
+ is_expanded = set(expanded)
93
+ with self.prevent(Tree.NodeExpanded, Tree.NodeCollapsed):
94
+ for node in self._branch_nodes():
95
+ if node.allow_expand and node.is_expanded != (node.data in is_expanded):
96
+ node.toggle()
97
+
98
+ def set_selected(self, selected: TreePath | None) -> None:
99
+ self.selected_path = selected
100
+ for node in self._branch_nodes():
101
+ node.refresh() # its label, marked or not
102
+ if node.data == selected:
103
+ self.move_cursor(node)
104
+
105
+ def render_label(
106
+ self, node: TextualTreeNode[TreePath], base_style: Style, style: Style
107
+ ) -> Text:
108
+ if node.data is not None and node.data == self.selected_path:
109
+ style += self.get_component_rich_style("tree--selected", partial=True)
110
+ label = super().render_label(node, base_style, style)
111
+ mark = None if node.data is None else self._marks.get(node.data)
112
+ if mark is not None: # in its own color, the selected label's left out
113
+ glyph, color = MARKS[mark]
114
+ label.append(f" {glyph}", base_style + color)
115
+ return label
116
+
117
+ def on_tree_node_selected(self, event: Tree.NodeSelected[TreePath]) -> None:
118
+ path = event.node.data
119
+ if path is None or path == self.selected_path:
120
+ return
121
+ self.set_selected(path)
122
+ self.selected.emit(path)
123
+
124
+ def on_tree_node_expanded(self, event: Tree.NodeExpanded[TreePath]) -> None:
125
+ # the hidden root's data is None: no event from it, as only the code expands it
126
+ if event.node.data is not None: # pragma: no branch
127
+ self.toggled.emit(event.node.data, True) # noqa: FBT003
128
+
129
+ def on_tree_node_collapsed(self, event: Tree.NodeCollapsed[TreePath]) -> None:
130
+ if event.node.data is not None: # pragma: no branch
131
+ self.toggled.emit(event.node.data, False) # noqa: FBT003
132
+
133
+ def _branch_nodes(self) -> Iterator[TextualTreeNode[TreePath]]:
134
+ """Get the nodes under the root, depth first."""
135
+ nodes = list(reversed(self.root.children))
136
+ while len(nodes) > 0:
137
+ node = nodes.pop()
138
+ yield node
139
+ nodes.extend(reversed(node.children))
File without changes