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