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
File without changes
@@ -0,0 +1,123 @@
1
+ import QtQuick
2
+ import QtLocation
3
+ import QtPositioning
4
+
5
+ Item {
6
+ id: root
7
+ property var points: []
8
+ property bool tiles: true
9
+ property real latitude: 0
10
+ property real longitude: 0
11
+ property alias zoom: map.zoomLevel
12
+ focus: true
13
+ signal viewportMoved(real latitude, real longitude, real zoom)
14
+ signal pointClicked(real latitude, real longitude)
15
+
16
+ function moved() {
17
+ // Copy both numbers before changing the properties that bind map.center.
18
+ const movedLatitude = map.center.latitude
19
+ const movedLongitude = map.center.longitude
20
+ latitude = Math.max(-85.05112878, Math.min(85.05112878, movedLatitude))
21
+ longitude = movedLongitude
22
+ viewportMoved(latitude, longitude, map.zoomLevel)
23
+ }
24
+
25
+ Keys.onPressed: (event) => {
26
+ if (event.key === Qt.Key_Plus || event.key === Qt.Key_Equal) zoom = Math.min(19, zoom + 1)
27
+ else if (event.key === Qt.Key_Minus) zoom = Math.max(0, zoom - 1)
28
+ else if (event.key === Qt.Key_Left) map.pan(-40, 0)
29
+ else if (event.key === Qt.Key_Right) map.pan(40, 0)
30
+ else if (event.key === Qt.Key_Up) map.pan(0, -40)
31
+ else if (event.key === Qt.Key_Down) map.pan(0, 40)
32
+ else return
33
+ event.accepted = true
34
+ moved()
35
+ }
36
+
37
+ Map {
38
+ id: map
39
+ center: QtPositioning.coordinate(root.latitude, root.longitude)
40
+ anchors.fill: parent
41
+ visible: root.tiles
42
+ copyrightsVisible: root.tiles
43
+ color: "#e5e7eb"
44
+ plugin: tilePlugin
45
+ onSupportedMapTypesChanged: {
46
+ // The custom host is the last map type; never select a provider repository URL.
47
+ if (root.tiles && supportedMapTypes.length)
48
+ activeMapType = supportedMapTypes[supportedMapTypes.length - 1]
49
+ }
50
+ onCenterChanged: overlay.requestPaint()
51
+ onZoomLevelChanged: overlay.requestPaint()
52
+ onMapReadyChanged: overlay.requestPaint()
53
+ }
54
+ Plugin {
55
+ id: tilePlugin
56
+ name: root.tiles ? "osm" : "itemsoverlay"
57
+ PluginParameter { name: "osm.useragent"; value: mapUserAgent }
58
+ PluginParameter { name: "osm.mapping.providersrepository.disabled"; value: true }
59
+ PluginParameter {
60
+ name: "osm.mapping.custom.host"
61
+ value: "https://tile.openstreetmap.org/"
62
+ }
63
+ PluginParameter {
64
+ name: "osm.mapping.custom.mapcopyright"
65
+ value: "© OpenStreetMap contributors"
66
+ }
67
+ PluginParameter { name: "osm.mapping.prefetching_style"; value: "NoPrefetching" }
68
+ }
69
+ Item {
70
+ anchors.fill: parent
71
+ DragHandler {
72
+ target: null
73
+ onTranslationChanged: (delta) => {
74
+ map.pan(-delta.x, -delta.y)
75
+ root.moved()
76
+ }
77
+ }
78
+ WheelHandler {
79
+ target: null
80
+ onWheel: (event) => {
81
+ map.zoomLevel = Math.max(0, Math.min(19, map.zoomLevel + event.angleDelta.y / 120))
82
+ root.moved()
83
+ }
84
+ }
85
+ TapHandler {
86
+ onTapped: (eventPoint) => {
87
+ const position = map.toCoordinate(eventPoint.position, false)
88
+ root.pointClicked(position.latitude, position.longitude)
89
+ }
90
+ }
91
+ }
92
+ Canvas {
93
+ id: overlay
94
+ anchors.fill: parent
95
+ onPaint: {
96
+ const ctx = getContext("2d")
97
+ ctx.clearRect(0, 0, width, height)
98
+ ctx.fillStyle = "#2563eb"
99
+ ctx.globalAlpha = 0.55
100
+ ctx.beginPath()
101
+ const world = 256 * Math.pow(2, root.zoom)
102
+ function mercator(latitude) {
103
+ const sine = Math.sin(latitude * Math.PI / 180)
104
+ return 0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)
105
+ }
106
+ const centerY = mercator(root.latitude)
107
+ for (let i = 0; i < root.points.length; ++i) {
108
+ const p = root.points[i]
109
+ let dx = (p[1] - root.longitude) / 360
110
+ dx -= Math.round(dx)
111
+ const pixel = { x: width / 2 + dx * world,
112
+ y: height / 2 + (mercator(p[0]) - centerY) * world }
113
+ if (pixel.x < -3 || pixel.x > width + 3 || pixel.y < -3 || pixel.y > height + 3) continue
114
+ ctx.moveTo(pixel.x + 3, pixel.y)
115
+ ctx.arc(pixel.x, pixel.y, 3, 0, 2 * Math.PI)
116
+ }
117
+ ctx.fill()
118
+ }
119
+ onWidthChanged: requestPaint()
120
+ onHeightChanged: requestPaint()
121
+ }
122
+ onPointsChanged: overlay.requestPaint()
123
+ }
@@ -0,0 +1,393 @@
1
+ """A chart widget over pyqtgraph: fast with long series, zoomed and panned with the mouse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import numpy as np
9
+ import pyqtgraph as pg
10
+ from qtpy.QtCore import QEvent, QPointF, QSize, Qt
11
+ from qtpy.QtCore import Signal as QtSignal
12
+ from qtpy.QtGui import QAction, QColor, QFont, QPalette
13
+
14
+ from composeui.core import chartimage
15
+ from composeui.core.chartcolors import is_dashed, series_color
16
+ from composeui.core.view import ChartKind, ChartView, LineStyle
17
+
18
+ if TYPE_CHECKING:
19
+ from qtpy.QtWidgets import QWidget
20
+
21
+ from composeui.core.view import ReferenceLine, Series
22
+
23
+ #: the hover values' rate, per second: a mouse sends far more moves than that
24
+ _HOVER_RATE = 30
25
+ #: the lightness (of 255) under which a background is dark
26
+ _DARK_LIGHTNESS = 128
27
+ #: the size a chart asks of its layout, in pixels: pyqtgraph's own, before it is shown
28
+ _SIZE_HINT = (600, 480)
29
+ #: a scatter's dots, in pixels
30
+ _DOT_SIZE = 8
31
+
32
+
33
+ #: a reference line's pen style, by its style
34
+ _LINE_STYLES = {
35
+ LineStyle.SOLID: Qt.PenStyle.SolidLine,
36
+ LineStyle.DASHED: Qt.PenStyle.DashLine,
37
+ LineStyle.DOTTED: Qt.PenStyle.DotLine,
38
+ LineStyle.DASH_DOT: Qt.PenStyle.DashDotLine,
39
+ }
40
+
41
+
42
+ def _format(value: float, unit: str) -> str:
43
+ # its trailing zeros kept: as many digits wherever the cursor is, as on the web
44
+ return f"{value:#.4g} {unit}".rstrip()
45
+
46
+
47
+ def _tabular_font() -> QFont:
48
+ """Get the application's font with digits of one width, where Qt has them (6.7)."""
49
+ font = QFont()
50
+ if hasattr(font, "setFeature") and hasattr(QFont, "Tag"): # pragma: no branch
51
+ font.setFeature(QFont.Tag("tnum"), 1)
52
+ return font
53
+
54
+
55
+ class QChart(pg.PlotWidget): # type: ignore[misc]
56
+ """A chart of series; hovering it shows each series' point nearest the cursor's x.
57
+
58
+ Its colors follow the application's palette, changing with it (a dark scheme).
59
+ """
60
+
61
+ #: the user asked for the chart's image, from its context menu's Save Image
62
+ save_image_requested = QtSignal()
63
+ #: the user clicked the plot at a point, as the axes' values (not their logs)
64
+ point_clicked = QtSignal(float, float)
65
+ #: whether `__init__` is done: pyqtgraph's own changes the palette before it is
66
+ _is_built = False
67
+
68
+ def __init__(self, parent: QWidget | None = None) -> None:
69
+ super().__init__(parent)
70
+ self._series: list[Series] = []
71
+ self._kind = ChartKind.LINE
72
+ self._categories: list[str] = []
73
+ self._x_log = False
74
+ self._y_log = False
75
+ self._bars: list[pg.BarGraphItem] = []
76
+ self._units = ("", "")
77
+ self._items: list[pg.PlotDataItem] = []
78
+ self._reference_lines: list[ReferenceLine] = []
79
+ self._line_items: list[pg.InfiniteLine] = []
80
+ plot = self.getPlotItem()
81
+ self._legend = plot.addLegend()
82
+ # pyqtgraph thins and clips what it draws itself: a million points stay fluid
83
+ plot.setDownsampling(auto=True, mode="peak")
84
+ plot.setClipToView(True)
85
+ plot.showGrid(x=True, y=True, alpha=0.15)
86
+ self._crosshair = pg.InfiniteLine(angle=90, movable=False)
87
+ self._crosshair.setVisible(False)
88
+ self._hover = pg.TextItem(anchor=(0, 1))
89
+ # its values change as the cursor moves, without changing their width
90
+ self._hover.setFont(_tabular_font())
91
+ self._hover.setVisible(False)
92
+ for item in (self._crosshair, self._hover):
93
+ plot.addItem(item, ignoreBounds=True)
94
+ self.scene().sigMouseClicked.connect(self._on_mouse_clicked)
95
+ self._mouse_proxy = pg.SignalProxy(
96
+ self.scene().sigMouseMoved, rateLimit=_HOVER_RATE, slot=self._on_mouse_moved
97
+ )
98
+ #: beside pyqtgraph's own Export, which stays for CSV; only with matplotlib, which
99
+ #: draws the image
100
+ self.save_image_action = QAction(self)
101
+ self.save_image_action.triggered.connect(self.save_image_requested.emit)
102
+ if chartimage.is_available():
103
+ plot.getViewBox().menu.addAction(self.save_image_action)
104
+ self._is_built = True
105
+ self._apply_palette()
106
+
107
+ def set_title(self, title: str) -> None:
108
+ self.getPlotItem().setTitle(title or None)
109
+
110
+ def set_axis(self, axis: str, title: str, unit: str) -> None:
111
+ """Label the `axis` ("bottom" or "left"); pyqtgraph scales the unit, e.g. into ms."""
112
+ self.getPlotItem().setLabel(axis, title or None, units=unit or None)
113
+ x_unit, y_unit = self._units
114
+ self._units = (unit, y_unit) if axis == "bottom" else (x_unit, unit)
115
+
116
+ def set_kind(self, kind: ChartKind) -> None:
117
+ """Draw the series as `kind`, unless they say otherwise (`Series.kind`)."""
118
+ self._kind = kind
119
+ self._draw()
120
+
121
+ def set_categories(self, categories: list[str]) -> None:
122
+ """Name a bar chart's categories, under their groups of bars."""
123
+ self._categories = categories
124
+ self._draw()
125
+
126
+ def set_log(self, *, x_log: bool, y_log: bool) -> None:
127
+ """Make the axes log axes or linear ones; a bar chart's x stays its categories."""
128
+ self._x_log, self._y_log = x_log, y_log
129
+ self._draw()
130
+
131
+ def set_series(self, series: list[Series]) -> None:
132
+ """Show `series`, reusing the items drawn: the zoom stays where the user put it."""
133
+ self._series = series
134
+ self._draw()
135
+ self._hover.setVisible(False)
136
+ self._crosshair.setVisible(False)
137
+
138
+ def set_reference_lines(self, lines: list[ReferenceLine]) -> None:
139
+ """Draw lines at values, each in its style, labeled beside itself."""
140
+ self._reference_lines = lines
141
+ self._draw()
142
+
143
+ def _draw(self) -> None:
144
+ """Draw the series as curves, or as a bar chart's bars, and their legend."""
145
+ plot = self.getPlotItem()
146
+ is_bar = self._kind is ChartKind.BAR
147
+ curve_count, bar_count = (0, len(self._series)) if is_bar else (len(self._series), 0)
148
+ for items, count, make in (
149
+ (self._items, curve_count, lambda: plot.plot(connect="finite")),
150
+ (self._bars, bar_count, self._make_bars),
151
+ ):
152
+ while len(items) > count:
153
+ plot.removeItem(items.pop())
154
+ while len(items) < count:
155
+ items.append(make())
156
+ x_log = self._x_log and not is_bar
157
+ for item, curve in zip(self._items, self._series):
158
+ x, y = ChartView.drawn_points(curve, x_log=x_log, y_log=self._y_log)
159
+ if self._is_step(curve):
160
+ x, y = ChartView.step_points(x, y)
161
+ item.setData(np.asarray(x, dtype=float), np.asarray(y, dtype=float))
162
+ item.opts["name"] = curve.name
163
+ # the curves draw their log themselves; the bars are given theirs (see `_bar_opts`)
164
+ plot.setLogMode(x=x_log, y=self._y_log)
165
+ ticks = [list(enumerate(self._categories))] if is_bar else None
166
+ plot.getAxis("bottom").setTicks(ticks)
167
+ self._draw_reference_lines(x_log=x_log)
168
+ self._apply_styles()
169
+
170
+ def _draw_reference_lines(self, *, x_log: bool) -> None:
171
+ """Draw the reference lines afresh: a log axis takes their logs, as pyqtgraph doesn't."""
172
+ plot = self.getPlotItem()
173
+ for item in self._line_items:
174
+ plot.removeItem(item)
175
+ self._line_items = []
176
+ lines = ChartView.drawn_lines(self._reference_lines, x_log=x_log, y_log=self._y_log)
177
+ for line in lines:
178
+ is_log = x_log if line.vertical else self._y_log
179
+ item = pg.InfiniteLine(
180
+ pos=math.log10(line.value) if is_log else line.value,
181
+ angle=90 if line.vertical else 0,
182
+ movable=False,
183
+ # the label is a format of the line's value: braces of its own are text
184
+ label=line.label.replace("{", "{{").replace("}", "}}"),
185
+ # a horizontal one's at its right end, over it; a vertical one's at its
186
+ # top, left of it (now, after the data)
187
+ labelOpts={
188
+ "position": 0.98,
189
+ "anchors": [(1, 0), (1, 0)] if line.vertical else [(1, 1), (1, 1)],
190
+ },
191
+ )
192
+ item.setZValue(1) # over the series
193
+ plot.addItem(item)
194
+ self._line_items.append(item)
195
+
196
+ def _make_bars(self) -> pg.BarGraphItem:
197
+ bars = pg.BarGraphItem(x0=[], height=[], width=1)
198
+ self.getPlotItem().addItem(bars)
199
+ return bars
200
+
201
+ def shown_range(self) -> tuple[tuple[float, float], tuple[float, float]]:
202
+ """Get the x and y ranges the chart shows, e.g. zoomed into; not their logs."""
203
+ (x_min, x_max), (y_min, y_max) = self.getPlotItem().getViewBox().viewRange()
204
+ if self._x_log and self._kind is not ChartKind.BAR:
205
+ x_min, x_max = 10**x_min, 10**x_max
206
+ if self._y_log:
207
+ y_min, y_max = 10**y_min, 10**y_max
208
+ return (float(x_min), float(x_max)), (float(y_min), float(y_max))
209
+
210
+ def sizeHint(self) -> QSize: # noqa: N802
211
+ # not its scene's, which follows its own size: a height it was given once would stay
212
+ return QSize(*_SIZE_HINT)
213
+
214
+ def legend_names(self) -> list[str]:
215
+ return [label.text for _sample, label in self._legend.items]
216
+
217
+ def hover_text(self) -> str:
218
+ """Get the values the hover shows, empty while the cursor is off the chart."""
219
+ return str(self._hover.toPlainText()) if self._hover.isVisible() else ""
220
+
221
+ def changeEvent(self, event: QEvent) -> None: # noqa: N802
222
+ super().changeEvent(event)
223
+ if event.type() == QEvent.Type.PaletteChange and self._is_built:
224
+ self._apply_palette()
225
+
226
+ def _is_dark(self) -> bool:
227
+ return bool(self.palette().color(QPalette.ColorRole.Window).lightness() < _DARK_LIGHTNESS)
228
+
229
+ def _apply_palette(self) -> None:
230
+ palette = self.palette()
231
+ background = palette.color(QPalette.ColorRole.Window)
232
+ text = palette.color(QPalette.ColorRole.WindowText)
233
+ self.setBackground(background)
234
+ plot = self.getPlotItem()
235
+ for name in ("bottom", "left"):
236
+ axis = plot.getAxis(name)
237
+ axis.setPen(text)
238
+ axis.setTextPen(text)
239
+ plot.titleLabel.setText(plot.titleLabel.text, color=text)
240
+ self._legend.setLabelTextColor(text)
241
+ # over the data, so its names stay readable over a curve or a bar
242
+ legend_background = QColor(background)
243
+ legend_background.setAlpha(200)
244
+ self._legend.setBrush(legend_background)
245
+ self._hover.setColor(text)
246
+ crosshair = QColor(text)
247
+ crosshair.setAlpha(120)
248
+ self._crosshair.setPen(pg.mkPen(crosshair))
249
+ self._apply_styles()
250
+
251
+ def _apply_styles(self) -> None:
252
+ is_dark = self._is_dark()
253
+ background = self.palette().color(QPalette.ColorRole.Window)
254
+ for index, (item, curve) in enumerate(zip(self._items, self._series)):
255
+ color = curve.color or series_color(index, is_dark=is_dark)
256
+ # filled down to 0, e.g. a shape
257
+ item.setFillLevel(0 if curve.fill else None)
258
+ item.setBrush(color if curve.fill else None)
259
+ if ChartView.kind_of(curve, self._kind) is ChartKind.SCATTER:
260
+ item.setPen(None)
261
+ # past the palette, around again as triangles
262
+ item.setSymbol("t" if is_dashed(index) else "o")
263
+ item.setSymbolSize(_DOT_SIZE)
264
+ item.setSymbolBrush(color)
265
+ # a ring of the background: overlapping dots stay apart
266
+ item.setSymbolPen(pg.mkPen(background, width=1))
267
+ else:
268
+ item.setSymbol(None)
269
+ style = {"dash": [6, 4]} if is_dashed(index) else {}
270
+ item.setPen(pg.mkPen(color, width=2, **style))
271
+ all_heights = [
272
+ np.asarray(ChartView.bar_heights(curve, len(self._categories), y_log=self._y_log))
273
+ for curve in self._series
274
+ ]
275
+ drawn = np.concatenate([[], *all_heights])
276
+ drawn = drawn[np.isfinite(drawn)]
277
+ # on a log axis, the bars rise from the power of 10 under the lowest of them
278
+ base = math.floor(np.log10(drawn.min())) if self._y_log and len(drawn) > 0 else 0
279
+ for index, (bars, curve, heights) in enumerate(zip(self._bars, self._series, all_heights)):
280
+ categories = np.flatnonzero(np.isfinite(heights)) # a NaN: no bar
281
+ tops = np.log10(heights[categories]) if self._y_log else heights[categories]
282
+ offset, width = ChartView.bar_slot(index, len(self._series))
283
+ bars.setOpts(
284
+ x0=categories + offset - width / 2,
285
+ y0=base,
286
+ height=tops - base,
287
+ width=width,
288
+ brush=curve.color or series_color(index, is_dark=is_dark),
289
+ # a line of the background between neighbouring bars
290
+ pen=pg.mkPen(background, width=1),
291
+ name=curve.name,
292
+ )
293
+ text = self.palette().color(QPalette.ColorRole.WindowText)
294
+ lines = ChartView.drawn_lines(
295
+ self._reference_lines,
296
+ x_log=self._x_log and self._kind is not ChartKind.BAR,
297
+ y_log=self._y_log,
298
+ )
299
+ for item, line in zip(self._line_items, lines):
300
+ ink = QColor(line.color) if line.color is not None else text
301
+ item.setPen(pg.mkPen(ink, width=1.5, style=_LINE_STYLES[line.style]))
302
+ item.label.setColor(ink)
303
+ # the legend's samples draw the styles their items had when added
304
+ self._legend.clear()
305
+ for item, curve in zip(self._items or self._bars, self._series):
306
+ self._legend.addItem(item, curve.name)
307
+
308
+ def _on_mouse_clicked(self, event: Any) -> None:
309
+ if event.button() != Qt.MouseButton.LeftButton or event.double():
310
+ return # the right button opens the context menu
311
+ self.click(event.scenePos())
312
+
313
+ def click(self, scene_position: QPointF) -> None:
314
+ """Emit `point_clicked` for a click at `scene_position`, if it is on the plot."""
315
+ view_box = self.getPlotItem().getViewBox()
316
+ if not view_box.sceneBoundingRect().contains(scene_position):
317
+ return
318
+ position = view_box.mapSceneToView(scene_position)
319
+ x, y = position.x(), position.y()
320
+ # the view's values are their logs on a log axis
321
+ if self._x_log and self._kind is not ChartKind.BAR:
322
+ x = 10**x
323
+ if self._y_log:
324
+ y = 10**y
325
+ self.point_clicked.emit(float(x), float(y))
326
+
327
+ def _on_mouse_moved(self, event: tuple[Any, ...]) -> None:
328
+ self.show_hover(event[0])
329
+
330
+ def show_hover(self, scene_position: QPointF) -> None:
331
+ """Show the values nearest `scene_position`'s x, or hide them off the plot."""
332
+ view_box = self.getPlotItem().vb
333
+ if not view_box.sceneBoundingRect().contains(scene_position) or len(self._series) == 0:
334
+ self._hover.setVisible(False)
335
+ self._crosshair.setVisible(False)
336
+ return
337
+ position = view_box.mapSceneToView(scene_position)
338
+ is_log = self._x_log and self._kind is not ChartKind.BAR
339
+ # the view's x is its log on a log axis
340
+ lines = self._hover_lines(10 ** position.x() if is_log else position.x())
341
+ if len(lines) == 0: # between a bar chart's categories, or past them
342
+ self._hover.setVisible(False)
343
+ self._crosshair.setVisible(False)
344
+ return
345
+ self._crosshair.setPos(position.x())
346
+ self._crosshair.setVisible(True)
347
+ self._hover.setText("\n".join(lines))
348
+ self._hover.setPos(position)
349
+ self._hover.setVisible(True)
350
+
351
+ def _hover_lines(self, x: float) -> list[str]:
352
+ """Get what the hover shows at `x`: where it is, then each series' value there."""
353
+ x_unit, y_unit = self._units
354
+ if self._kind is ChartKind.BAR:
355
+ category = round(x)
356
+ if not 0 <= category < len(self._categories):
357
+ return []
358
+ lines = [self._categories[category]]
359
+ for curve in self._series:
360
+ heights = ChartView.bar_heights(curve, len(self._categories), y_log=self._y_log)
361
+ height = heights[category]
362
+ if math.isfinite(height):
363
+ lines.append(f"{curve.name}: {_format(height, y_unit)}")
364
+ return lines
365
+ lines = [_format(x, x_unit)]
366
+ for curve in self._series:
367
+ points = ChartView.drawn_points(curve, x_log=self._x_log, y_log=self._y_log)
368
+ if self._is_step(curve):
369
+ # the value held there, rather than the next point's
370
+ held = ChartView.held_value(*points, x)
371
+ y = None if held is None or math.isnan(held) else held
372
+ else:
373
+ point = _nearest(*points, x)
374
+ y = None if point is None else point[1]
375
+ if y is not None:
376
+ lines.append(f"{curve.name}: {_format(y, y_unit)}")
377
+ return lines
378
+
379
+ def _is_step(self, curve: Series) -> bool:
380
+ """Tell whether `curve` is drawn in steps: a line's points only, not dots."""
381
+ return curve.step and ChartView.kind_of(curve, self._kind) is ChartKind.LINE
382
+
383
+
384
+ def _nearest(x_values: list[float], y_values: list[float], x: float) -> tuple[float, float] | None:
385
+ """Get the drawn point with the x nearest `x`, None when none is drawn."""
386
+ xs = np.asarray(x_values, dtype=float)
387
+ ys = np.asarray(y_values, dtype=float)
388
+ drawn = np.isfinite(xs) & np.isfinite(ys)
389
+ if not drawn.any():
390
+ return None
391
+ distances = np.where(drawn, np.abs(xs - x), math.inf)
392
+ index = int(np.argmin(distances))
393
+ return float(xs[index]), float(ys[index])
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ from typing import Any, Optional
5
+
6
+ from qtpy.QtCore import QDate, QPoint, Qt
7
+ from qtpy.QtCore import Signal as QtSignal
8
+ from qtpy.QtWidgets import QCalendarWidget, QHBoxLayout, QLineEdit, QToolButton, QWidget
9
+
10
+ from composeui.core.view import ISO_DATE_FORMAT, format_date, parse_date
11
+
12
+ #: a calendar's own range, Qt's default, for a limit the view leaves out
13
+ _CALENDAR_FIRST, _CALENDAR_LAST = QDate(-4714, 11, 25), QDate(9999, 12, 31)
14
+
15
+
16
+ def _qdate(value: datetime.date) -> QDate:
17
+ return QDate(value.year, value.month, value.day)
18
+
19
+
20
+ class QDateField(QWidget):
21
+ """A field typing a date as `date_format` says, then a button opening a calendar.
22
+
23
+ Qt's date edit can't be empty: this field can, meaning no date. `date_edited` reports
24
+ the user's dates, typed or picked, and None once the field is emptied; text that isn't
25
+ a date reports nothing, and leaving the field puts the date back. The calendar, a
26
+ popup, offers the dates from `minimum` to `maximum`, named in the system's language.
27
+ """
28
+
29
+ #: the user typed or picked a date (a `datetime.date`), or emptied the field (None)
30
+ date_edited = QtSignal(object)
31
+
32
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
33
+ super().__init__(*args, **kwargs)
34
+ self._date: Optional[datetime.date] = None
35
+ self._date_format = ISO_DATE_FORMAT
36
+ layout = QHBoxLayout(self)
37
+ layout.setContentsMargins(0, 0, 0, 0)
38
+ layout.setSpacing(2)
39
+ self.line_edit = QLineEdit()
40
+ self.line_edit.textEdited.connect(self._read_text)
41
+ self.line_edit.editingFinished.connect(self._show_date)
42
+ layout.addWidget(self.line_edit)
43
+ self.button = QToolButton()
44
+ self.button.setArrowType(Qt.ArrowType.DownArrow)
45
+ self.button.clicked.connect(self.open_calendar)
46
+ layout.addWidget(self.button)
47
+ self.calendar = QCalendarWidget(self)
48
+ self.calendar.setWindowFlags(Qt.WindowType.Popup)
49
+ self.calendar.clicked.connect(self._pick)
50
+ self.calendar.activated.connect(self._pick) # Enter on a day
51
+
52
+ def set_date(self, value: Optional[datetime.date]) -> None:
53
+ self._date = value
54
+ self._show_date()
55
+
56
+ def set_date_format(self, date_format: str) -> None:
57
+ self._date_format = date_format
58
+ self._show_date()
59
+
60
+ def set_range(self, minimum: Optional[datetime.date], maximum: Optional[datetime.date]) -> None:
61
+ first = _CALENDAR_FIRST if minimum is None else _qdate(minimum)
62
+ last = _CALENDAR_LAST if maximum is None else _qdate(maximum)
63
+ self.calendar.setDateRange(first, last)
64
+
65
+ def open_calendar(self) -> None:
66
+ """Show the calendar under the field, on the date, else on today's month."""
67
+ if self._date is not None:
68
+ self.calendar.setSelectedDate(_qdate(self._date))
69
+ else:
70
+ today = QDate.currentDate()
71
+ self.calendar.setCurrentPage(today.year(), today.month())
72
+ self.calendar.move(self.mapToGlobal(QPoint(0, self.height())))
73
+ self.calendar.show()
74
+ self.calendar.setFocus()
75
+
76
+ def _read_text(self, text: str) -> None:
77
+ if text.strip() == "":
78
+ self._edit(None)
79
+ return
80
+ try:
81
+ value = parse_date(text, self._date_format)
82
+ except ValueError:
83
+ return # not a date yet, e.g. halfway through typing one
84
+ self._edit(value)
85
+
86
+ def _pick(self, value: QDate) -> None:
87
+ # `close`, not `hide`: it also releases the popup's window, which WSLg's
88
+ # compositor could otherwise keep drawing (see `release_when_hidden`)
89
+ self.calendar.close()
90
+ self._edit(datetime.date(value.year(), value.month(), value.day()))
91
+ self._show_date()
92
+
93
+ def _edit(self, value: Optional[datetime.date]) -> None:
94
+ if value != self._date:
95
+ self._date = value
96
+ self.date_edited.emit(value)
97
+
98
+ def _show_date(self) -> None:
99
+ text = "" if self._date is None else format_date(self._date, self._date_format)
100
+ if self.line_edit.text() != text:
101
+ self.line_edit.setText(text)
@@ -0,0 +1,60 @@
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 QSizePolicy, QToolButton, QVBoxLayout, QWidget
8
+
9
+ #: pixels the content is indented by, under the header's title
10
+ _INDENT = 20
11
+
12
+
13
+ class QExpander(QWidget):
14
+ """A collapsible section: a header button with an arrow, then its content.
15
+
16
+ The content shows only while the section is expanded. `expanded_changed` reports the
17
+ user's clicks on the header; `set_expanded` is quiet, and so is any change while the
18
+ expander's signals are blocked (the renderers block them while refreshing).
19
+ """
20
+
21
+ #: the user expanded (True) or collapsed (False) the section
22
+ expanded_changed = QtSignal(bool)
23
+
24
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
25
+ super().__init__(*args, **kwargs)
26
+ layout = QVBoxLayout(self)
27
+ layout.setContentsMargins(0, 0, 0, 0)
28
+ self.header = QToolButton()
29
+ self.header.setCheckable(True)
30
+ self.header.setAutoRaise(True)
31
+ self.header.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
32
+ # the whole row is the header to click, not only its text
33
+ self.header.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
34
+ # flat: a checkable button draws itself pressed while expanded, not a header's look
35
+ self.header.setStyleSheet("QToolButton { border: none; padding: 2px 0; }")
36
+ font = self.header.font()
37
+ font.setBold(True)
38
+ self.header.setFont(font)
39
+ self.header.toggled.connect(self._on_header_toggled)
40
+ layout.addWidget(self.header)
41
+ self.content = QWidget()
42
+ self.content_layout = QVBoxLayout(self.content)
43
+ # under the title, past the arrow, so a nested section reads as nested
44
+ self.content_layout.setContentsMargins(_INDENT, 0, 0, 0)
45
+ layout.addWidget(self.content)
46
+ self._show_expanded(is_expanded=False)
47
+
48
+ def set_expanded(self, is_expanded: bool) -> None:
49
+ self.header.blockSignals(True) # noqa: FBT003
50
+ self.header.setChecked(is_expanded)
51
+ self.header.blockSignals(False) # noqa: FBT003
52
+ self._show_expanded(is_expanded=is_expanded)
53
+
54
+ def _show_expanded(self, *, is_expanded: bool) -> None:
55
+ self.header.setArrowType(Qt.ArrowType.DownArrow if is_expanded else Qt.ArrowType.RightArrow)
56
+ self.content.setVisible(is_expanded)
57
+
58
+ def _on_header_toggled(self, is_expanded: bool) -> None:
59
+ self._show_expanded(is_expanded=is_expanded)
60
+ self.expanded_changed.emit(is_expanded)