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
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual.widgets import Button
|
|
4
|
+
|
|
5
|
+
from composeui.core.signal import SignalNoArgs
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ComposeButton(Button):
|
|
9
|
+
"""A `Button` re-exposing its presses as a plain composeui `Signal`."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
super().__init__()
|
|
13
|
+
self.clicked = SignalNoArgs()
|
|
14
|
+
|
|
15
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
16
|
+
event.stop()
|
|
17
|
+
self.clicked.emit()
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import math
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from textual.color import Color
|
|
8
|
+
from textual_fspicker import FileSave, Filters
|
|
9
|
+
from textual_fspicker.path_filters import Filter
|
|
10
|
+
from textual_plotext import PlotextPlot
|
|
11
|
+
|
|
12
|
+
from composeui.core import chartimage
|
|
13
|
+
from composeui.core.chartcolors import series_color
|
|
14
|
+
from composeui.core.signal import Signal
|
|
15
|
+
from composeui.core.view import ChartKind, ChartView
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from textual.app import RenderResult
|
|
21
|
+
|
|
22
|
+
from composeui.core.view import ReferenceLine, Series
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
#: the key saving the chart's image
|
|
26
|
+
SAVE_IMAGE_KEY = "s"
|
|
27
|
+
#: the formats the save dialog offers, the vector ones first, by its filters' names
|
|
28
|
+
_IMAGE_FILTERS = {"PDF": "pdf", "SVG": "svg", "PNG": "png"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _has_suffix(path: Path, suffix: str) -> bool:
|
|
32
|
+
return path.suffix.lower() == suffix
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ComposeChart(PlotextPlot):
|
|
36
|
+
"""A chart drawn with plotext's characters: no zoom or hover in a terminal.
|
|
37
|
+
|
|
38
|
+
With the `report` extra, its key `s` asks where to save its image, which
|
|
39
|
+
`image_path_chosen` emits.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
can_focus = True
|
|
43
|
+
|
|
44
|
+
DEFAULT_CSS = """
|
|
45
|
+
ComposeChart {
|
|
46
|
+
height: 20;
|
|
47
|
+
min-height: 10;
|
|
48
|
+
}
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
super().__init__()
|
|
53
|
+
self.chart_title = ""
|
|
54
|
+
self.x_label = ""
|
|
55
|
+
self.y_label = ""
|
|
56
|
+
self.series: list[Series] = []
|
|
57
|
+
self.reference_lines: list[ReferenceLine] = []
|
|
58
|
+
self.kind = ChartKind.LINE
|
|
59
|
+
self.categories: list[str] = []
|
|
60
|
+
self.x_log = False
|
|
61
|
+
self.y_log = False
|
|
62
|
+
self.image_name = "chart.pdf"
|
|
63
|
+
self.image_path_chosen: Signal[Path] = Signal()
|
|
64
|
+
self.set_save_image_text("Save Image")
|
|
65
|
+
|
|
66
|
+
def set_save_image_text(self, text: str) -> None:
|
|
67
|
+
"""Describe the key saving the chart's image, e.g. in the app's footer."""
|
|
68
|
+
self._bindings.key_to_bindings.pop(SAVE_IMAGE_KEY, None)
|
|
69
|
+
self._bindings.bind(SAVE_IMAGE_KEY, "save_image", text)
|
|
70
|
+
self.refresh_bindings()
|
|
71
|
+
|
|
72
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
73
|
+
if action == "save_image":
|
|
74
|
+
return chartimage.is_available() # matplotlib draws the image
|
|
75
|
+
# Textual asks a widget about its own bindings only
|
|
76
|
+
return super().check_action(action, parameters) # pragma: no cover
|
|
77
|
+
|
|
78
|
+
def action_save_image(self) -> None:
|
|
79
|
+
filters = Filters(
|
|
80
|
+
*(
|
|
81
|
+
Filter(name, functools.partial(_has_suffix, suffix=f".{suffix}"))
|
|
82
|
+
for name, suffix in _IMAGE_FILTERS.items()
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
dialog = FileSave(filters=filters, default_file=self.image_name)
|
|
86
|
+
self.app.push_screen(dialog, self._image_path_chosen)
|
|
87
|
+
|
|
88
|
+
def _image_path_chosen(self, path: Path | None) -> None:
|
|
89
|
+
if path is None:
|
|
90
|
+
return
|
|
91
|
+
if path.suffix.lstrip(".").lower() not in chartimage.FORMATS:
|
|
92
|
+
path = path.with_name(f"{path.name}.pdf")
|
|
93
|
+
self.image_path_chosen.emit(path)
|
|
94
|
+
|
|
95
|
+
def render(self) -> RenderResult:
|
|
96
|
+
# the figure afresh at each render, from the attributes: building a figure with a
|
|
97
|
+
# log scale takes the log of its data in place, so building it twice would take
|
|
98
|
+
# the log of the logs. In the theme's colors too, its series' stepped for it
|
|
99
|
+
self._fill_figure()
|
|
100
|
+
return super().render()
|
|
101
|
+
|
|
102
|
+
def _fill_figure(self) -> None:
|
|
103
|
+
"""Give plotext the chart's title, labels and series."""
|
|
104
|
+
plt = self.plt
|
|
105
|
+
plt.clear_figure()
|
|
106
|
+
plt.title(self.chart_title)
|
|
107
|
+
plt.xlabel(self.x_label)
|
|
108
|
+
plt.ylabel(self.y_label)
|
|
109
|
+
is_dark = self.app.current_theme.dark if self.is_mounted else False
|
|
110
|
+
colors = [
|
|
111
|
+
Color.parse(curve.color or series_color(index, is_dark=is_dark)).rgb
|
|
112
|
+
for index, curve in enumerate(self.series)
|
|
113
|
+
]
|
|
114
|
+
if self.kind is ChartKind.BAR:
|
|
115
|
+
self._plot_bars(colors)
|
|
116
|
+
else:
|
|
117
|
+
self._plot_curves(colors)
|
|
118
|
+
self._plot_reference_lines()
|
|
119
|
+
|
|
120
|
+
def _plot_curves(self, colors: list[tuple[int, int, int]]) -> None:
|
|
121
|
+
plt = self.plt
|
|
122
|
+
if self.x_log:
|
|
123
|
+
plt.xscale("log")
|
|
124
|
+
if self.y_log:
|
|
125
|
+
plt.yscale("log")
|
|
126
|
+
for index, curve in enumerate(self.series):
|
|
127
|
+
is_dots = ChartView.kind_of(curve, self.kind) is ChartKind.SCATTER
|
|
128
|
+
draw = plt.scatter if is_dots else plt.plot
|
|
129
|
+
x, y = ChartView.drawn_points(curve, x_log=self.x_log, y_log=self.y_log)
|
|
130
|
+
if curve.step and not is_dots:
|
|
131
|
+
x, y = ChartView.step_points(x, y)
|
|
132
|
+
# plotext leaves a gap at a NaN itself
|
|
133
|
+
draw(
|
|
134
|
+
x,
|
|
135
|
+
y,
|
|
136
|
+
label=curve.name,
|
|
137
|
+
color=colors[index],
|
|
138
|
+
marker="braille",
|
|
139
|
+
# down to y = 0, e.g. a shape
|
|
140
|
+
fillx=curve.fill,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _extent(self) -> tuple[list[float], list[float]]:
|
|
144
|
+
"""Get the x and y the series reach, in plotext's values: a bar chart's x from 1."""
|
|
145
|
+
if self.kind is ChartKind.BAR:
|
|
146
|
+
heights = [
|
|
147
|
+
height
|
|
148
|
+
for curve in self.series
|
|
149
|
+
for height in ChartView.bar_heights(curve, len(self.categories), y_log=self.y_log)
|
|
150
|
+
if math.isfinite(height)
|
|
151
|
+
]
|
|
152
|
+
return [0.5, len(self.categories) + 0.5], heights
|
|
153
|
+
xs: list[float] = []
|
|
154
|
+
ys: list[float] = []
|
|
155
|
+
for curve in self.series:
|
|
156
|
+
x, y = ChartView.drawn_points(curve, x_log=self.x_log, y_log=self.y_log)
|
|
157
|
+
xs.extend(value for value in x if math.isfinite(value))
|
|
158
|
+
ys.extend(value for value in y if math.isfinite(value))
|
|
159
|
+
return xs, ys
|
|
160
|
+
|
|
161
|
+
def _plot_reference_lines(self) -> None:
|
|
162
|
+
"""Draw the reference lines, each labeled at its end, solid: a terminal has no dashes."""
|
|
163
|
+
is_bar = self.kind is ChartKind.BAR
|
|
164
|
+
lines = ChartView.drawn_lines(
|
|
165
|
+
self.reference_lines, x_log=self.x_log and not is_bar, y_log=self.y_log
|
|
166
|
+
)
|
|
167
|
+
if len(lines) == 0:
|
|
168
|
+
return
|
|
169
|
+
# plotext draws a bar chart's categories from 1
|
|
170
|
+
offset = 1 if is_bar else 0
|
|
171
|
+
xs, ys = self._extent()
|
|
172
|
+
xs += [line.value + offset for line in lines if line.vertical]
|
|
173
|
+
ys += [line.value for line in lines if not line.vertical]
|
|
174
|
+
# the theme's text, the axes' own
|
|
175
|
+
ink = Color.parse(self.app.theme_variables["foreground"]).rgb if self.is_mounted else None
|
|
176
|
+
for line in lines:
|
|
177
|
+
color = Color.parse(line.color).rgb if line.color is not None else ink
|
|
178
|
+
if line.vertical:
|
|
179
|
+
x = line.value + offset
|
|
180
|
+
self.plt.vline(x, color)
|
|
181
|
+
if line.label != "" and len(ys) > 0:
|
|
182
|
+
# at its top, left of it (now, after the data)
|
|
183
|
+
self.plt.text(line.label, x, max(ys), color, alignment="right")
|
|
184
|
+
else:
|
|
185
|
+
self.plt.hline(line.value, color)
|
|
186
|
+
if line.label != "" and len(xs) > 0:
|
|
187
|
+
# over it, at its right end
|
|
188
|
+
self.plt.text(line.label, max(xs), line.value, color, alignment="right")
|
|
189
|
+
|
|
190
|
+
def _plot_bars(self, colors: list[tuple[int, int, int]]) -> None:
|
|
191
|
+
if len(self.series) == 0 or len(self.categories) == 0:
|
|
192
|
+
return
|
|
193
|
+
heights = [
|
|
194
|
+
ChartView.bar_heights(curve, len(self.categories), y_log=self.y_log)
|
|
195
|
+
for curve in self.series
|
|
196
|
+
]
|
|
197
|
+
drawn = [height for bars in heights for height in bars if math.isfinite(height)]
|
|
198
|
+
base = 0.0
|
|
199
|
+
if self.y_log:
|
|
200
|
+
self.plt.yscale("log")
|
|
201
|
+
# from the power of 10 under the lowest bar: a log axis has no 0
|
|
202
|
+
base = 10.0 ** math.floor(math.log10(min(drawn))) if len(drawn) > 0 else 1.0
|
|
203
|
+
# plotext draws numbers only: no bar is a bar from the base to itself
|
|
204
|
+
heights = [[h if math.isfinite(h) else base for h in bars] for bars in heights]
|
|
205
|
+
# plotext takes a label and a color per series, as its stub doesn't say
|
|
206
|
+
self.plt.multiple_bar( # type: ignore[call-arg]
|
|
207
|
+
self.categories,
|
|
208
|
+
heights,
|
|
209
|
+
labels=[curve.name for curve in self.series],
|
|
210
|
+
color=colors, # type: ignore[arg-type]
|
|
211
|
+
minimum=base,
|
|
212
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual.widgets import Checkbox
|
|
4
|
+
|
|
5
|
+
from composeui.core.signal import Signal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ComposeCheckBox(Checkbox):
|
|
9
|
+
"""A `Checkbox` re-exposing the user's toggles as a plain composeui `Signal`.
|
|
10
|
+
|
|
11
|
+
Textual posts `Checkbox.Changed` for any change of `value`, set in code too;
|
|
12
|
+
`set_checked` keeps those quiet, as Qt's renderers block their widget's signals
|
|
13
|
+
while refreshing it, so `toggled` only reports the user's toggles.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# a form row's field while the row is invalid (see `ROW_INVALID_CLASS`)
|
|
17
|
+
DEFAULT_CSS = """
|
|
18
|
+
ComposeCheckBox.-row-invalid {
|
|
19
|
+
border: tall $error 60%;
|
|
20
|
+
&:focus {
|
|
21
|
+
border: tall $error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.toggled: Signal[bool] = Signal()
|
|
29
|
+
|
|
30
|
+
def set_checked(self, is_checked: bool) -> None:
|
|
31
|
+
with self.prevent(Checkbox.Changed):
|
|
32
|
+
self.value = is_checked
|
|
33
|
+
|
|
34
|
+
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
|
35
|
+
self.toggled.emit(event.value)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from textual.content import Content
|
|
6
|
+
from textual.widgets import Collapsible
|
|
7
|
+
|
|
8
|
+
from composeui.core.signal import Signal
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from textual.widget import Widget
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ComposeCollapsible(Collapsible):
|
|
15
|
+
"""A `Collapsible` re-exposing the user's toggles as a plain composeui `Signal`.
|
|
16
|
+
|
|
17
|
+
Textual posts `Collapsible.Expanded`/`Collapsed` for any change of `collapsed`, set
|
|
18
|
+
in code too; `set_expanded` keeps those quiet, as Qt's renderers block their
|
|
19
|
+
widget's signals while refreshing it, so `toggled` only reports the user's toggles.
|
|
20
|
+
The messages bubble: one from a collapsible nested in this one isn't this one's.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, *children: Widget) -> None:
|
|
24
|
+
super().__init__(*children)
|
|
25
|
+
self.toggled: Signal[bool] = Signal()
|
|
26
|
+
|
|
27
|
+
def _watch_title(self, title: str) -> None:
|
|
28
|
+
# plain text: Textual's own reads the title as markup
|
|
29
|
+
self._title.label = Content(title)
|
|
30
|
+
|
|
31
|
+
def set_expanded(self, is_expanded: bool) -> None:
|
|
32
|
+
with self.prevent(Collapsible.Expanded, Collapsible.Collapsed):
|
|
33
|
+
self.collapsed = not is_expanded
|
|
34
|
+
|
|
35
|
+
def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None:
|
|
36
|
+
if event.collapsible is self:
|
|
37
|
+
self.toggled.emit(True) # noqa: FBT003
|
|
38
|
+
|
|
39
|
+
def on_collapsible_collapsed(self, event: Collapsible.Collapsed) -> None:
|
|
40
|
+
if event.collapsible is self:
|
|
41
|
+
self.toggled.emit(False) # noqa: FBT003
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
from textual.content import Content
|
|
6
|
+
from textual.widgets import Select
|
|
7
|
+
|
|
8
|
+
from composeui.core.signal import Signal
|
|
9
|
+
|
|
10
|
+
TValue = TypeVar("TValue")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _options(values: dict[str, TValue]) -> list[tuple[Content, TValue]]:
|
|
14
|
+
"""Get the options of `values`, their labels plain text, not markup."""
|
|
15
|
+
return [(Content(label), value) for label, value in values.items()]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ComposeComboBox(Select[TValue], Generic[TValue]):
|
|
19
|
+
"""A combobox built from a `{label: value}` mapping."""
|
|
20
|
+
|
|
21
|
+
# a form row's field while the row is invalid (see `ROW_INVALID_CLASS`): the border
|
|
22
|
+
# is the current option's
|
|
23
|
+
DEFAULT_CSS = """
|
|
24
|
+
ComposeComboBox.-row-invalid > SelectCurrent {
|
|
25
|
+
border: tall $error 60%;
|
|
26
|
+
}
|
|
27
|
+
ComposeComboBox.-row-invalid:focus > SelectCurrent {
|
|
28
|
+
border: tall $error;
|
|
29
|
+
}
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, values: dict[str, TValue]) -> None:
|
|
33
|
+
self._values: dict[str, TValue] = dict(values)
|
|
34
|
+
super().__init__(_options(values))
|
|
35
|
+
self.value_changed: Signal[TValue | None] = Signal()
|
|
36
|
+
|
|
37
|
+
def set_values(self, values: dict[str, TValue]) -> None:
|
|
38
|
+
"""Rebuild the select for a new set of options."""
|
|
39
|
+
# set in code, not chosen by the user: `value_changed` isn't emitted
|
|
40
|
+
with self.prevent(Select.Changed):
|
|
41
|
+
self.set_options(_options(values))
|
|
42
|
+
|
|
43
|
+
def set_value(self, value: TValue | None) -> None:
|
|
44
|
+
with self.prevent(Select.Changed):
|
|
45
|
+
if value is None:
|
|
46
|
+
self.value = Select.NULL
|
|
47
|
+
else:
|
|
48
|
+
self.value = value
|
|
49
|
+
|
|
50
|
+
def current_value(self) -> TValue | None:
|
|
51
|
+
return self.selection
|
|
52
|
+
|
|
53
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
54
|
+
self.value_changed.emit(self.current_value())
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import calendar
|
|
4
|
+
import datetime
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, Optional
|
|
6
|
+
|
|
7
|
+
from rich.style import Style
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual.binding import Binding, BindingType
|
|
10
|
+
from textual.containers import Center, Horizontal, Middle
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.validation import ValidationResult, Validator
|
|
13
|
+
from textual.widget import Widget
|
|
14
|
+
from textual.widgets import Button
|
|
15
|
+
|
|
16
|
+
from composeui.core.signal import Signal
|
|
17
|
+
from composeui.core.view import ISO_DATE_FORMAT, format_date, parse_date
|
|
18
|
+
from composeui.textualcore.widgets.composeinput import ComposeInput
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
|
|
23
|
+
from textual import events
|
|
24
|
+
from textual.app import ComposeResult, RenderResult
|
|
25
|
+
|
|
26
|
+
#: a day's cell in the month grid: its number right-aligned, after a space
|
|
27
|
+
_CELL_WIDTH = 3
|
|
28
|
+
#: the month grid's lines above its weeks: the month, then the days' names
|
|
29
|
+
_HEADER_LINES = 2
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ComposeMonth(Widget, can_focus=True):
|
|
33
|
+
"""A month's days in a grid, a week a line from Monday, the cursor on one day.
|
|
34
|
+
|
|
35
|
+
Textual has no calendar. The arrows move the cursor a day or a week, Page Up/Down a
|
|
36
|
+
month, onto the next month's grid as needed; Enter picks the cursor's day, and a click
|
|
37
|
+
picks the day under it (or, on the month's line, goes to the month before or after).
|
|
38
|
+
Days out of `minimum`/`maximum` are dimmed and never picked.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
42
|
+
Binding("left", "move(-1)", show=False),
|
|
43
|
+
Binding("right", "move(1)", show=False),
|
|
44
|
+
Binding("up", "move(-7)", show=False),
|
|
45
|
+
Binding("down", "move(7)", show=False),
|
|
46
|
+
Binding("pageup", "move_month(-1)", show=False),
|
|
47
|
+
Binding("pagedown", "move_month(1)", show=False),
|
|
48
|
+
Binding("enter", "pick", show=False),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
|
52
|
+
"month--title",
|
|
53
|
+
"month--day-name",
|
|
54
|
+
"month--cursor",
|
|
55
|
+
"month--picked",
|
|
56
|
+
"month--out-of-range",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
DEFAULT_CSS = """
|
|
60
|
+
ComposeMonth {
|
|
61
|
+
width: 21;
|
|
62
|
+
height: 8;
|
|
63
|
+
& > .month--title {
|
|
64
|
+
text-style: bold;
|
|
65
|
+
}
|
|
66
|
+
& > .month--day-name {
|
|
67
|
+
color: $foreground 60%;
|
|
68
|
+
}
|
|
69
|
+
& > .month--cursor {
|
|
70
|
+
background: $accent 40%;
|
|
71
|
+
}
|
|
72
|
+
&:focus > .month--cursor {
|
|
73
|
+
background: $accent;
|
|
74
|
+
color: $text;
|
|
75
|
+
}
|
|
76
|
+
& > .month--picked {
|
|
77
|
+
text-style: bold underline;
|
|
78
|
+
}
|
|
79
|
+
& > .month--out-of-range {
|
|
80
|
+
color: $foreground 30%;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
picked: datetime.date | None,
|
|
88
|
+
minimum: datetime.date | None,
|
|
89
|
+
maximum: datetime.date | None,
|
|
90
|
+
month_names: Sequence[str] = (),
|
|
91
|
+
day_names: Sequence[str] = (),
|
|
92
|
+
) -> None:
|
|
93
|
+
super().__init__()
|
|
94
|
+
self.minimum, self.maximum = minimum, maximum
|
|
95
|
+
# on the date picked, else on today
|
|
96
|
+
self.cursor = self._in_range(picked or datetime.datetime.now().astimezone().date())
|
|
97
|
+
self.picked_date = picked
|
|
98
|
+
self.month_names = list(month_names) or list(calendar.month_name)[1:]
|
|
99
|
+
self.day_names = list(day_names) or [name[:2] for name in calendar.day_abbr]
|
|
100
|
+
#: the user picked a day
|
|
101
|
+
self.picked: Signal[datetime.date] = Signal()
|
|
102
|
+
|
|
103
|
+
def render(self) -> RenderResult:
|
|
104
|
+
text = Text(no_wrap=True, overflow="crop")
|
|
105
|
+
width = 7 * _CELL_WIDTH
|
|
106
|
+
title = f"{self.month_names[self.cursor.month - 1]} {self.cursor.year}"
|
|
107
|
+
text.append(
|
|
108
|
+
f"<{title.center(width - 2)}>\n", style=self.get_component_rich_style("month--title")
|
|
109
|
+
)
|
|
110
|
+
names = "".join(f"{name[:2]:>{_CELL_WIDTH}}" for name in self.day_names)
|
|
111
|
+
text.append(f"{names}\n", style=self.get_component_rich_style("month--day-name"))
|
|
112
|
+
for week in self._weeks():
|
|
113
|
+
for day in week:
|
|
114
|
+
if day is None:
|
|
115
|
+
text.append(" " * _CELL_WIDTH)
|
|
116
|
+
continue
|
|
117
|
+
styles = []
|
|
118
|
+
if not self._is_in_range(day):
|
|
119
|
+
styles.append("month--out-of-range")
|
|
120
|
+
if day == self.picked_date:
|
|
121
|
+
styles.append("month--picked")
|
|
122
|
+
if day == self.cursor:
|
|
123
|
+
styles.append("month--cursor")
|
|
124
|
+
style = Style.combine([Style(), *map(self.get_component_rich_style, styles)])
|
|
125
|
+
text.append(" ")
|
|
126
|
+
text.append(f"{day.day:>{_CELL_WIDTH - 1}}", style=style)
|
|
127
|
+
text.append("\n")
|
|
128
|
+
text.rstrip()
|
|
129
|
+
return text
|
|
130
|
+
|
|
131
|
+
def _weeks(self) -> list[list[datetime.date | None]]:
|
|
132
|
+
"""Get the cursor's month a week a list, Monday first, None out of the month."""
|
|
133
|
+
month = calendar.Calendar(firstweekday=0)
|
|
134
|
+
return [
|
|
135
|
+
[day if day.month == self.cursor.month else None for day in week]
|
|
136
|
+
for week in month.monthdatescalendar(self.cursor.year, self.cursor.month)
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
def action_move(self, days: int) -> None:
|
|
140
|
+
self._move_to(self.cursor + datetime.timedelta(days=days))
|
|
141
|
+
|
|
142
|
+
def action_move_month(self, months: int) -> None:
|
|
143
|
+
month = self.cursor.month - 1 + months
|
|
144
|
+
year, month = self.cursor.year + month // 12, month % 12 + 1
|
|
145
|
+
day = min(self.cursor.day, calendar.monthrange(year, month)[1])
|
|
146
|
+
self._move_to(datetime.date(year, month, day))
|
|
147
|
+
|
|
148
|
+
def action_pick(self) -> None:
|
|
149
|
+
if self._is_in_range(self.cursor):
|
|
150
|
+
self.picked.emit(self.cursor)
|
|
151
|
+
|
|
152
|
+
def on_click(self, event: events.Click) -> None:
|
|
153
|
+
if event.y == 0:
|
|
154
|
+
self.action_move_month(-1 if event.x < 7 * _CELL_WIDTH // 2 else 1)
|
|
155
|
+
return
|
|
156
|
+
week, column = event.y - _HEADER_LINES, event.x // _CELL_WIDTH
|
|
157
|
+
weeks = self._weeks()
|
|
158
|
+
if 0 <= week < len(weeks) and 0 <= column < len(weeks[week]):
|
|
159
|
+
day = weeks[week][column]
|
|
160
|
+
if day is not None and self._is_in_range(day):
|
|
161
|
+
self.cursor = day
|
|
162
|
+
self.picked.emit(day)
|
|
163
|
+
|
|
164
|
+
def _move_to(self, day: datetime.date) -> None:
|
|
165
|
+
self.cursor = self._in_range(day)
|
|
166
|
+
self.refresh()
|
|
167
|
+
|
|
168
|
+
def _is_in_range(self, day: datetime.date) -> bool:
|
|
169
|
+
return (self.minimum is None or day >= self.minimum) and (
|
|
170
|
+
self.maximum is None or day <= self.maximum
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _in_range(self, day: datetime.date) -> datetime.date:
|
|
174
|
+
if self.minimum is not None and day < self.minimum:
|
|
175
|
+
return self.minimum
|
|
176
|
+
if self.maximum is not None and day > self.maximum:
|
|
177
|
+
return self.maximum
|
|
178
|
+
return day
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ComposeCalendar(ModalScreen[Optional[datetime.date]]):
|
|
182
|
+
"""A month grid over the app: dismissed with the day picked, or None on Escape."""
|
|
183
|
+
|
|
184
|
+
BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "dismiss_none", show=False)]
|
|
185
|
+
|
|
186
|
+
DEFAULT_CSS = """
|
|
187
|
+
ComposeCalendar {
|
|
188
|
+
align: center middle;
|
|
189
|
+
& ComposeMonth {
|
|
190
|
+
border: round $accent;
|
|
191
|
+
padding: 0 1;
|
|
192
|
+
width: 25;
|
|
193
|
+
height: 10;
|
|
194
|
+
background: $surface;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(self, month: ComposeMonth) -> None:
|
|
200
|
+
super().__init__()
|
|
201
|
+
self.month = month
|
|
202
|
+
month.picked.connect(self._dismiss_with)
|
|
203
|
+
|
|
204
|
+
def compose(self) -> ComposeResult:
|
|
205
|
+
with Middle(), Center():
|
|
206
|
+
yield self.month
|
|
207
|
+
|
|
208
|
+
def on_mount(self) -> None:
|
|
209
|
+
self.month.focus()
|
|
210
|
+
|
|
211
|
+
def action_dismiss_none(self) -> None:
|
|
212
|
+
self.dismiss(None)
|
|
213
|
+
|
|
214
|
+
def _dismiss_with(self, day: datetime.date) -> None:
|
|
215
|
+
self.dismiss(day)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class _DateValidator(Validator):
|
|
219
|
+
"""A date as the field's format says, within its range; no date at all is valid too."""
|
|
220
|
+
|
|
221
|
+
def __init__(self, field: ComposeDateInput) -> None:
|
|
222
|
+
super().__init__()
|
|
223
|
+
self.field = field
|
|
224
|
+
|
|
225
|
+
def validate(self, value: str) -> ValidationResult:
|
|
226
|
+
if value.strip() == "":
|
|
227
|
+
return self.success()
|
|
228
|
+
try:
|
|
229
|
+
date = parse_date(value, self.field.date_format)
|
|
230
|
+
except ValueError:
|
|
231
|
+
return self.failure()
|
|
232
|
+
minimum, maximum = self.field.minimum, self.field.maximum
|
|
233
|
+
if (minimum is not None and date < minimum) or (maximum is not None and date > maximum):
|
|
234
|
+
return self.failure()
|
|
235
|
+
return self.success()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class ComposeDateInput(Horizontal):
|
|
239
|
+
"""A field typing a date as `date_format` says, then a button opening a calendar.
|
|
240
|
+
|
|
241
|
+
`date_edited` reports the user's dates, typed or picked, and None once the field is
|
|
242
|
+
emptied: never a date set in code (`set_date`), though Textual posts its field's
|
|
243
|
+
changes either way. Text that isn't a date, or a date out of range, marks the field
|
|
244
|
+
invalid; text that isn't a date reports nothing, and leaving the field puts the date
|
|
245
|
+
back.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
DEFAULT_CSS = """
|
|
249
|
+
ComposeDateInput {
|
|
250
|
+
height: auto;
|
|
251
|
+
& > ComposeInput {
|
|
252
|
+
width: 1fr;
|
|
253
|
+
}
|
|
254
|
+
& > Button {
|
|
255
|
+
min-width: 5;
|
|
256
|
+
width: 5;
|
|
257
|
+
}
|
|
258
|
+
&.-row-invalid > ComposeInput {
|
|
259
|
+
border: tall $error 60%;
|
|
260
|
+
&:focus {
|
|
261
|
+
border: tall $error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def __init__(self) -> None:
|
|
268
|
+
self.input = ComposeInput(validators=[])
|
|
269
|
+
self.button = Button("▾")
|
|
270
|
+
super().__init__(self.input, self.button)
|
|
271
|
+
self.input.validators.append(_DateValidator(self))
|
|
272
|
+
self.date_format = ISO_DATE_FORMAT
|
|
273
|
+
self.minimum: datetime.date | None = None
|
|
274
|
+
self.maximum: datetime.date | None = None
|
|
275
|
+
self.month_names: list[str] = []
|
|
276
|
+
self.day_names: list[str] = []
|
|
277
|
+
self._date: datetime.date | None = None
|
|
278
|
+
self.date_edited: Signal[datetime.date | None] = Signal()
|
|
279
|
+
self.input.value_changed.connect(self._read_text)
|
|
280
|
+
self.input.value_submitted.connect(self._show_date)
|
|
281
|
+
|
|
282
|
+
def set_date(self, value: datetime.date | None) -> None:
|
|
283
|
+
self._date = value
|
|
284
|
+
self._show_date()
|
|
285
|
+
|
|
286
|
+
def set_date_format(self, date_format: str) -> None:
|
|
287
|
+
self.date_format = date_format
|
|
288
|
+
self._show_date()
|
|
289
|
+
|
|
290
|
+
def open_calendar(self) -> None:
|
|
291
|
+
"""Show the calendar on the date, else on today."""
|
|
292
|
+
month = ComposeMonth(
|
|
293
|
+
self._date, self.minimum, self.maximum, self.month_names, self.day_names
|
|
294
|
+
)
|
|
295
|
+
self.app.push_screen(ComposeCalendar(month), self._pick)
|
|
296
|
+
|
|
297
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
298
|
+
event.stop()
|
|
299
|
+
self.open_calendar()
|
|
300
|
+
|
|
301
|
+
def _pick(self, value: datetime.date | None) -> None:
|
|
302
|
+
if value is not None:
|
|
303
|
+
self._edit(value)
|
|
304
|
+
self._show_date()
|
|
305
|
+
self.input.focus()
|
|
306
|
+
|
|
307
|
+
def _read_text(self, text: str) -> None:
|
|
308
|
+
if text.strip() == "":
|
|
309
|
+
self._edit(None)
|
|
310
|
+
return
|
|
311
|
+
try:
|
|
312
|
+
value = parse_date(text, self.date_format)
|
|
313
|
+
except ValueError:
|
|
314
|
+
return # not a date yet, e.g. halfway through typing one
|
|
315
|
+
self._edit(value)
|
|
316
|
+
|
|
317
|
+
def _edit(self, value: datetime.date | None) -> None:
|
|
318
|
+
if value != self._date:
|
|
319
|
+
self._date = value
|
|
320
|
+
self.date_edited.emit(value)
|
|
321
|
+
|
|
322
|
+
def _show_date(self) -> None:
|
|
323
|
+
text = "" if self._date is None else format_date(self._date, self.date_format)
|
|
324
|
+
if self.input.value != text:
|
|
325
|
+
self.input.value = text
|