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