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