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,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from qtpy.QtCore import Qt
|
|
6
|
+
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
|
7
|
+
|
|
8
|
+
from composeui.qtcore.widgets.qplaintext import plain_label
|
|
9
|
+
|
|
10
|
+
#: the badge's text color and its tint behind, for a rise and a fall; readable on a light
|
|
11
|
+
#: and a dark palette alike
|
|
12
|
+
_RISE_STYLE = "QLabel { color: #16a34a; background: rgba(22, 163, 74, 0.15); }"
|
|
13
|
+
_FALL_STYLE = "QLabel { color: #dc2626; background: rgba(220, 38, 38, 0.15); }"
|
|
14
|
+
_FLAT_STYLE = "QLabel { background: rgba(128, 128, 128, 0.15); }"
|
|
15
|
+
_BADGE_STYLE = "QLabel { border-radius: 4px; padding: 1px 6px; }"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class QMetric(QWidget):
|
|
19
|
+
"""A metric: its label, its value in a large bold font, then its change as a badge."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
22
|
+
super().__init__(*args, **kwargs)
|
|
23
|
+
layout = QVBoxLayout(self)
|
|
24
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
25
|
+
self.label = plain_label()
|
|
26
|
+
layout.addWidget(self.label)
|
|
27
|
+
self.value = plain_label()
|
|
28
|
+
font = self.value.font()
|
|
29
|
+
font.setPointSizeF(font.pointSizeF() * 2)
|
|
30
|
+
font.setBold(True)
|
|
31
|
+
self.value.setFont(font)
|
|
32
|
+
layout.addWidget(self.value)
|
|
33
|
+
self.badge = plain_label()
|
|
34
|
+
self.badge.setVisible(False)
|
|
35
|
+
# as wide as its text, not the column
|
|
36
|
+
layout.addWidget(self.badge, alignment=Qt.AlignmentFlag.AlignLeft)
|
|
37
|
+
layout.addStretch()
|
|
38
|
+
|
|
39
|
+
def set_delta(self, badge: str, sign: int) -> None:
|
|
40
|
+
"""Show `badge`, colored by the `sign` of its change; an empty one hides it."""
|
|
41
|
+
self.badge.setText(badge)
|
|
42
|
+
self.badge.setVisible(badge != "")
|
|
43
|
+
tint = _RISE_STYLE if sign > 0 else _FALL_STYLE if sign < 0 else _FLAT_STYLE
|
|
44
|
+
self.badge.setStyleSheet(f"{_BADGE_STYLE} {tint}")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""View text that Qt shows as it is, never read as HTML.
|
|
2
|
+
|
|
3
|
+
A `QLabel` and a tooltip guess their text's format: a text that looks like HTML, such as
|
|
4
|
+
`<b>x</b>`, would show as rich text, its tags hidden and its links clickable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import html
|
|
10
|
+
|
|
11
|
+
from qtpy.QtCore import Qt
|
|
12
|
+
from qtpy.QtWidgets import QLabel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def plain_label() -> QLabel:
|
|
16
|
+
"""Get a label that shows its text as plain text."""
|
|
17
|
+
label = QLabel()
|
|
18
|
+
label.setTextFormat(Qt.TextFormat.PlainText)
|
|
19
|
+
return label
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def plain_tool_tip(text: str) -> str:
|
|
23
|
+
"""Get `text` as a tooltip that shows it as it is.
|
|
24
|
+
|
|
25
|
+
A tooltip has no plain format: a text with a tag is escaped as rich text instead.
|
|
26
|
+
"""
|
|
27
|
+
if "<" not in text:
|
|
28
|
+
return text # no tag: Qt shows it as plain text
|
|
29
|
+
return "<p>{}</p>".format(html.escape(text, quote=False).replace("\n", "<br>"))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from qtpy.QtCore import Signal, Slot
|
|
6
|
+
from qtpy.QtWidgets import (
|
|
7
|
+
QButtonGroup,
|
|
8
|
+
QHBoxLayout,
|
|
9
|
+
QRadioButton,
|
|
10
|
+
QWidget,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QRadioGroup(QWidget):
|
|
15
|
+
"""Custom signal for the Radio group."""
|
|
16
|
+
|
|
17
|
+
value_changed = Signal(object)
|
|
18
|
+
|
|
19
|
+
def __init__(self, values: dict[str, Any], *args: Any, **kwargs: Any) -> None:
|
|
20
|
+
super().__init__(*args, **kwargs)
|
|
21
|
+
self._layout = QHBoxLayout()
|
|
22
|
+
self._layout.setContentsMargins(0, 0, 0, 0)
|
|
23
|
+
# the buttons packed at the start, as on the web, not spread over the row
|
|
24
|
+
self._layout.addStretch()
|
|
25
|
+
self.setLayout(self._layout)
|
|
26
|
+
self._button_group = QButtonGroup(self)
|
|
27
|
+
self._button_group.setExclusive(True)
|
|
28
|
+
self._button_group.idClicked.connect(self._emit_value_changed_signal)
|
|
29
|
+
self._values: dict[str, Any] = {}
|
|
30
|
+
self.set_values(values)
|
|
31
|
+
|
|
32
|
+
def set_values(self, values: dict[str, Any]) -> None:
|
|
33
|
+
"""Rebuild the radio buttons for a new set of options."""
|
|
34
|
+
for button in self._button_group.buttons():
|
|
35
|
+
self._button_group.removeButton(button)
|
|
36
|
+
self._layout.removeWidget(button)
|
|
37
|
+
# detached at once: `deleteLater` alone leaves it a visible child, drawn
|
|
38
|
+
# next to the new buttons until the event loop gets to deleting it
|
|
39
|
+
button.setParent(None)
|
|
40
|
+
button.deleteLater()
|
|
41
|
+
self._values = values
|
|
42
|
+
for i, value_text in enumerate(self._values):
|
|
43
|
+
button = QRadioButton(value_text)
|
|
44
|
+
self._layout.insertWidget(self._layout.count() - 1, button) # before the stretch
|
|
45
|
+
self._button_group.addButton(button, i)
|
|
46
|
+
|
|
47
|
+
def set_value(self, value: Any) -> None:
|
|
48
|
+
for button_id, value_raw in enumerate(self._values.values()):
|
|
49
|
+
if value == value_raw:
|
|
50
|
+
self._button_group.button(button_id).setChecked(True)
|
|
51
|
+
|
|
52
|
+
@Slot()
|
|
53
|
+
def _emit_value_changed_signal(self, button_id: int) -> None:
|
|
54
|
+
"""Emit the value changed signal."""
|
|
55
|
+
value_text = self._button_group.button(button_id).text()
|
|
56
|
+
value = self._values[value_text]
|
|
57
|
+
self.value_changed.emit(value)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from qtpy.QtCore import Qt
|
|
6
|
+
from qtpy.QtCore import Signal as QtSignal
|
|
7
|
+
from qtpy.QtWidgets import (
|
|
8
|
+
QDockWidget,
|
|
9
|
+
QHBoxLayout,
|
|
10
|
+
QMainWindow,
|
|
11
|
+
QScrollArea,
|
|
12
|
+
QStyle,
|
|
13
|
+
QToolButton,
|
|
14
|
+
QVBoxLayout,
|
|
15
|
+
QWidget,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from composeui.qtcore.widgets.qplaintext import plain_label
|
|
19
|
+
|
|
20
|
+
#: pixels a sidebar is wide at least while expanded, e.g. room for a form's fields
|
|
21
|
+
_MINIMUM_WIDTH = 280
|
|
22
|
+
#: Qt's largest widget size, a widget's maximum when nothing limits it
|
|
23
|
+
_QWIDGETSIZE_MAX = 16777215
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class QSidebar(QDockWidget):
|
|
27
|
+
"""A dock for the window's left: a header, its title and a button, then its content.
|
|
28
|
+
|
|
29
|
+
The dock stays where it is: it can't be closed, moved or floated, as the sidebar is
|
|
30
|
+
part of the app's layout. Its own title bar is left out for the header. The header's
|
|
31
|
+
button retracts the sidebar to a strip holding only the button, and expands it back;
|
|
32
|
+
`expanded_changed` reports those clicks, and `set_expanded` is quiet, as is any change
|
|
33
|
+
while the dock's signals are blocked (the renderers block them while refreshing).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
#: the user expanded (True) or retracted (False) the sidebar
|
|
37
|
+
expanded_changed = QtSignal(bool)
|
|
38
|
+
|
|
39
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
40
|
+
super().__init__(*args, **kwargs)
|
|
41
|
+
self.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures)
|
|
42
|
+
self.setTitleBarWidget(QWidget())
|
|
43
|
+
self._width = _MINIMUM_WIDTH
|
|
44
|
+
body = QWidget()
|
|
45
|
+
body_layout = self._body_layout = QVBoxLayout(body)
|
|
46
|
+
body_layout.setContentsMargins(0, 0, 0, 0)
|
|
47
|
+
body_layout.setSpacing(0)
|
|
48
|
+
self._header = QHBoxLayout()
|
|
49
|
+
header = self._header
|
|
50
|
+
self.title = plain_label()
|
|
51
|
+
font = self.title.font()
|
|
52
|
+
font.setBold(True)
|
|
53
|
+
self.title.setFont(font)
|
|
54
|
+
header.addWidget(self.title, 1)
|
|
55
|
+
self.toggle = QToolButton()
|
|
56
|
+
self.toggle.setAutoRaise(True)
|
|
57
|
+
self.toggle.clicked.connect(self._on_toggle_clicked)
|
|
58
|
+
header.addWidget(self.toggle, 0, Qt.AlignmentFlag.AlignTop)
|
|
59
|
+
body_layout.addLayout(header)
|
|
60
|
+
content = QWidget()
|
|
61
|
+
self.content_layout = QVBoxLayout(content)
|
|
62
|
+
self.content_scroll = QScrollArea()
|
|
63
|
+
self.content_scroll.setMinimumWidth(_MINIMUM_WIDTH)
|
|
64
|
+
self.content_scroll.setWidgetResizable(True)
|
|
65
|
+
self.content_scroll.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
66
|
+
# as wide as its content: no horizontal scroll bar
|
|
67
|
+
self.content_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
68
|
+
self.content_scroll.setWidget(content)
|
|
69
|
+
body_layout.addWidget(self.content_scroll, 1)
|
|
70
|
+
# the strip's room below the button, once retracted
|
|
71
|
+
body_layout.addStretch(0)
|
|
72
|
+
self.setWidget(body)
|
|
73
|
+
self._show_expanded(is_expanded=True)
|
|
74
|
+
|
|
75
|
+
def finish_content(self) -> None:
|
|
76
|
+
"""Leave the height to spare below the content, once it is added, not between it."""
|
|
77
|
+
self.content_layout.addStretch()
|
|
78
|
+
|
|
79
|
+
def is_expanded(self) -> bool:
|
|
80
|
+
return self.content_scroll.isVisibleTo(self)
|
|
81
|
+
|
|
82
|
+
def set_expanded(self, is_expanded: bool) -> None:
|
|
83
|
+
self._show_expanded(is_expanded=is_expanded)
|
|
84
|
+
|
|
85
|
+
def set_title(self, title: str) -> None:
|
|
86
|
+
self.title.setText(title)
|
|
87
|
+
self.title.setVisible(title != "" and self.is_expanded())
|
|
88
|
+
|
|
89
|
+
def _show_expanded(self, *, is_expanded: bool) -> None:
|
|
90
|
+
if not is_expanded and self.content_scroll.isVisibleTo(self):
|
|
91
|
+
self._width = self.width() # given back once expanded
|
|
92
|
+
self.content_scroll.setVisible(is_expanded)
|
|
93
|
+
self.title.setVisible(is_expanded and self.title.text() != "")
|
|
94
|
+
# pointing the way the sidebar goes: left to retract it, right to expand it
|
|
95
|
+
arrow = Qt.ArrowType.LeftArrow if is_expanded else Qt.ArrowType.RightArrow
|
|
96
|
+
self.toggle.setArrowType(arrow)
|
|
97
|
+
style = self.style()
|
|
98
|
+
left = style.pixelMetric(QStyle.PixelMetric.PM_LayoutLeftMargin)
|
|
99
|
+
right = style.pixelMetric(QStyle.PixelMetric.PM_LayoutRightMargin)
|
|
100
|
+
top = style.pixelMetric(QStyle.PixelMetric.PM_LayoutTopMargin)
|
|
101
|
+
# expanded, inset like the rows under it (their column's margin, then their own);
|
|
102
|
+
# retracted, the strip only as wide as the button and a margin
|
|
103
|
+
inset = 2 if is_expanded else 1
|
|
104
|
+
self._header.setContentsMargins(inset * left, top, inset * right, 0)
|
|
105
|
+
self._body_layout.activate()
|
|
106
|
+
# a dock keeps the width it has: retracted, as narrow as the strip; expanded, back
|
|
107
|
+
# to its width before
|
|
108
|
+
self.setMaximumWidth(_QWIDGETSIZE_MAX if is_expanded else self.minimumSizeHint().width())
|
|
109
|
+
window = self.parentWidget()
|
|
110
|
+
if is_expanded and isinstance(window, QMainWindow):
|
|
111
|
+
window.resizeDocks([self], [self._width], Qt.Orientation.Horizontal)
|
|
112
|
+
|
|
113
|
+
def _on_toggle_clicked(self) -> None:
|
|
114
|
+
is_expanded = not self.is_expanded()
|
|
115
|
+
self._show_expanded(is_expanded=is_expanded)
|
|
116
|
+
self.expanded_changed.emit(is_expanded)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""A large table's widget: a `QTableView` that asks its view for the rows it draws."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Optional, Protocol, Union
|
|
6
|
+
|
|
7
|
+
from qtpy.QtCore import (
|
|
8
|
+
QAbstractTableModel,
|
|
9
|
+
QModelIndex,
|
|
10
|
+
QObject,
|
|
11
|
+
QPersistentModelIndex,
|
|
12
|
+
Qt,
|
|
13
|
+
Signal,
|
|
14
|
+
)
|
|
15
|
+
from qtpy.QtGui import QAction, QKeySequence, QPalette
|
|
16
|
+
from qtpy.QtWidgets import (
|
|
17
|
+
QAbstractItemView,
|
|
18
|
+
QApplication,
|
|
19
|
+
QHeaderView,
|
|
20
|
+
QSizePolicy,
|
|
21
|
+
QTableView,
|
|
22
|
+
QToolButton,
|
|
23
|
+
QVBoxLayout,
|
|
24
|
+
QWidget,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from composeui.qtcore.widgets.qfiltertable import QTableFilterBar
|
|
28
|
+
from composeui.qtcore.widgets.qplaintext import plain_label
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from qtpy.QtGui import QKeyEvent
|
|
32
|
+
|
|
33
|
+
_Index = Union[QModelIndex, QPersistentModelIndex]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SourceTableLike(Protocol):
|
|
37
|
+
"""What the model reads from a `SourceTableView`."""
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def headers(self) -> list[str]: ...
|
|
41
|
+
@property
|
|
42
|
+
def row_count(self) -> int: ...
|
|
43
|
+
def rows(self, start: int, stop: int) -> list[tuple[Any, list[str]]]: ...
|
|
44
|
+
def is_selected(self, row_id: Any) -> bool: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class QSourceTableModel(QAbstractTableModel):
|
|
48
|
+
"""The rows of a large table, read from its view as Qt draws them, never all at once.
|
|
49
|
+
|
|
50
|
+
Qt asks for the cells it draws, and the view reads them from its source a window at
|
|
51
|
+
a time. `reset` shows a new order of the rows; `repaint` the same rows, formatted or
|
|
52
|
+
selected again. The rows' headers are their positions, from 1. A selected row has the
|
|
53
|
+
palette's highlight colors.
|
|
54
|
+
|
|
55
|
+
Qt asks for each cell's text and colors apart: the model keeps the row it read last,
|
|
56
|
+
so the view formats each row drawn once, not once a cell and a role.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, table: SourceTableLike, parent: Optional[QObject] = None) -> None:
|
|
60
|
+
super().__init__(parent)
|
|
61
|
+
self._table = table
|
|
62
|
+
#: the row read last: its position, its id and its cells
|
|
63
|
+
self._row: Optional[tuple[int, Any, list[str]]] = None
|
|
64
|
+
|
|
65
|
+
def reset(self) -> None:
|
|
66
|
+
self.beginResetModel()
|
|
67
|
+
self._row = None
|
|
68
|
+
self.endResetModel()
|
|
69
|
+
|
|
70
|
+
def repaint(self) -> None:
|
|
71
|
+
self._row = None
|
|
72
|
+
if self.rowCount() > 0 and self.columnCount() > 0:
|
|
73
|
+
last = self.index(self.rowCount() - 1, self.columnCount() - 1)
|
|
74
|
+
self.dataChanged.emit(self.index(0, 0), last)
|
|
75
|
+
|
|
76
|
+
def rowCount(self, parent: _Index = QModelIndex()) -> int: # noqa: B008, N802
|
|
77
|
+
return 0 if parent.isValid() else self._table.row_count
|
|
78
|
+
|
|
79
|
+
def columnCount(self, parent: _Index = QModelIndex()) -> int: # noqa: B008, N802
|
|
80
|
+
return 0 if parent.isValid() else len(self._table.headers)
|
|
81
|
+
|
|
82
|
+
def data(self, index: _Index, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
|
|
83
|
+
looks: dict[int, QPalette.ColorRole] = {
|
|
84
|
+
Qt.ItemDataRole.BackgroundRole: QPalette.ColorRole.Highlight,
|
|
85
|
+
Qt.ItemDataRole.ForegroundRole: QPalette.ColorRole.HighlightedText,
|
|
86
|
+
}
|
|
87
|
+
if not index.isValid() or (role != Qt.ItemDataRole.DisplayRole and role not in looks):
|
|
88
|
+
return None
|
|
89
|
+
if self._row is None or self._row[0] != index.row():
|
|
90
|
+
rows = self._table.rows(index.row(), index.row() + 1)
|
|
91
|
+
if len(rows) == 0: # rows not shown any longer, the model not reset yet
|
|
92
|
+
# a paint between the source's change and the model's reset: no test
|
|
93
|
+
# reaches that moment
|
|
94
|
+
return None # pragma: no cover
|
|
95
|
+
self._row = (index.row(), *rows[0])
|
|
96
|
+
_position, row_id, cells = self._row
|
|
97
|
+
if role in looks:
|
|
98
|
+
is_selected = self._table.is_selected(row_id)
|
|
99
|
+
return QApplication.palette().brush(looks[role]) if is_selected else None
|
|
100
|
+
return cells[index.column()] if index.column() < len(cells) else None
|
|
101
|
+
|
|
102
|
+
def headerData( # noqa: N802
|
|
103
|
+
self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole
|
|
104
|
+
) -> Any:
|
|
105
|
+
if role != Qt.ItemDataRole.DisplayRole:
|
|
106
|
+
return None
|
|
107
|
+
if orientation == Qt.Orientation.Horizontal:
|
|
108
|
+
headers = self._table.headers
|
|
109
|
+
return headers[section] if section < len(headers) else None
|
|
110
|
+
return f"{section + 1:,}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class QSourceTableView(QTableView):
|
|
114
|
+
"""A large table: as tall as its place, its rows scrolled, each row of the same height.
|
|
115
|
+
|
|
116
|
+
The rows all have the height of a line of text, so the scroll bar's place gives the
|
|
117
|
+
rows shown with no row measured. The columns share the width. `header_clicked` emits
|
|
118
|
+
the column whose header the user clicked; the header shows the order
|
|
119
|
+
`set_sort_indicator` gives, the table never sorting its rows itself.
|
|
120
|
+
|
|
121
|
+
The table selects no row itself: `row_clicked` emits the position the user clicked,
|
|
122
|
+
or the current one on Space, and whether Shift extends the selection to it;
|
|
123
|
+
`row_activated` emits it on a double click, Enter or Return. `copy_requested` emits
|
|
124
|
+
on the platform's Copy shortcut (Ctrl+C), `export_requested` on `export_action`, in
|
|
125
|
+
the table's context menu.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
header_clicked = Signal(int)
|
|
129
|
+
row_clicked = Signal(int, bool)
|
|
130
|
+
row_activated = Signal(int)
|
|
131
|
+
copy_requested = Signal()
|
|
132
|
+
export_requested = Signal()
|
|
133
|
+
|
|
134
|
+
def __init__(self, table: SourceTableLike) -> None:
|
|
135
|
+
super().__init__()
|
|
136
|
+
self._sort: Optional[tuple[int, bool]] = None
|
|
137
|
+
self.data_model = QSourceTableModel(table, self)
|
|
138
|
+
self.setModel(self.data_model)
|
|
139
|
+
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
140
|
+
rows = self.verticalHeader()
|
|
141
|
+
rows.setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
|
|
142
|
+
rows.setDefaultSectionSize(self.fontMetrics().height() + 10)
|
|
143
|
+
rows.setDefaultAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
144
|
+
columns = self.horizontalHeader()
|
|
145
|
+
columns.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
|
146
|
+
columns.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
|
147
|
+
self.setShowGrid(False)
|
|
148
|
+
self.setAlternatingRowColors(True)
|
|
149
|
+
self.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
|
150
|
+
columns.setSectionsClickable(True)
|
|
151
|
+
columns.sectionClicked.connect(self._header_clicked)
|
|
152
|
+
self.clicked.connect(self._row_clicked)
|
|
153
|
+
self.doubleClicked.connect(lambda index: self.row_activated.emit(index.row()))
|
|
154
|
+
self.export_action = QAction(self)
|
|
155
|
+
self.export_action.triggered.connect(self.export_requested.emit)
|
|
156
|
+
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
|
157
|
+
self.addAction(self.export_action)
|
|
158
|
+
|
|
159
|
+
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
|
|
160
|
+
current = self.currentIndex()
|
|
161
|
+
if event.matches(QKeySequence.StandardKey.Copy):
|
|
162
|
+
self.copy_requested.emit()
|
|
163
|
+
elif current.isValid() and event.key() == Qt.Key.Key_Space:
|
|
164
|
+
is_extended = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier)
|
|
165
|
+
self.row_clicked.emit(current.row(), is_extended)
|
|
166
|
+
elif current.isValid() and event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
|
167
|
+
self.row_activated.emit(current.row())
|
|
168
|
+
else:
|
|
169
|
+
super().keyPressEvent(event)
|
|
170
|
+
|
|
171
|
+
def _row_clicked(self, index: QModelIndex) -> None:
|
|
172
|
+
modifiers = QApplication.keyboardModifiers()
|
|
173
|
+
self.row_clicked.emit(index.row(), bool(modifiers & Qt.KeyboardModifier.ShiftModifier))
|
|
174
|
+
|
|
175
|
+
def set_sort_indicator(self, sort: Optional[tuple[int, bool]]) -> None:
|
|
176
|
+
"""Show the order: by a column, descending or not; None for the source's own."""
|
|
177
|
+
self._sort = sort
|
|
178
|
+
header = self.horizontalHeader()
|
|
179
|
+
header.setSortIndicatorShown(sort is not None)
|
|
180
|
+
if sort is not None:
|
|
181
|
+
column, is_descending = sort
|
|
182
|
+
order = Qt.SortOrder.DescendingOrder if is_descending else Qt.SortOrder.AscendingOrder
|
|
183
|
+
header.setSortIndicator(column, order)
|
|
184
|
+
|
|
185
|
+
def _header_clicked(self, column: int) -> None:
|
|
186
|
+
# the header flips its indicator on a click: the order is the view's to say
|
|
187
|
+
self.set_sort_indicator(self._sort)
|
|
188
|
+
self.header_clicked.emit(column)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class QDataFilterTable(QWidget):
|
|
192
|
+
"""A large table under its filter bar, which shows only while a column filters.
|
|
193
|
+
|
|
194
|
+
`export` shows the table's `export_action` above the rows, at their right, as the
|
|
195
|
+
web's link: the context menu alone hides it. When the search or the filters hide
|
|
196
|
+
every row, `empty` says so under the headers.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(self, table: SourceTableLike) -> None:
|
|
200
|
+
super().__init__()
|
|
201
|
+
self.bar = QTableFilterBar()
|
|
202
|
+
self.table = QSourceTableView(table)
|
|
203
|
+
self.export = QToolButton()
|
|
204
|
+
self.export.setDefaultAction(self.table.export_action)
|
|
205
|
+
self.export.setAutoRaise(True)
|
|
206
|
+
self.empty = plain_label()
|
|
207
|
+
self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
208
|
+
self.empty.setVisible(False)
|
|
209
|
+
layout = QVBoxLayout(self)
|
|
210
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
211
|
+
layout.addWidget(self.bar)
|
|
212
|
+
layout.addWidget(self.export, 0, Qt.AlignmentFlag.AlignRight)
|
|
213
|
+
layout.addWidget(self.table, 1)
|
|
214
|
+
layout.addWidget(self.empty)
|
|
215
|
+
self.bar.setVisible(False)
|
|
216
|
+
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from qtpy.QtCore import Qt
|
|
6
|
+
from qtpy.QtCore import Signal as QtSignal
|
|
7
|
+
from qtpy.QtGui import QPainter, QPalette
|
|
8
|
+
from qtpy.QtWidgets import QSplitter, QSplitterHandle
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from qtpy.QtGui import QKeyEvent, QMouseEvent, QPaintEvent
|
|
12
|
+
|
|
13
|
+
#: pixels an arrow key moves a focused handle by, as on the web
|
|
14
|
+
KEY_STEP = 16
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class QSplitterBorder(QSplitterHandle):
|
|
18
|
+
"""A splitter's handle the keyboard reaches too: the arrow keys move it once focused.
|
|
19
|
+
|
|
20
|
+
Qt's own handles take no focus, so only the mouse moves them.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, orientation: Qt.Orientation, parent: QComposeSplitter) -> None:
|
|
24
|
+
super().__init__(orientation, parent)
|
|
25
|
+
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
26
|
+
|
|
27
|
+
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
|
|
28
|
+
if self.orientation() == Qt.Orientation.Vertical:
|
|
29
|
+
steps = {Qt.Key.Key_Up: -KEY_STEP, Qt.Key.Key_Down: KEY_STEP}
|
|
30
|
+
position = self.y()
|
|
31
|
+
else:
|
|
32
|
+
steps = {Qt.Key.Key_Left: -KEY_STEP, Qt.Key.Key_Right: KEY_STEP}
|
|
33
|
+
position = self.x()
|
|
34
|
+
step = steps.get(Qt.Key(event.key()))
|
|
35
|
+
if step is None:
|
|
36
|
+
super().keyPressEvent(event)
|
|
37
|
+
return
|
|
38
|
+
self.moveSplitter(position + step)
|
|
39
|
+
self._splitter().moved_by_user.emit()
|
|
40
|
+
|
|
41
|
+
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802
|
|
42
|
+
super().mouseReleaseEvent(event)
|
|
43
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
44
|
+
self._splitter().moved_by_user.emit()
|
|
45
|
+
|
|
46
|
+
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802
|
|
47
|
+
super().paintEvent(event)
|
|
48
|
+
if self.hasFocus(): # Qt's style draws no focus on a handle
|
|
49
|
+
painter = QPainter(self)
|
|
50
|
+
painter.fillRect(self.rect(), self.palette().color(QPalette.ColorRole.Highlight))
|
|
51
|
+
|
|
52
|
+
def _splitter(self) -> QComposeSplitter:
|
|
53
|
+
splitter = self.splitter()
|
|
54
|
+
assert isinstance(splitter, QComposeSplitter)
|
|
55
|
+
return splitter
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class QComposeSplitter(QSplitter):
|
|
59
|
+
"""A splitter whose handles the keyboard moves too, telling once the user moved one.
|
|
60
|
+
|
|
61
|
+
`moved_by_user` reports the end of a drag, or a key's move, rather than each step
|
|
62
|
+
of a drag as `splitterMoved` does; never a change of `setSizes`.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
#: the user dropped a handle they dragged, or moved one with a key
|
|
66
|
+
moved_by_user = QtSignal()
|
|
67
|
+
|
|
68
|
+
def createHandle(self) -> QSplitterHandle: # noqa: N802
|
|
69
|
+
return QSplitterBorder(self.orientation(), self)
|