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,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)
|