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,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from qtpy.QtWidgets import QHBoxLayout, QLineEdit, QStyle, QToolButton, QWidget
6
+
7
+
8
+ class QFilePathField(QWidget):
9
+ """A field typing a file's path, then a "…" button and a reload button, hidden by default.
10
+
11
+ The buttons only report their clicks: the renderer opens the file dialog. Both are
12
+ squares as tall as the field: a tool button's own size depends on its text or icon,
13
+ so "…" and the icon would differ from the field and from each other.
14
+ """
15
+
16
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
17
+ super().__init__(*args, **kwargs)
18
+ layout = QHBoxLayout(self)
19
+ layout.setContentsMargins(0, 0, 0, 0)
20
+ layout.setSpacing(2)
21
+ self.line_edit = QLineEdit()
22
+ layout.addWidget(self.line_edit)
23
+ self.browse_button = QToolButton()
24
+ self.browse_button.setText("…")
25
+ layout.addWidget(self.browse_button)
26
+ self.reload_button = QToolButton()
27
+ self.reload_button.setIcon(
28
+ self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)
29
+ )
30
+ self.reload_button.setVisible(False)
31
+ layout.addWidget(self.reload_button)
32
+ side = self.line_edit.sizeHint().height()
33
+ for button in (self.browse_button, self.reload_button):
34
+ button.setFixedSize(side, side)
@@ -0,0 +1,394 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Callable
4
+
5
+ from qtpy.QtCore import QMargins, QPoint, QRect, QSize, Qt, QTimer, Signal
6
+ from qtpy.QtWidgets import (
7
+ QComboBox,
8
+ QHBoxLayout,
9
+ QLayout,
10
+ QLineEdit,
11
+ QPushButton,
12
+ QSizePolicy,
13
+ QStyle,
14
+ QToolButton,
15
+ QVBoxLayout,
16
+ QWidget,
17
+ QWidgetItem,
18
+ )
19
+
20
+ from composeui.core.view import SEARCH_DELAY_MS, as_pattern, split_pattern
21
+ from composeui.qtcore.widgets.qplaintext import plain_label, plain_tool_tip
22
+ from composeui.qtcore.widgets.qtexttable import QTextTableView
23
+
24
+ if TYPE_CHECKING:
25
+ from qtpy.QtGui import QKeyEvent
26
+ from qtpy.QtWidgets import QLayoutItem
27
+
28
+ #: a chip's remove button: a multiplication sign, as a close button often is
29
+ REMOVE = "\u00d7"
30
+ #: after a chip's text: a pencil, as its click edits the filter
31
+ EDIT = "\u270e"
32
+ #: the value field's style while what it holds can't filter, as a form's invalid field
33
+ _INVALID_STYLE = "QLineEdit { border: 1px solid red; }"
34
+
35
+
36
+ class QFlowLayout(QLayout):
37
+ """Lays its widgets out in a row, wrapping onto the next when the row is full.
38
+
39
+ Qt has none built in: the Qt documentation's Flow Layout example.
40
+ """
41
+
42
+ def __init__(self, parent: QWidget | None = None, spacing: int = 4) -> None:
43
+ super().__init__(parent)
44
+ self._items: list[QLayoutItem] = []
45
+ self._spacing = spacing
46
+ self.setContentsMargins(QMargins(0, 0, 0, 0))
47
+
48
+ def addItem(self, item: QLayoutItem) -> None: # noqa: N802
49
+ self._items.append(item)
50
+
51
+ def count(self) -> int:
52
+ return len(self._items)
53
+
54
+ def insert_widget(self, index: int, widget: QWidget) -> None:
55
+ self.addChildWidget(widget)
56
+ # now: Qt would show it on its next pass, after laying it out as if hidden
57
+ widget.show()
58
+ self._items.insert(index, QWidgetItem(widget))
59
+ self.invalidate()
60
+
61
+ def itemAt(self, index: int) -> QLayoutItem | None: # noqa: N802
62
+ return self._items[index] if 0 <= index < len(self._items) else None
63
+
64
+ def takeAt(self, index: int) -> QLayoutItem | None: # noqa: N802
65
+ return self._items.pop(index) if 0 <= index < len(self._items) else None
66
+
67
+ def expandingDirections(self) -> Qt.Orientation: # noqa: N802
68
+ return Qt.Orientation(0)
69
+
70
+ def hasHeightForWidth(self) -> bool: # noqa: N802
71
+ return True
72
+
73
+ def heightForWidth(self, width: int) -> int: # noqa: N802
74
+ return self._lay_out(QRect(0, 0, width, 0), move=False)
75
+
76
+ def setGeometry(self, rect: QRect) -> None: # noqa: N802
77
+ super().setGeometry(rect)
78
+ self._lay_out(rect, move=True)
79
+
80
+ def sizeHint(self) -> QSize: # noqa: N802
81
+ return self.minimumSize()
82
+
83
+ def minimumSize(self) -> QSize: # noqa: N802
84
+ size = QSize()
85
+ for item in self._items:
86
+ size = size.expandedTo(item.minimumSize())
87
+ margins = self.contentsMargins()
88
+ return size + QSize(margins.left() + margins.right(), margins.top() + margins.bottom())
89
+
90
+ def _lay_out(self, rect: QRect, *, move: bool) -> int:
91
+ """Place the items in `rect`'s width (if `move`); return the height they take."""
92
+ x, y, line_height = rect.x(), rect.y(), 0
93
+ for item in self._items:
94
+ if item.isEmpty(): # a hidden widget takes no room
95
+ continue
96
+ hint = item.sizeHint()
97
+ if x + hint.width() > rect.right() + 1 and line_height > 0:
98
+ x, y, line_height = rect.x(), y + line_height + self._spacing, 0
99
+ if move:
100
+ item.setGeometry(QRect(QPoint(x, y), hint))
101
+ x += hint.width() + self._spacing
102
+ line_height = max(line_height, hint.height())
103
+ return y + line_height - rect.y()
104
+
105
+
106
+ class _ValueEdit(QLineEdit):
107
+ """The filter editor's value field: Escape closes the editor."""
108
+
109
+ escaped = Signal()
110
+
111
+ def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
112
+ if event.key() == Qt.Key.Key_Escape:
113
+ self.escaped.emit()
114
+ return
115
+ super().keyPressEvent(event)
116
+
117
+
118
+ class _ChipButton(QToolButton):
119
+ """A chip's text, which Enter clicks too, as Space does: the focus lands on a new one."""
120
+
121
+ def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
122
+ if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
123
+ self.click()
124
+ return
125
+ super().keyPressEvent(event)
126
+
127
+
128
+ class QFilterChip(QWidget):
129
+ """A filter, as its column's header then its text, opening it, and a button removing it.
130
+
131
+ The pencil after the text and the tooltip say a click edits it.
132
+ """
133
+
134
+ def __init__(self, text: str, edit_text: str, remove_text: str) -> None:
135
+ super().__init__()
136
+ self.open_button = _ChipButton()
137
+ self.open_button.setText(f"{text} {EDIT}")
138
+ self.open_button.setAccessibleName(text)
139
+ self.open_button.setToolTip(plain_tool_tip(edit_text))
140
+ self.open_button.setAutoRaise(True)
141
+ self.remove_button = QToolButton()
142
+ self.remove_button.setText(REMOVE)
143
+ self.remove_button.setAutoRaise(True)
144
+ self.remove_button.setToolTip(plain_tool_tip(remove_text))
145
+ self.remove_button.setAccessibleName(remove_text)
146
+ layout = QHBoxLayout(self)
147
+ layout.setContentsMargins(4, 0, 0, 0)
148
+ layout.setSpacing(0)
149
+ layout.addWidget(self.open_button)
150
+ layout.addWidget(self.remove_button)
151
+ self.setObjectName("chip")
152
+ self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)
153
+ self.setStyleSheet("#chip { border: 1px solid palette(mid); border-radius: 10px; }")
154
+
155
+
156
+ class QTableFilterBar(QWidget):
157
+ """A table's filter bar: a search, the filters as chips and the count of rows shown.
158
+
159
+ "+ Filter" opens an editor in the bar, of a column, a value and an Add button, and in
160
+ a text column a toggle (`pattern`) making the value a regular expression: Enter
161
+ or Add adds the filter, unless `check` says why its value can't filter, marking the
162
+ value field invalid; Escape closes it. Clicking a chip opens the editor on its
163
+ filter, its remove button removes it. The signals emit only for the user: `search_edited` the
164
+ text typed, `filter_added` a column and its text, `filter_removed` a column, and
165
+ `filters_cleared`. `search_edited` waits `SEARCH_DELAY_MS` after the last key, but
166
+ emits at once on Enter or once the search is cleared.
167
+ """
168
+
169
+ search_edited = Signal(str)
170
+ filter_added = Signal(int, str)
171
+ filter_removed = Signal(int)
172
+ filters_cleared = Signal()
173
+
174
+ def __init__(self) -> None:
175
+ super().__init__()
176
+ #: why a column's text can't filter it, or None if it can
177
+ self.check: Callable[[int, str], str | None] = lambda _column, _text: None
178
+ #: a hint at what filters a column, as a regular expression or not
179
+ self.placeholder: Callable[[int, bool], str] = lambda _column, _is_pattern: ""
180
+ #: whether a regular expression filters a column
181
+ self.takes_pattern: Callable[[int], bool] = lambda _column: False
182
+ self._texts: dict[str, str] = {}
183
+ self._filters: list[tuple[int, str, str, str]] = []
184
+ #: the column whose chip takes the focus once shown, its filter just added
185
+ self._focused_chip: int | None = None
186
+ self.search = QLineEdit()
187
+ self.search.setClearButtonEnabled(True)
188
+ icon = self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogContentsView)
189
+ self.search.addAction(icon, QLineEdit.ActionPosition.LeadingPosition)
190
+ self._search_timer = QTimer(self)
191
+ self._search_timer.setSingleShot(True)
192
+ self._search_timer.setInterval(SEARCH_DELAY_MS)
193
+ self._search_timer.timeout.connect(self._emit_search)
194
+ self.search.textEdited.connect(self._search_typed)
195
+ self.search.returnPressed.connect(self._emit_search)
196
+ self.add_filter = QToolButton()
197
+ self.add_filter.clicked.connect(self._open_new_editor)
198
+ self.column = QComboBox()
199
+ self.column.currentIndexChanged.connect(self._show_placeholder)
200
+ self.pattern = QToolButton()
201
+ self.pattern.setText(".*")
202
+ self.pattern.setCheckable(True)
203
+ self.pattern.toggled.connect(self._show_placeholder)
204
+ self.value = _ValueEdit()
205
+ self.value.returnPressed.connect(self._add)
206
+ self.value.escaped.connect(self.close_editor)
207
+ self.value.textEdited.connect(lambda _text: self._mark_invalid(None))
208
+ self.add = QPushButton()
209
+ self.add.clicked.connect(self._add)
210
+ self.clear = QToolButton()
211
+ self.clear.clicked.connect(self.filters_cleared)
212
+ self.count = plain_label()
213
+ # as tall as the first line's fields, its text in their middle
214
+ self.count.setMinimumHeight(self.search.sizeHint().height())
215
+ self.count.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
216
+ self._chips: list[QFilterChip] = []
217
+ # the fields and the chips wrap in the width left; the count stays at the right end
218
+ flow = QWidget()
219
+ self._layout = QFlowLayout(flow)
220
+ layout = QHBoxLayout(self)
221
+ layout.setContentsMargins(0, 0, 0, 0)
222
+ layout.addWidget(flow, 1)
223
+ layout.addWidget(self.count, 0, Qt.AlignmentFlag.AlignTop)
224
+ for widget in (
225
+ self.search,
226
+ self.add_filter,
227
+ self.column,
228
+ self.pattern,
229
+ self.value,
230
+ self.add,
231
+ ):
232
+ self._layout.addWidget(widget)
233
+ self._layout.addWidget(self.clear)
234
+ self.search.setMinimumWidth(160)
235
+ self.value.setMinimumWidth(120)
236
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
237
+ self.close_editor()
238
+
239
+ def set_texts(self, texts: dict[str, str]) -> None:
240
+ self._texts = texts
241
+ self.search.setPlaceholderText(texts.get("search", ""))
242
+ self.add_filter.setText(texts.get("add_filter", ""))
243
+ self.add.setText(texts.get("add", ""))
244
+ self.clear.setText(texts.get("clear", ""))
245
+ self.pattern.setToolTip(plain_tool_tip(texts.get("pattern", "")))
246
+ self.pattern.setAccessibleName(texts.get("pattern", ""))
247
+ self.column.setAccessibleName(texts.get("column", ""))
248
+ self.value.setAccessibleName(texts.get("value", ""))
249
+ self.set_filters(self._filters)
250
+
251
+ def set_columns(self, columns: list[tuple[int, str]]) -> None:
252
+ """Offer `columns`, each its index and its header, in the editor."""
253
+ column = self.column.currentData()
254
+ self.column.blockSignals(True) # noqa: FBT003
255
+ self.column.clear()
256
+ for index, header in columns:
257
+ self.column.addItem(header, index)
258
+ found = self.column.findData(column)
259
+ self.column.setCurrentIndex(max(found, 0))
260
+ self.column.blockSignals(False) # noqa: FBT003
261
+ self._show_placeholder()
262
+
263
+ def set_search(self, text: str) -> None:
264
+ if self.search.text() != text:
265
+ self._search_timer.stop() # the app's search, not the user's typed before
266
+ self.search.setText(text)
267
+
268
+ def _search_typed(self, text: str) -> None:
269
+ if text == "":
270
+ self._emit_search()
271
+ else:
272
+ self._search_timer.start()
273
+
274
+ def _emit_search(self) -> None:
275
+ self._search_timer.stop()
276
+ self.search_edited.emit(self.search.text())
277
+
278
+ def set_filters(self, filters: list[tuple[int, str, str, str]]) -> None:
279
+ """Show each filter as a chip: its column, the chip's text, the text typed, the header."""
280
+ self._filters = filters
281
+ for chip in self._chips:
282
+ self._layout.removeWidget(chip)
283
+ chip.deleteLater()
284
+ self._chips = []
285
+ position = self._layout.indexOf(self.clear)
286
+ for column, text, typed, header in filters:
287
+ chip = QFilterChip(
288
+ text,
289
+ self._texts.get("edit", "{header}").format(header=header),
290
+ self._texts.get("remove", "{header}").format(header=header),
291
+ )
292
+ chip.open_button.clicked.connect(
293
+ lambda _checked=False, c=column, t=typed: self.open_editor(c, t)
294
+ )
295
+ chip.remove_button.clicked.connect(
296
+ lambda _checked=False, c=column: self.filter_removed.emit(c)
297
+ )
298
+ self._layout.insert_widget(position, chip)
299
+ position += 1
300
+ self._chips.append(chip)
301
+ if column == self._focused_chip:
302
+ # once shown: the layout shows a widget it takes on the next pass
303
+ QTimer.singleShot(0, chip.open_button.setFocus)
304
+ self._focused_chip = None
305
+ self.clear.setVisible(len(filters) >= 2) # noqa: PLR2004
306
+
307
+ @property
308
+ def chips(self) -> list[QFilterChip]:
309
+ return list(self._chips)
310
+
311
+ def set_count(self, text: str) -> None:
312
+ self.count.setText(text)
313
+ self.count.setVisible(text != "")
314
+
315
+ def open_editor(self, column: int | None = None, text: str = "") -> None:
316
+ if column is not None:
317
+ self.column.setCurrentIndex(max(self.column.findData(column), 0))
318
+ text, is_pattern = split_pattern(text)
319
+ self.pattern.setChecked(is_pattern)
320
+ self.value.setText(text)
321
+ self._mark_invalid(None)
322
+ for widget in (self.column, self.value, self.add):
323
+ widget.setVisible(True)
324
+ self._show_placeholder()
325
+ self.add_filter.setVisible(False)
326
+ self.value.setFocus()
327
+
328
+ def _open_new_editor(self) -> None:
329
+ self.open_editor()
330
+
331
+ def close_editor(self) -> None:
332
+ had_focus = self.value.hasFocus()
333
+ for widget in (self.column, self.pattern, self.value, self.add):
334
+ widget.setVisible(False)
335
+ self.add_filter.setVisible(True)
336
+ if had_focus:
337
+ self.add_filter.setFocus()
338
+
339
+ def is_editing(self) -> bool:
340
+ return not self.value.isHidden()
341
+
342
+ def _add(self) -> None:
343
+ column = self.column.currentData()
344
+ assert isinstance(column, int) # the bar shows only with a column to filter
345
+ text = self.value.text()
346
+ if self.pattern.isChecked() and self.takes_pattern(column):
347
+ text = as_pattern(text)
348
+ reason = self.check(column, text)
349
+ if reason is not None:
350
+ self._mark_invalid(reason)
351
+ return
352
+ self.close_editor()
353
+ # its chip takes the focus: Enter opens it again
354
+ self._focused_chip = column
355
+ self.filter_added.emit(column, text)
356
+ self._focused_chip = None
357
+
358
+ def _mark_invalid(self, reason: str | None) -> None:
359
+ self.value.setStyleSheet(_INVALID_STYLE if reason is not None else "")
360
+ self.value.setToolTip(plain_tool_tip(reason or ""))
361
+ self.value.setProperty("invalid", reason is not None)
362
+
363
+ def _show_placeholder(self) -> None:
364
+ column = self.column.currentData()
365
+ if not isinstance(column, int):
366
+ self.value.setPlaceholderText("")
367
+ return
368
+ # a toggle only where a regular expression filters, while editing
369
+ takes_pattern = self.takes_pattern(column)
370
+ self.pattern.setVisible(takes_pattern and not self.value.isHidden())
371
+ is_pattern = takes_pattern and self.pattern.isChecked()
372
+ self.value.setPlaceholderText(self.placeholder(column, is_pattern))
373
+
374
+
375
+ class QFilterTable(QWidget):
376
+ """A table of text under its filter bar, which shows only while a column filters.
377
+
378
+ When the filters hide every row, `empty` says so under the headers.
379
+ """
380
+
381
+ def __init__(self) -> None:
382
+ super().__init__()
383
+ self.bar = QTableFilterBar()
384
+ self.table = QTextTableView()
385
+ self.empty = plain_label()
386
+ self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
387
+ self.empty.setVisible(False)
388
+ layout = QVBoxLayout(self)
389
+ layout.setContentsMargins(0, 0, 0, 0)
390
+ layout.addWidget(self.bar)
391
+ layout.addWidget(self.table)
392
+ layout.addWidget(self.empty)
393
+ self.bar.setVisible(False)
394
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from qtpy.QtCore import Signal as QtSignal
6
+ from qtpy.QtWidgets import QDialog, QDialogButtonBox, QPushButton, QVBoxLayout, QWidget
7
+
8
+ from composeui.qtcore.widgets.qplaintext import plain_label
9
+
10
+ if TYPE_CHECKING:
11
+ from qtpy.QtGui import QCloseEvent
12
+
13
+
14
+ class QFormDialog(QDialog):
15
+ """A modal dialog: its text, its form, then OK and Cancel.
16
+
17
+ It reports the user's answer without closing (`answered_ok`, `answered_cancel`, and
18
+ `dismissed` for its window closed): its view closes it, and stays open when its
19
+ form refuses OK. Escape is Cancel, as in any dialog.
20
+ """
21
+
22
+ answered_ok = QtSignal()
23
+ answered_cancel = QtSignal()
24
+ dismissed = QtSignal()
25
+
26
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
27
+ super().__init__(*args, **kwargs)
28
+ self.setModal(True)
29
+ self._layout = QVBoxLayout(self)
30
+ self.text = plain_label()
31
+ self.text.setWordWrap(True)
32
+ self._layout.addWidget(self.text)
33
+ self.buttons = QDialogButtonBox(
34
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
35
+ )
36
+ self.buttons.accepted.connect(self.accept)
37
+ self.buttons.rejected.connect(self.reject)
38
+ self._layout.addWidget(self.buttons)
39
+
40
+ @property
41
+ def ok_button(self) -> QPushButton:
42
+ button = self.buttons.button(QDialogButtonBox.StandardButton.Ok)
43
+ assert button is not None
44
+ return button
45
+
46
+ @property
47
+ def cancel_button(self) -> QPushButton:
48
+ button = self.buttons.button(QDialogButtonBox.StandardButton.Cancel)
49
+ assert button is not None
50
+ return button
51
+
52
+ def set_form(self, form: QWidget) -> None:
53
+ """Show `form` between the text and the buttons."""
54
+ self._layout.insertWidget(1, form)
55
+
56
+ def accept(self) -> None:
57
+ self.answered_ok.emit()
58
+
59
+ def reject(self) -> None:
60
+ self.answered_cancel.emit()
61
+
62
+ def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
63
+ event.ignore() # closed by its view, once it hears the answer
64
+ self.dismissed.emit()
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from qtpy.QtCore import QPoint
6
+ from qtpy.QtWidgets import QHBoxLayout, QToolButton, QToolTip, QWidget
7
+
8
+ from composeui.qtcore.widgets.qplaintext import plain_label, plain_tool_tip
9
+
10
+
11
+ class QFormLabel(QWidget):
12
+ """A form row's label, then a "?" button showing the row's help, if it has any.
13
+
14
+ Hovering shows the help as a tooltip; clicking the button, or Space once Tab reached
15
+ it, shows it too, for a touch screen or the keyboard.
16
+ """
17
+
18
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
19
+ super().__init__(*args, **kwargs)
20
+ layout = QHBoxLayout(self)
21
+ layout.setContentsMargins(0, 0, 0, 0)
22
+ self.text_label = plain_label()
23
+ layout.addWidget(self.text_label)
24
+ self._help = ""
25
+ self.help_button = QToolButton()
26
+ self.help_button.setText("?")
27
+ self.help_button.setAutoRaise(True)
28
+ self.help_button.setVisible(False)
29
+ self.help_button.clicked.connect(self.show_help)
30
+ layout.addWidget(self.help_button)
31
+ # both packed at the start, the "?" right after the text
32
+ layout.addStretch()
33
+
34
+ def set_text(self, text: str) -> None:
35
+ self.text_label.setText(text)
36
+
37
+ def help(self) -> str:
38
+ return self._help
39
+
40
+ def set_help(self, text: str) -> None:
41
+ # on the whole label: hovering its text shows the help too
42
+ self._help = text
43
+ self.setToolTip(plain_tool_tip(text))
44
+ self.help_button.setToolTip(plain_tool_tip(text))
45
+ self.help_button.setVisible(text != "")
46
+
47
+ def set_help_button_text(self, text: str) -> None:
48
+ self.help_button.setAccessibleName(text)
49
+
50
+ def show_help(self) -> None:
51
+ """Show the help under the button, as hovering it would."""
52
+ below = self.help_button.mapToGlobal(QPoint(0, self.help_button.height()))
53
+ QToolTip.showText(below, plain_tool_tip(self.help()), self.help_button)
@@ -0,0 +1,68 @@
1
+ """A Qt Location map embedded in a Widgets layout, without a browser engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from qtpy.QtCore import Qt, QUrl
9
+ from qtpy.QtCore import Signal as QtSignal
10
+ from qtpy.QtQuickWidgets import QQuickWidget
11
+ from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
12
+ from typing_extensions import override
13
+
14
+ from composeui.qtcore.widgets.qplaintext import plain_label
15
+
16
+ if TYPE_CHECKING:
17
+ from qtpy.QtGui import QResizeEvent
18
+
19
+
20
+ class _MapCanvas(QQuickWidget):
21
+ resized = QtSignal()
22
+
23
+ @override
24
+ def resizeEvent(self, event: QResizeEvent) -> None:
25
+ super().resizeEvent(event)
26
+ self.resized.emit()
27
+
28
+
29
+ class QMap(QWidget):
30
+ """A title above a Qt Location map with a single canvas for its points."""
31
+
32
+ viewport_moved = QtSignal(float, float, float)
33
+ point_clicked = QtSignal(float, float)
34
+
35
+ def __init__(self, user_agent: str, *, tiles: bool) -> None:
36
+ super().__init__()
37
+ layout = QVBoxLayout(self)
38
+ layout.setContentsMargins(0, 0, 0, 0)
39
+ self.title: QLabel = plain_label()
40
+ layout.addWidget(self.title)
41
+ self.map = _MapCanvas()
42
+ self.map.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
43
+ self.map.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView)
44
+ layout.addWidget(self.map)
45
+ self._settings: tuple[str, bool] | None = None
46
+ self.configure(user_agent, tiles=tiles)
47
+
48
+ def configure(self, user_agent: str, *, tiles: bool) -> None:
49
+ """Load the component with its tile settings, which Qt's plugin reads only once."""
50
+ if self._settings == (user_agent, tiles):
51
+ return
52
+ self._settings = (user_agent, tiles)
53
+ self.map.setSource(QUrl())
54
+ self.map.rootContext().setContextProperty("mapUserAgent", user_agent)
55
+ self.map.setInitialProperties({"tiles": tiles})
56
+ self.map.setSource(QUrl.fromLocalFile(str(Path(__file__).with_name("map.qml"))))
57
+ self.root.viewportMoved.connect(self.viewport_moved)
58
+ self.root.pointClicked.connect(self.point_clicked)
59
+
60
+ @property
61
+ def root(self) -> Any:
62
+ """Get the map's QML root after loading its component."""
63
+ root = self.map.rootObject()
64
+ if root is None:
65
+ message = "; ".join(error.toString() for error in self.map.errors())
66
+ message = f"Cannot load the Qt Location map: {message}"
67
+ raise RuntimeError(message)
68
+ return root
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ from typing import Final
5
+
6
+ from qtpy.QtCore import QPoint, Qt
7
+ from qtpy.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
8
+
9
+ from composeui.core.view import NodeMark
10
+
11
+ #: a `NodeMark`'s color, as a log's warning and error are colored
12
+ _MARK_COLORS: Final = {NodeMark.DONE: "green", NodeMark.WARNING: "orange", NodeMark.ERROR: "red"}
13
+
14
+
15
+ @functools.cache
16
+ def mark_icon(mark: NodeMark) -> QIcon:
17
+ """Draw `mark` in a circle, as the web's icons are: a check, a "!" or a cross."""
18
+ size = 32 # scaled down sharp, on a high-density screen too
19
+ pixmap = QPixmap(size, size)
20
+ pixmap.fill(Qt.GlobalColor.transparent)
21
+ painter = QPainter(pixmap)
22
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
23
+ pen = QPen(QColor(_MARK_COLORS[mark]), 3)
24
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
25
+ pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
26
+ painter.setPen(pen)
27
+ painter.drawEllipse(3, 3, 26, 26)
28
+ if mark is NodeMark.DONE:
29
+ painter.drawPolyline([QPoint(10, 16), QPoint(14, 20), QPoint(22, 12)])
30
+ elif mark is NodeMark.WARNING:
31
+ painter.drawLine(16, 10, 16, 17)
32
+ painter.drawPoint(16, 22)
33
+ else:
34
+ painter.drawLine(11, 11, 21, 21)
35
+ painter.drawLine(21, 11, 11, 21)
36
+ painter.end()
37
+ return QIcon(pixmap)