nice-dialogs 1.3.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.
- nice_dialogs/__init__.py +3 -0
- nice_dialogs/dialogs/__init__.py +15 -0
- nice_dialogs/dialogs/confirmation.py +111 -0
- nice_dialogs/dialogs/cron_editor.py +336 -0
- nice_dialogs/dialogs/datetime_picker.py +226 -0
- nice_dialogs/dialogs/labelmaker.py +278 -0
- nice_dialogs/dialogs/types.py +30 -0
- nice_dialogs/dialogs/upload_file.py +148 -0
- nice_dialogs/py.typed +0 -0
- nice_dialogs-1.3.0.dist-info/METADATA +84 -0
- nice_dialogs-1.3.0.dist-info/RECORD +13 -0
- nice_dialogs-1.3.0.dist-info/WHEEL +4 -0
- nice_dialogs-1.3.0.dist-info/licenses/LICENSE +21 -0
nice_dialogs/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .confirmation import ConfirmationDialog
|
|
4
|
+
from .cron_editor import CronEditorDialog
|
|
5
|
+
from .datetime_picker import DatetimePickerDialog
|
|
6
|
+
from .labelmaker import LabelMakerDialog
|
|
7
|
+
from .upload_file import UploadFileDialog
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ConfirmationDialog",
|
|
11
|
+
"CronEditorDialog",
|
|
12
|
+
"DatetimePickerDialog",
|
|
13
|
+
"LabelMakerDialog",
|
|
14
|
+
"UploadFileDialog",
|
|
15
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""This module implements a simple Yes/No confirmation dialog with a few useful
|
|
2
|
+
extra options.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from nicegui import ui
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ConfirmationDialogModel:
|
|
16
|
+
"""Data model for a confirmation dialog.
|
|
17
|
+
|
|
18
|
+
Attributes allow customization of dialog elements, which allows the dialog
|
|
19
|
+
to be used in various situations that require custom messaging and light
|
|
20
|
+
theming. The text style of the message itself can be set by providing
|
|
21
|
+
Tailwind classes in the `message_text_classes` attribute, or customized
|
|
22
|
+
more directly using CSS with the `message_text_style` attribute; these are applied
|
|
23
|
+
to the `classes` and `style` of the message `ui.label` component, respectively.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
icon: str = "warning"
|
|
27
|
+
icon_color: str = "warning"
|
|
28
|
+
yes_button_label: str = "Yes"
|
|
29
|
+
yes_button_color: str = "positive"
|
|
30
|
+
no_button_label: str = "No"
|
|
31
|
+
no_button_color: str = "negative"
|
|
32
|
+
remember: bool = False
|
|
33
|
+
yes_return_value: Any = True
|
|
34
|
+
no_return_value: Any = False
|
|
35
|
+
show_remember_checkbox: bool = False
|
|
36
|
+
remember_checkbox_color: str = "accent"
|
|
37
|
+
message_text_classes: str = "font-medium"
|
|
38
|
+
message_text_style: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfirmationDialog(ui.dialog):
|
|
42
|
+
"""A NiceGUI dialog box presenting a simple yes/no confirmation.
|
|
43
|
+
|
|
44
|
+
Other than the required `dialog_title` and `message`, any keyword arguments are
|
|
45
|
+
passed along to the dialog's data model, which can control the wording and
|
|
46
|
+
color of the Yes/No options, as well as the displayed icon, and whether the
|
|
47
|
+
"remember my choice" checkbox is enabled.
|
|
48
|
+
|
|
49
|
+
When awaited, the dialog will always return a tuple of the configured return value
|
|
50
|
+
of the selected button and the state of the "remember" checkbox, irrespective
|
|
51
|
+
of whether it was displayed.
|
|
52
|
+
|
|
53
|
+
By default, the "yes"/"no" buttons return a bool `True`/`False` respectively,
|
|
54
|
+
but these values can be customized via the data model.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
dialog_title: str
|
|
58
|
+
message: str
|
|
59
|
+
model: ConfirmationDialogModel
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
dialog_title: str = "Confirmation",
|
|
65
|
+
message: str = "Are you sure?",
|
|
66
|
+
**kwargs: Any,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Initialize the dialog and its components."""
|
|
69
|
+
super().__init__()
|
|
70
|
+
self.dialog_title = dialog_title
|
|
71
|
+
self.message = message
|
|
72
|
+
self.model = ConfirmationDialogModel(**kwargs)
|
|
73
|
+
self.dialog_layout()
|
|
74
|
+
|
|
75
|
+
if TYPE_CHECKING:
|
|
76
|
+
|
|
77
|
+
def __await__(self) -> Generator[None, None, tuple[Any, bool]]: ...
|
|
78
|
+
|
|
79
|
+
def dialog_layout(self) -> None:
|
|
80
|
+
with self, ui.card():
|
|
81
|
+
ui.label(self.dialog_title).classes("font-bold text-3xl")
|
|
82
|
+
with ui.row().classes("w-full items-center flex"):
|
|
83
|
+
ui.icon(
|
|
84
|
+
self.model.icon, size="4rem", color=self.model.icon_color
|
|
85
|
+
).classes("flex-none")
|
|
86
|
+
ui.label(self.message).classes(
|
|
87
|
+
f"flex-1 {self.model.message_text_classes}"
|
|
88
|
+
).style(self.model.message_text_style)
|
|
89
|
+
|
|
90
|
+
with ui.card_actions().classes("w-full align-left"):
|
|
91
|
+
with ui.row().classes("flex w-full"):
|
|
92
|
+
ui.button(
|
|
93
|
+
self.model.yes_button_label,
|
|
94
|
+
color=self.model.yes_button_color,
|
|
95
|
+
on_click=lambda: self.submit(
|
|
96
|
+
(self.model.yes_return_value, self.model.remember)
|
|
97
|
+
),
|
|
98
|
+
).mark("confirm_yes")
|
|
99
|
+
ui.button(
|
|
100
|
+
self.model.no_button_label,
|
|
101
|
+
color=self.model.no_button_color,
|
|
102
|
+
on_click=lambda: self.submit(
|
|
103
|
+
(self.model.no_return_value, self.model.remember)
|
|
104
|
+
),
|
|
105
|
+
).mark("confirm_no")
|
|
106
|
+
with ui.row().classes("flex w-full justify-end"):
|
|
107
|
+
ui.checkbox("Don't ask me again").classes("scale-75").props(
|
|
108
|
+
f"color={self.model.remember_checkbox_color}"
|
|
109
|
+
).bind_value_to(self, ("model", "remember")).bind_visibility_from(
|
|
110
|
+
self, ("model", "show_remember_checkbox")
|
|
111
|
+
).tooltip("Remember my preference for this confirmation.")
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from itertools import islice
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from nicegui import ui
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
from pydantic_extra_types.cron import CronStr
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .types import ValueChangeEventArguments
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MINUTE_OPTIONS = {
|
|
18
|
+
"*": "Every (*)",
|
|
19
|
+
"0": "HH:00",
|
|
20
|
+
"*/15": "Every 15 minutes",
|
|
21
|
+
"*/30": "Every 30 minutes",
|
|
22
|
+
}
|
|
23
|
+
"""Default set of cron-minute options."""
|
|
24
|
+
|
|
25
|
+
HOUR_OPTIONS = {
|
|
26
|
+
"*": "Every (*)",
|
|
27
|
+
"*/4": "Every 4 hours",
|
|
28
|
+
"*/8": "Every 8 hours",
|
|
29
|
+
"*/12": "Every 12 hours",
|
|
30
|
+
"0": "Midnight",
|
|
31
|
+
"12": "Noon",
|
|
32
|
+
}
|
|
33
|
+
"""Default set of cron-hour options."""
|
|
34
|
+
|
|
35
|
+
# January 1, 2023 was a Sunday
|
|
36
|
+
WEEKDAY_OPTIONS = {
|
|
37
|
+
"*": "Every (*)",
|
|
38
|
+
"1-5": "Weekdays",
|
|
39
|
+
"0,6": "Weekends",
|
|
40
|
+
} | {str(i): datetime(2023, 1, i + 8, tzinfo=UTC).strftime("%A") for i in range(7)}
|
|
41
|
+
"""Default set of cron-day-of-week options."""
|
|
42
|
+
|
|
43
|
+
DAY_OPTIONS = {"*": "Every (*)"}
|
|
44
|
+
"""Default set of cron-day-of-month options."""
|
|
45
|
+
|
|
46
|
+
MONTH_OPTIONS = {"*": "Every (*)"} | {
|
|
47
|
+
str(n): datetime(1970, n, 1, tzinfo=UTC).strftime("%B") for n in range(1, 13)
|
|
48
|
+
}
|
|
49
|
+
"""Default set of cron-month options (12 months)"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class CronEditorModel:
|
|
54
|
+
minute: str = "*"
|
|
55
|
+
hour: str = "*"
|
|
56
|
+
dom: str = "*"
|
|
57
|
+
month: str = "*"
|
|
58
|
+
dow: str = "*"
|
|
59
|
+
cron: CronStr = field(init=False)
|
|
60
|
+
is_valid: bool = field(init=False, default=True)
|
|
61
|
+
|
|
62
|
+
def __post_init__(self) -> None:
|
|
63
|
+
try:
|
|
64
|
+
self.cron = CronStr(str(self))
|
|
65
|
+
except (ValueError, ValidationError):
|
|
66
|
+
self.is_valid = False
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
return f"{self.minute} {self.hour} {self.dom} {self.month} {self.dow}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CronEditorDialog(ui.dialog):
|
|
73
|
+
"""NiceGUI Dialog displaying a Cron Expression Editor.
|
|
74
|
+
|
|
75
|
+
Two action buttons are available: "Done" returns the current cron expression string;
|
|
76
|
+
"Cancel" returns None.
|
|
77
|
+
|
|
78
|
+
This dialog is created with the initial cron expression of "* * * * *". This
|
|
79
|
+
expression can be replaced by calling the `reset` method with a new cron expression
|
|
80
|
+
string.
|
|
81
|
+
|
|
82
|
+
If an invalid cron expression is used to create or reset the dialog, a notification
|
|
83
|
+
will alert the user, but the dialog will remain open in order to allow the user to
|
|
84
|
+
correct the expression.
|
|
85
|
+
|
|
86
|
+
The dialog cannot be closed with a positive action if the cron expression is
|
|
87
|
+
invalid.
|
|
88
|
+
|
|
89
|
+
The dialog's `reset` button can be used to set the cron expression back to an
|
|
90
|
+
initial expression, which is whatever value was first used to create or set the
|
|
91
|
+
dialog, which will be `* * * * *` in the default case.
|
|
92
|
+
|
|
93
|
+
Attributes
|
|
94
|
+
----------
|
|
95
|
+
dialog_title: str
|
|
96
|
+
The title displayed at the top of the dialog.
|
|
97
|
+
|
|
98
|
+
model: CronEditorModel
|
|
99
|
+
A data model of the current cron expression, which includes string fields
|
|
100
|
+
for each cron part and a `CronStr` object for validation and scheduling.
|
|
101
|
+
|
|
102
|
+
initial_expression: str
|
|
103
|
+
The initial cron expression used with the dialog when it is first opened.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
dialog_title: str
|
|
107
|
+
model: CronEditorModel
|
|
108
|
+
initial_expression: str
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
dialog_title: str = "Cron Expression Editor",
|
|
114
|
+
) -> None:
|
|
115
|
+
super().__init__()
|
|
116
|
+
self.dialog_title = dialog_title
|
|
117
|
+
self.initial_expression = ""
|
|
118
|
+
self.reset()
|
|
119
|
+
self.dialog_layout()
|
|
120
|
+
|
|
121
|
+
if TYPE_CHECKING:
|
|
122
|
+
|
|
123
|
+
def __await__(self) -> Generator[None, None, str | None]: ...
|
|
124
|
+
|
|
125
|
+
def reset(self, cron: str | None = None) -> None:
|
|
126
|
+
"""Set the dialog to its initial state or to a provided cron expression by
|
|
127
|
+
establishing or replacing the dialog's data model.
|
|
128
|
+
|
|
129
|
+
If the provided cron expression is valid and contains novel tokens, those
|
|
130
|
+
tokens will be added to the options of the relevant select components.
|
|
131
|
+
|
|
132
|
+
This method is called when the dialog is first created, and can be called
|
|
133
|
+
at any time to set the dialog to a specific new cron expression.
|
|
134
|
+
"""
|
|
135
|
+
# Set the dialog's initial or fallback expression on first method call
|
|
136
|
+
self.initial_expression = self.initial_expression or cron or "* * * * *"
|
|
137
|
+
|
|
138
|
+
# Set the data model using the provided cron expression or the fallback
|
|
139
|
+
cron = cron or self.initial_expression
|
|
140
|
+
self.model = CronEditorModel(*cron.split())
|
|
141
|
+
|
|
142
|
+
if self.model.is_valid:
|
|
143
|
+
self.update_input_options()
|
|
144
|
+
else:
|
|
145
|
+
ui.notify(f"Invalid cron expression: {cron}", type="negative")
|
|
146
|
+
|
|
147
|
+
def update_input_options(self) -> None:
|
|
148
|
+
"""Add novel cron expression tokens to the options of the select components.
|
|
149
|
+
|
|
150
|
+
When used interactively, users may input arbitrary cron expression tokens,
|
|
151
|
+
and the select element will accept them as new options.
|
|
152
|
+
|
|
153
|
+
When the dialog is set programatically, as with the `reset` method, we have to
|
|
154
|
+
manually add any novel tokens to those select options.
|
|
155
|
+
"""
|
|
156
|
+
if TYPE_CHECKING:
|
|
157
|
+
# all select options in this implementation are dicts
|
|
158
|
+
assert isinstance(self.minute_select.options, dict)
|
|
159
|
+
assert isinstance(self.hour_select.options, dict)
|
|
160
|
+
assert isinstance(self.dom_select.options, dict)
|
|
161
|
+
assert isinstance(self.month_select.options, dict)
|
|
162
|
+
assert isinstance(self.dow_select.options, dict)
|
|
163
|
+
|
|
164
|
+
# The module constants providing default options for each component are
|
|
165
|
+
# never mutated, so are merged with the existing and novel options.
|
|
166
|
+
try:
|
|
167
|
+
if self.model.minute not in self.minute_select.options:
|
|
168
|
+
self.minute_select.set_options(
|
|
169
|
+
{self.model.minute: self.model.minute}
|
|
170
|
+
| self.minute_select.options
|
|
171
|
+
| MINUTE_OPTIONS
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if self.model.hour not in self.hour_select.options:
|
|
175
|
+
self.hour_select.set_options(
|
|
176
|
+
{self.model.hour: self.model.hour}
|
|
177
|
+
| self.hour_select.options
|
|
178
|
+
| HOUR_OPTIONS
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if self.model.dom not in self.dom_select.options:
|
|
182
|
+
self.dom_select.set_options(
|
|
183
|
+
{self.model.dom: self.model.dom}
|
|
184
|
+
| self.dom_select.options
|
|
185
|
+
| DAY_OPTIONS
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if self.model.month not in self.month_select.options:
|
|
189
|
+
self.month_select.set_options(
|
|
190
|
+
{self.model.month: self.model.month}
|
|
191
|
+
| self.month_select.options
|
|
192
|
+
| MONTH_OPTIONS
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if self.model.dow not in self.dow_select.options:
|
|
196
|
+
self.dow_select.set_options(
|
|
197
|
+
{self.model.dow: self.model.dow}
|
|
198
|
+
| self.dow_select.options
|
|
199
|
+
| WEEKDAY_OPTIONS
|
|
200
|
+
)
|
|
201
|
+
except AttributeError:
|
|
202
|
+
# When called before the dialog fully is rendered, some select components
|
|
203
|
+
# may not yet be created.
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
def dialog_layout(self) -> None:
|
|
207
|
+
with self, ui.card():
|
|
208
|
+
ui.label(self.dialog_title).classes("font-bold text-3xl")
|
|
209
|
+
with ui.row().classes("flex w-full"):
|
|
210
|
+
# cron expression display with copy button
|
|
211
|
+
ui.code().classes(
|
|
212
|
+
"text-2xl/8 text-center",
|
|
213
|
+
).bind_content_from(
|
|
214
|
+
target_object=self,
|
|
215
|
+
target_name="model",
|
|
216
|
+
backward=lambda x: str(x),
|
|
217
|
+
).classes("flex-1").mark("cron-expression-display")
|
|
218
|
+
with ui.row().classes("flex w-full"):
|
|
219
|
+
# 5 x dropdown selectors
|
|
220
|
+
self.minute_select = (
|
|
221
|
+
ui.select(
|
|
222
|
+
MINUTE_OPTIONS,
|
|
223
|
+
label="Minute",
|
|
224
|
+
with_input=True,
|
|
225
|
+
new_value_mode="add-unique",
|
|
226
|
+
on_change=self.handle_cron_change,
|
|
227
|
+
)
|
|
228
|
+
.classes("w-1/6 flex-1")
|
|
229
|
+
.bind_value(self, ("model", "minute"))
|
|
230
|
+
.mark(
|
|
231
|
+
"minute",
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
self.hour_select = (
|
|
235
|
+
ui.select(
|
|
236
|
+
HOUR_OPTIONS,
|
|
237
|
+
label="Hour",
|
|
238
|
+
with_input=True,
|
|
239
|
+
new_value_mode="add-unique",
|
|
240
|
+
on_change=self.handle_cron_change,
|
|
241
|
+
)
|
|
242
|
+
.classes("w-1/6 flex-1")
|
|
243
|
+
.bind_value(self, ("model", "hour"))
|
|
244
|
+
.mark(
|
|
245
|
+
"hour",
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
self.dom_select = (
|
|
249
|
+
ui.select(
|
|
250
|
+
DAY_OPTIONS,
|
|
251
|
+
label="Day",
|
|
252
|
+
with_input=True,
|
|
253
|
+
new_value_mode="add-unique",
|
|
254
|
+
on_change=self.handle_cron_change,
|
|
255
|
+
)
|
|
256
|
+
.classes("w-1/6 flex-1")
|
|
257
|
+
.bind_value(self, ("model", "dom"))
|
|
258
|
+
.mark("dom")
|
|
259
|
+
)
|
|
260
|
+
self.month_select = (
|
|
261
|
+
ui.select(
|
|
262
|
+
MONTH_OPTIONS,
|
|
263
|
+
label="Month",
|
|
264
|
+
with_input=True,
|
|
265
|
+
new_value_mode="add-unique",
|
|
266
|
+
on_change=self.handle_cron_change,
|
|
267
|
+
)
|
|
268
|
+
.classes("w-1/6 flex-1")
|
|
269
|
+
.bind_value(self, ("model", "month"))
|
|
270
|
+
.mark(
|
|
271
|
+
"month",
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
self.dow_select = (
|
|
275
|
+
ui.select(
|
|
276
|
+
WEEKDAY_OPTIONS,
|
|
277
|
+
label="Weekday",
|
|
278
|
+
with_input=True,
|
|
279
|
+
new_value_mode="add-unique",
|
|
280
|
+
on_change=self.handle_cron_change,
|
|
281
|
+
)
|
|
282
|
+
.classes("w-1/6 flex-1")
|
|
283
|
+
.bind_value(self, ("model", "dow"))
|
|
284
|
+
.mark("dow")
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
with ui.column().classes("w-full"):
|
|
288
|
+
ui.label("Next 5 Runs").classes("text-sm text-gray-400 font-medium")
|
|
289
|
+
self.show_next_runs()
|
|
290
|
+
|
|
291
|
+
with ui.card_actions().classes("w-full align-left"):
|
|
292
|
+
with ui.row().classes("flex w-full items-end"):
|
|
293
|
+
ui.button(
|
|
294
|
+
"Done",
|
|
295
|
+
color="positive",
|
|
296
|
+
on_click=lambda: self.submit(str(self.model)),
|
|
297
|
+
).bind_enabled_from(self, ("model", "is_valid")).mark("done")
|
|
298
|
+
ui.button(
|
|
299
|
+
"Cancel",
|
|
300
|
+
color="negative",
|
|
301
|
+
on_click=lambda: self.submit(None),
|
|
302
|
+
).mark("cancel")
|
|
303
|
+
ui.space()
|
|
304
|
+
ui.label("Reset").classes(
|
|
305
|
+
"text-sm text-negative font-medium cursor-pointer"
|
|
306
|
+
).on("click", self.reset).tooltip(
|
|
307
|
+
f"{self.initial_expression}"
|
|
308
|
+
).mark("reset")
|
|
309
|
+
|
|
310
|
+
def handle_cron_change(self, event: ValueChangeEventArguments) -> None:
|
|
311
|
+
"""Callback handler from cron expression selectors"""
|
|
312
|
+
try:
|
|
313
|
+
self.model.cron = CronStr(str(self.model))
|
|
314
|
+
self.model.is_valid = True
|
|
315
|
+
self.show_next_runs.refresh()
|
|
316
|
+
except (ValueError, ValidationError) as e:
|
|
317
|
+
self.model.is_valid = False
|
|
318
|
+
ui.notify(e, type="negative")
|
|
319
|
+
|
|
320
|
+
@ui.refreshable_method
|
|
321
|
+
def show_next_runs(self) -> None:
|
|
322
|
+
"""Refreshable method to display the next five events according to the
|
|
323
|
+
current cron schedule.
|
|
324
|
+
"""
|
|
325
|
+
now = datetime.now(tz=UTC)
|
|
326
|
+
for i, run in enumerate(islice(self.model.cron.schedule(), 5)):
|
|
327
|
+
until_run = run - now
|
|
328
|
+
with ui.row().classes("w-full items-center"):
|
|
329
|
+
ui.label(str(i + 1)).classes(
|
|
330
|
+
"text-xs bg-accent text-white px-2 py-1 rounded",
|
|
331
|
+
)
|
|
332
|
+
ui.label(f"{run:%a, %d-%b %Y at %I:%M %p %z}")
|
|
333
|
+
ui.space()
|
|
334
|
+
ui.label(
|
|
335
|
+
f"in {until_run.days} days, {until_run.seconds // 3600} hours",
|
|
336
|
+
).classes("text-sm text-gray-400")
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import UTC, date, datetime, time, tzinfo
|
|
6
|
+
from enum import IntFlag, auto
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
9
|
+
|
|
10
|
+
from nicegui import ui
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .types import ValueChangeEventArgumentsBool, ValueChangeEventArgumentsStr
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DialogOptions(IntFlag):
|
|
17
|
+
"""Dialog option flags."""
|
|
18
|
+
|
|
19
|
+
DISPLAY_12H = auto()
|
|
20
|
+
"""Whether to display time in 12h format. This option does not affect the returned
|
|
21
|
+
datetime value, which is always timezone-aware in UTC.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
FREEZE_TZ = auto()
|
|
25
|
+
"""Whether to disallow changes to the timezone input. The timezone input may be
|
|
26
|
+
visible but not editable.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
HIDE_TZ = auto()
|
|
30
|
+
"""Whether to hide the timezone input. The timezone input is shown by default."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DatetimePickerModel:
|
|
35
|
+
"""Data model for the datetime picker dialog.
|
|
36
|
+
|
|
37
|
+
Private (`init=False`) fields are used for binding with the dialog input components.
|
|
38
|
+
|
|
39
|
+
The dialog's callbacks will manage the model's fields, and the `dt` property will
|
|
40
|
+
always return a timezone-aware datetime in UTC.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
ts: float
|
|
44
|
+
tz: tzinfo = UTC
|
|
45
|
+
options: DialogOptions = field(default_factory=lambda: DialogOptions(0))
|
|
46
|
+
_date: str = field(init=False, default="")
|
|
47
|
+
_time: str = field(init=False, default="")
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def dt(self) -> datetime:
|
|
51
|
+
"""Returns a timezone-aware datetime in UTC."""
|
|
52
|
+
return datetime.fromtimestamp(self.ts, tz=UTC)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def localdt(self) -> datetime:
|
|
56
|
+
"""Returns a timezone-aware datetime in the specified timezone."""
|
|
57
|
+
return datetime.fromtimestamp(self.ts, tz=self.tz)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DatetimePickerDialog(ui.dialog):
|
|
61
|
+
"""NiceGUI Dialog displaying a Datetime Picker.
|
|
62
|
+
|
|
63
|
+
Two action buttons are available: "Done" returns the selected datetime;
|
|
64
|
+
"Cancel" returns None.
|
|
65
|
+
|
|
66
|
+
The user may select any timezone they wish when using the dialog, but the returned
|
|
67
|
+
value is always timezone-aware in UTC.
|
|
68
|
+
|
|
69
|
+
Notes
|
|
70
|
+
-----
|
|
71
|
+
- The timezone input is a freeform input field, but we check that its value is
|
|
72
|
+
matched to a known timezone before updating the model.
|
|
73
|
+
- The model stores the datetime as a UTC POSIX timestamp.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
dialog_title: str
|
|
77
|
+
model: DatetimePickerModel
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
initial_ts: int | float | None = None,
|
|
82
|
+
*,
|
|
83
|
+
dialog_title: str = "Datetime Picker",
|
|
84
|
+
hide_timezone: bool = False,
|
|
85
|
+
freeze_timezone: bool = False,
|
|
86
|
+
) -> None:
|
|
87
|
+
super().__init__()
|
|
88
|
+
self.dialog_title = dialog_title
|
|
89
|
+
if initial_ts is None:
|
|
90
|
+
initial_ts = datetime.now(UTC).timestamp()
|
|
91
|
+
self.model = DatetimePickerModel(ts=initial_ts, tz=UTC)
|
|
92
|
+
|
|
93
|
+
if hide_timezone:
|
|
94
|
+
self.model.options ^= DialogOptions.HIDE_TZ
|
|
95
|
+
if freeze_timezone:
|
|
96
|
+
self.model.options ^= DialogOptions.FREEZE_TZ
|
|
97
|
+
|
|
98
|
+
self.dialog_layout()
|
|
99
|
+
|
|
100
|
+
if TYPE_CHECKING:
|
|
101
|
+
|
|
102
|
+
def __await__(self) -> Generator[None, None, datetime | None]: ...
|
|
103
|
+
|
|
104
|
+
def dialog_layout(self) -> None:
|
|
105
|
+
with self, ui.card():
|
|
106
|
+
ui.label(self.dialog_title).classes("font-bold text-3xl")
|
|
107
|
+
with ui.row().classes("flex w-full"):
|
|
108
|
+
ui.date_input("Date").classes("flex-1 w-2/5").bind_value(
|
|
109
|
+
self,
|
|
110
|
+
("model", "_date"),
|
|
111
|
+
strict=False,
|
|
112
|
+
).on_value_change(self.handle_datetime_change).mark("date")
|
|
113
|
+
|
|
114
|
+
ui.time_input("Time").classes("flex-1 w-2/5").bind_value(
|
|
115
|
+
self,
|
|
116
|
+
("model", "_time"),
|
|
117
|
+
strict=False,
|
|
118
|
+
).on_value_change(self.handle_datetime_change).mark("time")
|
|
119
|
+
|
|
120
|
+
# The tz input is not bound the model; instead, updates are
|
|
121
|
+
# handled through the validation callback
|
|
122
|
+
self.tz_input = (
|
|
123
|
+
ui.input(
|
|
124
|
+
"Timezone",
|
|
125
|
+
value=str(self.model.tz),
|
|
126
|
+
)
|
|
127
|
+
.classes("flex-1 w-1/5")
|
|
128
|
+
.props("debounce=1000")
|
|
129
|
+
.bind_enabled_from(
|
|
130
|
+
self,
|
|
131
|
+
("model", "options"),
|
|
132
|
+
backward=lambda o: not o & DialogOptions.FREEZE_TZ,
|
|
133
|
+
)
|
|
134
|
+
.bind_visibility_from(
|
|
135
|
+
self,
|
|
136
|
+
("model", "options"),
|
|
137
|
+
backward=lambda o: not o & DialogOptions.HIDE_TZ,
|
|
138
|
+
)
|
|
139
|
+
.mark("timezone")
|
|
140
|
+
)
|
|
141
|
+
self.tz_input.validation = self.validate_timezone
|
|
142
|
+
|
|
143
|
+
with ui.card_actions().classes("w-full align-left"):
|
|
144
|
+
with ui.row().classes("flex w-full"):
|
|
145
|
+
ui.button(
|
|
146
|
+
"Done",
|
|
147
|
+
color="positive",
|
|
148
|
+
on_click=lambda: self.submit(self.model.dt),
|
|
149
|
+
).mark(
|
|
150
|
+
"done",
|
|
151
|
+
)
|
|
152
|
+
ui.button(
|
|
153
|
+
"Cancel",
|
|
154
|
+
color="negative",
|
|
155
|
+
on_click=lambda: self.submit(None),
|
|
156
|
+
).mark("cancel")
|
|
157
|
+
ui.space()
|
|
158
|
+
with ui.dropdown_button("Options", color="accent"):
|
|
159
|
+
ui.switch(text="24h").on_value_change(
|
|
160
|
+
self.handle_24h_option_change,
|
|
161
|
+
).bind_value_from(
|
|
162
|
+
self,
|
|
163
|
+
("model", "options"),
|
|
164
|
+
backward=lambda o: not o & DialogOptions.DISPLAY_12H,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def handle_24h_option_change(self, e: ValueChangeEventArgumentsBool) -> None:
|
|
168
|
+
"""Callback handler for 24h display option toggle."""
|
|
169
|
+
# Instead of an XOR toggle, we are explicit in setting or clearing the
|
|
170
|
+
# flag in order to keep the model consistent with the state of the UI.
|
|
171
|
+
if e.value:
|
|
172
|
+
# Clear the 12h display option flag
|
|
173
|
+
self.model.options &= ~DialogOptions.DISPLAY_12H
|
|
174
|
+
else:
|
|
175
|
+
# Set the 12h display option flag
|
|
176
|
+
self.model.options |= DialogOptions.DISPLAY_12H
|
|
177
|
+
|
|
178
|
+
# propagate the option change to the model's string fields
|
|
179
|
+
self.tz_input.validate()
|
|
180
|
+
|
|
181
|
+
def handle_datetime_change(
|
|
182
|
+
self,
|
|
183
|
+
e: ValueChangeEventArgumentsStr | None = None,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Callback handler from datetime selectors.
|
|
186
|
+
|
|
187
|
+
The model's timestamp is updated based on the date and time input values in UTC.
|
|
188
|
+
"""
|
|
189
|
+
# the date and time inputs are bound to the model
|
|
190
|
+
try:
|
|
191
|
+
d = date.fromisoformat(self.model._date)
|
|
192
|
+
t = time.fromisoformat(self.model._time)
|
|
193
|
+
except (TypeError, ValueError):
|
|
194
|
+
# case if either input is not yet populated
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
# update the model timestamp based on the date and time input values
|
|
198
|
+
local_dt = datetime.combine(d, t, tzinfo=self.model.tz)
|
|
199
|
+
self.model.ts = local_dt.astimezone(UTC).timestamp()
|
|
200
|
+
|
|
201
|
+
def validate_timezone(self, value: str) -> str | None:
|
|
202
|
+
"""Validation and update handler for timezone input.
|
|
203
|
+
|
|
204
|
+
The timezone input is a freeform input field, but we check that its
|
|
205
|
+
value is matched to a known timezone before updating the model.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
# validate and update the model
|
|
209
|
+
self.model.tz = ZoneInfo(value)
|
|
210
|
+
|
|
211
|
+
time_format = (
|
|
212
|
+
"%I:%M %p"
|
|
213
|
+
if self.model.options & DialogOptions.DISPLAY_12H
|
|
214
|
+
else "%H:%M"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# propagate the timezone change to the model's string fields
|
|
218
|
+
# these bound properties will update the UI inputs
|
|
219
|
+
self.model._date = f"{self.model.localdt.date()}"
|
|
220
|
+
self.model._time = self.model.localdt.timetz().strftime(time_format)
|
|
221
|
+
except ZoneInfoNotFoundError as e:
|
|
222
|
+
return f"Invalid timezone: {e}"
|
|
223
|
+
except Exception as e:
|
|
224
|
+
return f"Error processing timezone: {e}"
|
|
225
|
+
|
|
226
|
+
return None
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum, EnumType, IntFlag, StrEnum, auto
|
|
6
|
+
from functools import partial
|
|
7
|
+
from importlib.util import find_spec
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal, get_args, get_origin
|
|
9
|
+
|
|
10
|
+
from nicegui import ui
|
|
11
|
+
|
|
12
|
+
# SortableJS support is added in NiceGUI 3.11
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
if find_spec("nicegui.elements.sortable") is not None:
|
|
15
|
+
from nicegui.events import ( # type: ignore[attr-defined, unused-ignore]
|
|
16
|
+
SortableEventArguments,
|
|
17
|
+
)
|
|
18
|
+
else:
|
|
19
|
+
type SortableEventArguments = Any # type: ignore[no-redef]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
SORTABLE_AVAILABLE = find_spec("nicegui.elements.sortable") is not None
|
|
23
|
+
"""Module-level constant for whether the sortable component is available, which
|
|
24
|
+
dependson NiceGUI >= 3.11.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DialogOptions(IntFlag):
|
|
29
|
+
"""Dialog option flags."""
|
|
30
|
+
|
|
31
|
+
ALLOW_SORTABLE = auto()
|
|
32
|
+
"""Whether the label list can be sorted by dragging and dropping."""
|
|
33
|
+
|
|
34
|
+
USE_DEFAULT_VALIDATOR = auto()
|
|
35
|
+
"""Whether to use the default validator, which checks that fields are not empty."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class LabelMakerModel:
|
|
40
|
+
"""Data model for the label maker dialog."""
|
|
41
|
+
|
|
42
|
+
num_inputs: int = 2
|
|
43
|
+
inputs: list[ui.input | ui.select] = field(default_factory=list)
|
|
44
|
+
input_labels: list[str] = field(default_factory=list)
|
|
45
|
+
values: list[tuple[str, ...]] = field(default_factory=list)
|
|
46
|
+
validators: list[dict[str, Any]] = field(default_factory=list)
|
|
47
|
+
value_types: list[type | StrEnum | list[str]] = field(default_factory=list)
|
|
48
|
+
options: DialogOptions = field(default_factory=lambda: DialogOptions(0))
|
|
49
|
+
default_validator: dict[str, Any] = field(init=False, default_factory=dict)
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
"""For fields that are expected to be the same length as the number of
|
|
53
|
+
inputs, fill in any missing values with defaults.
|
|
54
|
+
"""
|
|
55
|
+
if len(self.input_labels) < self.num_inputs:
|
|
56
|
+
# fill in any missing input labels with empty label
|
|
57
|
+
for _ in range(len(self.input_labels), self.num_inputs):
|
|
58
|
+
self.input_labels.append("")
|
|
59
|
+
if len(self.value_types) < self.num_inputs:
|
|
60
|
+
# fill in any missing value types with str
|
|
61
|
+
for _ in range(len(self.value_types), self.num_inputs):
|
|
62
|
+
self.value_types.append(str)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def all_valid(self) -> bool:
|
|
66
|
+
"""Returns whether all input fields currently have valid values."""
|
|
67
|
+
return all([_input.validate() for _input in self.inputs])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class LabelMakerDialog(ui.dialog):
|
|
71
|
+
"""The labelmaker dialog is an interface for applying label-based metadata
|
|
72
|
+
to an arbitrary object. The default type of "label" is a key-value pair, but
|
|
73
|
+
the dialog can be initialized with any number of input fields, and the return
|
|
74
|
+
value is a list of tuples. This leaves the formatting of the labels up to the
|
|
75
|
+
caller.
|
|
76
|
+
|
|
77
|
+
Labels are displayed in a list, can be reordered by dragging and dropping, and
|
|
78
|
+
can be removed by clicking a delete button.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
dialog_title: str
|
|
82
|
+
model: LabelMakerModel
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
dialog_title: str = "List Editor",
|
|
87
|
+
*,
|
|
88
|
+
allow_sorting: bool = True,
|
|
89
|
+
use_default_validator: bool = True,
|
|
90
|
+
**kwargs: Any,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Dialog class for creating and editing sets of labels.
|
|
93
|
+
|
|
94
|
+
Other than `dialog_title`, all parameters are passed to the `LabelMakerModel`
|
|
95
|
+
dataclass, including seed values for the label list and input field validators.
|
|
96
|
+
|
|
97
|
+
By default, the input fields are simple text inputs for string values,
|
|
98
|
+
but if the `value_types` parameter is set to a list of types and the type
|
|
99
|
+
at position *n* is a `Literal`, the input at position *n* will be a select
|
|
100
|
+
component with options generated from the matching `Literal`. Likewise,
|
|
101
|
+
if this type is an `Enum`, then the input will be a select with options
|
|
102
|
+
generated from the enum names (or values, in the case of a `StrEnum`).
|
|
103
|
+
Finally if the type is a list of strings, these will be used as options
|
|
104
|
+
for a select input.
|
|
105
|
+
|
|
106
|
+
Once populated, the dialog handles all values as strings, and results
|
|
107
|
+
are not cast back to the original types.
|
|
108
|
+
"""
|
|
109
|
+
super().__init__()
|
|
110
|
+
self.dialog_title = dialog_title
|
|
111
|
+
self.model = LabelMakerModel(**kwargs)
|
|
112
|
+
|
|
113
|
+
if SORTABLE_AVAILABLE and allow_sorting:
|
|
114
|
+
self.model.options |= DialogOptions.ALLOW_SORTABLE
|
|
115
|
+
|
|
116
|
+
if use_default_validator:
|
|
117
|
+
self.model.options |= DialogOptions.USE_DEFAULT_VALIDATOR
|
|
118
|
+
self.model.default_validator["Input must not be empty"] = lambda v: (
|
|
119
|
+
len(v) > 0 if v is not None else False
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
self.dialog_layout()
|
|
123
|
+
|
|
124
|
+
if TYPE_CHECKING:
|
|
125
|
+
|
|
126
|
+
def __await__(self) -> Generator[None, None, list[tuple[str, ...]] | None]: ...
|
|
127
|
+
|
|
128
|
+
def dialog_layout(self) -> None:
|
|
129
|
+
with self, ui.card().classes("w-[96vw] max-w-[96vw]"):
|
|
130
|
+
ui.label(self.dialog_title).classes("font-bold text-3xl")
|
|
131
|
+
|
|
132
|
+
with ui.row().classes("flex w-full items-start"):
|
|
133
|
+
# an input or select field for each label component
|
|
134
|
+
for i in range(0, self.model.num_inputs):
|
|
135
|
+
input_element: ui.input | ui.select | None = None
|
|
136
|
+
value_type = self.model.value_types[i]
|
|
137
|
+
input_label = self.model.input_labels[i]
|
|
138
|
+
|
|
139
|
+
if get_origin(value_type) is Literal:
|
|
140
|
+
input_element = ui.select(
|
|
141
|
+
options=list(get_args(value_type)),
|
|
142
|
+
)
|
|
143
|
+
elif isinstance(value_type, EnumType) and issubclass(
|
|
144
|
+
value_type, StrEnum
|
|
145
|
+
):
|
|
146
|
+
input_element = ui.select(
|
|
147
|
+
options=[option for option in value_type],
|
|
148
|
+
)
|
|
149
|
+
elif isinstance(value_type, EnumType) and issubclass(
|
|
150
|
+
value_type, Enum
|
|
151
|
+
):
|
|
152
|
+
input_element = ui.select(
|
|
153
|
+
options=[option.name for option in value_type],
|
|
154
|
+
)
|
|
155
|
+
elif isinstance(value_type, list):
|
|
156
|
+
input_element = ui.select(options=value_type)
|
|
157
|
+
else:
|
|
158
|
+
# All other cases result in an ordinary text input
|
|
159
|
+
input_element = ui.input()
|
|
160
|
+
|
|
161
|
+
if TYPE_CHECKING:
|
|
162
|
+
assert input_element is not None
|
|
163
|
+
|
|
164
|
+
input_element.classes(add="flex-1").props("clearable").set_label(
|
|
165
|
+
input_label
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Create a non-mutating copy of the provided validator for
|
|
169
|
+
# the input and optionally apply the default validators.
|
|
170
|
+
validators: dict[str, Any]
|
|
171
|
+
try:
|
|
172
|
+
validators = {**self.model.validators[i]}
|
|
173
|
+
except IndexError:
|
|
174
|
+
validators = {}
|
|
175
|
+
|
|
176
|
+
if DialogOptions.USE_DEFAULT_VALIDATOR in self.model.options:
|
|
177
|
+
validators |= self.model.default_validator
|
|
178
|
+
|
|
179
|
+
input_element.validation = validators
|
|
180
|
+
|
|
181
|
+
input_element.mark(f"input_{i}")
|
|
182
|
+
self.model.inputs.append(input_element)
|
|
183
|
+
|
|
184
|
+
ui.button(
|
|
185
|
+
icon="add", on_click=self.handle_add_label, color="accent"
|
|
186
|
+
).props("fab-mini").tooltip("Add").bind_enabled_from(
|
|
187
|
+
self, ("model", "all_valid")
|
|
188
|
+
).mark("add")
|
|
189
|
+
|
|
190
|
+
ui.separator()
|
|
191
|
+
|
|
192
|
+
with ui.column().classes("w-full overflow-y-auto") as self.labels_column:
|
|
193
|
+
self.labels_container()
|
|
194
|
+
|
|
195
|
+
ui.separator()
|
|
196
|
+
|
|
197
|
+
with ui.card_actions().classes("w-full"):
|
|
198
|
+
ui.button(
|
|
199
|
+
"Submit",
|
|
200
|
+
on_click=lambda: self.submit(self.model.values),
|
|
201
|
+
color="positive",
|
|
202
|
+
).mark("submit")
|
|
203
|
+
ui.button(
|
|
204
|
+
"Cancel", on_click=lambda: self.submit(None), color="negative"
|
|
205
|
+
).mark("cancel")
|
|
206
|
+
|
|
207
|
+
async def handle_add_label(self) -> None:
|
|
208
|
+
label_value_tuple = tuple(str(_input.value) for _input in self.model.inputs)
|
|
209
|
+
self.model.values.append(label_value_tuple)
|
|
210
|
+
for _input in self.model.inputs:
|
|
211
|
+
_input.value = ""
|
|
212
|
+
self.labels_container.refresh()
|
|
213
|
+
|
|
214
|
+
@ui.refreshable_method
|
|
215
|
+
def labels_container(self) -> None:
|
|
216
|
+
"""Creates the content of the label list container. Each list element
|
|
217
|
+
has a delete button.
|
|
218
|
+
"""
|
|
219
|
+
with ui.list().classes("w-full") as list_container:
|
|
220
|
+
for i, label_tuple in enumerate(self.model.values):
|
|
221
|
+
delete_callback = partial(self.handle_delete_label, i)
|
|
222
|
+
click_callback = partial(self.handle_item_click, i)
|
|
223
|
+
with ui.item().classes("w-full").mark(f"label_{i}"):
|
|
224
|
+
with ui.item_section().props("side"):
|
|
225
|
+
ui.icon("drag_indicator").classes(
|
|
226
|
+
"handle cursor-grab active:cursor-grabbing"
|
|
227
|
+
).bind_visibility_from(
|
|
228
|
+
self,
|
|
229
|
+
("model", "options"),
|
|
230
|
+
backward=lambda o: DialogOptions.ALLOW_SORTABLE in o,
|
|
231
|
+
)
|
|
232
|
+
with ui.item_section().props():
|
|
233
|
+
ui.chip(
|
|
234
|
+
" : ".join(label_tuple), on_click=click_callback
|
|
235
|
+
).classes("w-full flex-1 secondary text-white")
|
|
236
|
+
with ui.item_section().props("side"):
|
|
237
|
+
ui.icon(
|
|
238
|
+
name="delete",
|
|
239
|
+
color="warning",
|
|
240
|
+
).on("click", delete_callback).props("flat").tooltip(
|
|
241
|
+
"Delete"
|
|
242
|
+
).mark("delete")
|
|
243
|
+
|
|
244
|
+
# We only make the list sortable if the option is enabled, which is
|
|
245
|
+
# possible only when nicegui supports it, but for type checking
|
|
246
|
+
# we also assert that the make_sortable method is present anyway
|
|
247
|
+
if TYPE_CHECKING:
|
|
248
|
+
assert hasattr(list_container, "make_sortable")
|
|
249
|
+
|
|
250
|
+
if DialogOptions.ALLOW_SORTABLE in self.model.options:
|
|
251
|
+
list_container.make_sortable(handle=".handle", on_end=self.handle_sortable)
|
|
252
|
+
|
|
253
|
+
def handle_delete_label(self, index: int) -> None:
|
|
254
|
+
"""Removes the label set at the specified index from the model."""
|
|
255
|
+
self.model.values.pop(index)
|
|
256
|
+
self.labels_container.refresh()
|
|
257
|
+
|
|
258
|
+
def handle_item_click(self, index: int) -> None:
|
|
259
|
+
"""Repopulates the input fields with the values of the clicked label set."""
|
|
260
|
+
try:
|
|
261
|
+
for i, _input in enumerate(self.model.inputs):
|
|
262
|
+
_input.value = self.model.values[index][i]
|
|
263
|
+
except IndexError:
|
|
264
|
+
ui.notify(
|
|
265
|
+
"An error occurred while updating the input fields.", color="negative"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def handle_sortable(self, e: SortableEventArguments) -> None:
|
|
269
|
+
"""Updates the model when the label list is sorted on the client side."""
|
|
270
|
+
# If the event doesn't support the necessary attributes, make this a no-op.
|
|
271
|
+
if not hasattr(e, "old_index") or not hasattr(e, "new_index"):
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
shifting_item = self.model.values.pop(e.old_index)
|
|
276
|
+
self.model.values.insert(e.new_index, shifting_item)
|
|
277
|
+
except IndexError:
|
|
278
|
+
ui.notify("An error occurred while sorting the list.", color="negative")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Module for generic NiceGUI event argument types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
import nicegui.events
|
|
9
|
+
|
|
10
|
+
type ValueChangeEventArguments = nicegui.events.ValueChangeEventArguments[Any]
|
|
11
|
+
"""A generic ValueChangeEventArguments for indeterminate or dynamic value
|
|
12
|
+
change events"""
|
|
13
|
+
|
|
14
|
+
type ValueChangeEventArgumentsBool = nicegui.events.ValueChangeEventArguments[
|
|
15
|
+
bool | None
|
|
16
|
+
]
|
|
17
|
+
"""A ValueChangeEventArguments for events that use a boolean value when a
|
|
18
|
+
value is present."""
|
|
19
|
+
|
|
20
|
+
type ValueChangeEventArgumentsStr = nicegui.events.ValueChangeEventArguments[
|
|
21
|
+
str | None
|
|
22
|
+
]
|
|
23
|
+
"""A ValueChangeEventArguments for events that use a string value when a
|
|
24
|
+
value is present."""
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ValueChangeEventArguments",
|
|
28
|
+
"ValueChangeEventArgumentsBool",
|
|
29
|
+
"ValueChangeEventArgumentsStr",
|
|
30
|
+
]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Dialog for managing the upload of a single file.
|
|
2
|
+
|
|
3
|
+
This dialog is designed to be simple and straightforward for the upload of one
|
|
4
|
+
file at a time to a NiceGUI application. For more robust uploading needs,
|
|
5
|
+
especially the handling of larger files, consult the NiceGUI docuementation for
|
|
6
|
+
the `ui.upload` component, particularly the advice regarding adjusting the
|
|
7
|
+
Starlette Multiparser's `spool_max_size`.
|
|
8
|
+
|
|
9
|
+
The default spool size of 1MiB is sufficient for many use cases, especially for
|
|
10
|
+
the small files this dialog supports in its default configuration. With this
|
|
11
|
+
limit, the NiceGUI uploader uses buffer during the upload but then passes the
|
|
12
|
+
actual bytes of the uploaded file to the `on_upload` callback. If the file
|
|
13
|
+
is larger than Starlette's spool size, the upload is spilled to a temp file
|
|
14
|
+
instead.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Generator
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from nicegui import ui
|
|
24
|
+
from nicegui.elements.upload_files import FileUpload
|
|
25
|
+
from nicegui.events import UiEventArguments, UploadEventArguments
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class UploadFileModel:
|
|
30
|
+
"""Data model for the upload file dialog.
|
|
31
|
+
|
|
32
|
+
Attributes
|
|
33
|
+
----------
|
|
34
|
+
max_file_size: int
|
|
35
|
+
The maximum allowed file size in bytes. This defaults to 1 MiB.
|
|
36
|
+
|
|
37
|
+
allowed_file_types: list[str]
|
|
38
|
+
A list of allowed file types for upload. These file types are not
|
|
39
|
+
documented by NiceGUI but are passed directly to the `accept` property
|
|
40
|
+
of the underlying Quasar `<q-uploader>` component.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
allowed_file_types: list[str] = field(default_factory=list)
|
|
44
|
+
max_file_size: int = 1_048_576 # 1 MiB
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UploadFileDialog(ui.dialog):
|
|
48
|
+
"""NiceGUI Dialog for uploading a single file.
|
|
49
|
+
|
|
50
|
+
With a maximum file size of around 1MB, this dialog is intended for small
|
|
51
|
+
files that will fit in RAM (within the default spool size of the underlying
|
|
52
|
+
Starlette MultipartParser).
|
|
53
|
+
|
|
54
|
+
When a file is successfully uploaded, the dialog automatically closes and
|
|
55
|
+
returns the FileUpload object. A "Cancel" button allows users to dismiss the
|
|
56
|
+
dialog without uploading, returning None.
|
|
57
|
+
|
|
58
|
+
Params
|
|
59
|
+
------
|
|
60
|
+
dialog_title: str
|
|
61
|
+
The title displayed at the top of the dialog.
|
|
62
|
+
|
|
63
|
+
allowed_file_types: list[str]
|
|
64
|
+
A list of allowed file types for upload, e.g. `[".jpg", ".png"]` to only
|
|
65
|
+
allow JPEG and PNG images, or `["image/*"]` to allow all image types.
|
|
66
|
+
See NiceGUI's `ui.upload` component for more details.
|
|
67
|
+
|
|
68
|
+
max_file_size: int
|
|
69
|
+
The maximum allowed file size in bytes. Defaults to 1,000,000 bytes (1 MB).
|
|
70
|
+
|
|
71
|
+
Returns
|
|
72
|
+
-------
|
|
73
|
+
nicegui.elements.upload_files.FileUpload | None
|
|
74
|
+
The uploaded file object when the upload completes, or `None` if the user
|
|
75
|
+
clicks "Cancel".
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
dialog_title: str = "Single File Upload",
|
|
82
|
+
allowed_file_types: list[str] = [".*"],
|
|
83
|
+
max_file_size: int = 1_000_000,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Initialize the dialog and its components."""
|
|
86
|
+
super().__init__()
|
|
87
|
+
self.dialog_title = dialog_title
|
|
88
|
+
self.model = UploadFileModel(
|
|
89
|
+
allowed_file_types=allowed_file_types,
|
|
90
|
+
max_file_size=max_file_size,
|
|
91
|
+
)
|
|
92
|
+
self.dialog_layout()
|
|
93
|
+
|
|
94
|
+
if TYPE_CHECKING:
|
|
95
|
+
|
|
96
|
+
def __await__(self) -> Generator[None, None, FileUpload | None]: ...
|
|
97
|
+
|
|
98
|
+
def dialog_layout(self) -> None:
|
|
99
|
+
with self, ui.card():
|
|
100
|
+
ui.label(self.dialog_title).classes("font-bold text-3xl")
|
|
101
|
+
with ui.row().classes("flex w-full"):
|
|
102
|
+
self.uploader = (
|
|
103
|
+
ui.upload(
|
|
104
|
+
label="Upload File",
|
|
105
|
+
on_begin_upload=self.handle_begin_upload,
|
|
106
|
+
on_upload=self.handle_file_upload,
|
|
107
|
+
on_rejected=self.handle_rejected_upload,
|
|
108
|
+
max_file_size=self.model.max_file_size,
|
|
109
|
+
max_files=1,
|
|
110
|
+
)
|
|
111
|
+
.classes("flex-1")
|
|
112
|
+
.props(f'accept="{",".join(self.model.allowed_file_types)}"')
|
|
113
|
+
.mark("file_upload")
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
with ui.card_actions().classes("w-full align-left"):
|
|
117
|
+
with ui.row().classes("flex w-full"):
|
|
118
|
+
ui.button(
|
|
119
|
+
"Cancel",
|
|
120
|
+
color="negative",
|
|
121
|
+
on_click=lambda: self.submit(None),
|
|
122
|
+
).mark("cancel")
|
|
123
|
+
|
|
124
|
+
async def handle_begin_upload(self, e: UiEventArguments) -> None:
|
|
125
|
+
"""Callback handler for the beginning of a file upload."""
|
|
126
|
+
...
|
|
127
|
+
|
|
128
|
+
async def handle_file_upload(self, e: UploadEventArguments) -> None:
|
|
129
|
+
"""Callback handler for successful file upload.
|
|
130
|
+
|
|
131
|
+
Displays a notification with the file size and immediately submits the
|
|
132
|
+
dialog with the uploaded file object.
|
|
133
|
+
"""
|
|
134
|
+
size_k = e.file.size() / 1024
|
|
135
|
+
ui.notify(
|
|
136
|
+
f"Upload complete: {e.file.name} ({size_k:.1f} KiB)", color="positive"
|
|
137
|
+
)
|
|
138
|
+
self.submit(e.file)
|
|
139
|
+
|
|
140
|
+
async def handle_rejected_upload(self, e: UiEventArguments) -> None:
|
|
141
|
+
"""Callback handler for rejected file upload (e.g. due to file type or size
|
|
142
|
+
restrictions).
|
|
143
|
+
"""
|
|
144
|
+
ui.notify(
|
|
145
|
+
"File upload rejected. Please ensure the file meets the type and size "
|
|
146
|
+
"requirements.",
|
|
147
|
+
color="negative",
|
|
148
|
+
)
|
nice_dialogs/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nice-dialogs
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: Package containing reusable general-purpose NiceGUI dialogs
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Internet
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.13
|
|
19
|
+
Requires-Dist: nicegui<4,>=3.11
|
|
20
|
+
Requires-Dist: pydantic-extra-types[cron]>=2.10.6
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Nice Dialogs
|
|
24
|
+
|
|
25
|
+
[](https://github.com/astral-sh/uv)
|
|
26
|
+
[](https://github.com/astral-sh/ruff)
|
|
27
|
+
[](https://github.com/j178/prek)
|
|
28
|
+
|
|
29
|
+
A collection of opinionated [NiceGUI](https://nicegui.io) dialogs.
|
|
30
|
+
|
|
31
|
+
## Dialogs
|
|
32
|
+
|
|
33
|
+
### Cron Expression Editor
|
|
34
|
+
|
|
35
|
+
Using `cron_converter` via `CronStr` from `pydantic-extra-types`, this dialog allows one to build a cron expression by populating the cron schedule parts from selectors.
|
|
36
|
+
|
|
37
|
+
### Datetime Picker
|
|
38
|
+
|
|
39
|
+
A dialog, on which one may pick an arbitrary date and time using the appropriate NiceGUI picker elements.
|
|
40
|
+
A timezone field is optional and allows the user to set an IANA timezone while using the dialog.
|
|
41
|
+
The returned datetime object is always timezone-aware and converted to UTC.
|
|
42
|
+
|
|
43
|
+
### File Uploader
|
|
44
|
+
|
|
45
|
+
A simple single-file uploader component dialog with options for restricting file types and max size.
|
|
46
|
+
The dialog returns a NiceGUI `FileUpload` object for the consumer to dereference after the dialog returns.
|
|
47
|
+
|
|
48
|
+
## Requirements
|
|
49
|
+
|
|
50
|
+
- uv. This project is managed by [uv](https://astral.sh/uv)
|
|
51
|
+
- Python >= 3.13[^1].
|
|
52
|
+
- NiceGUI >= 3.11[^2].
|
|
53
|
+
|
|
54
|
+
## Examples
|
|
55
|
+
|
|
56
|
+
A NiceGUI application can be started to demonstrate the dialogs by running `make examples`.
|
|
57
|
+
Note this uses NiceGUI's native mode by default. You may set the `NATIVE=0` environment variable to disable native mode.
|
|
58
|
+
The example application is also used by the NiceGUI `User` test fixture.
|
|
59
|
+
|
|
60
|
+
## Development Setup
|
|
61
|
+
|
|
62
|
+
To prepare for development, run `make init`.
|
|
63
|
+
The contents of the `.vscode/` directory may be useful for IDE-based testing and debugging.
|
|
64
|
+
|
|
65
|
+
## Usage in Projects
|
|
66
|
+
|
|
67
|
+
To use these dialogs in a NiceGUI application, set a dependency; example for `uv` and `pyproject.toml` shown:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
dependencies = [
|
|
71
|
+
...,
|
|
72
|
+
"nice-dialog",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
[tool.uv.sources]
|
|
76
|
+
nice-dialog = { git = "https://github.com/tcjennings/nice-dialogs", rev = "..." }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
> [!TIP]
|
|
80
|
+
> Consider pinning a `git` dependency source to a specific commit revision for maximum security.
|
|
81
|
+
|
|
82
|
+
[^1]: This project uses modern Python syntax, including `datetime` format strings, structural pattern matching, and walrus operators.
|
|
83
|
+
|
|
84
|
+
[^2]: This project uses NiceGUI features introduced in version 3.11 (e.g., nested dictionary binding and type generics) and is not compatible with older versions.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
nice_dialogs/__init__.py,sha256=-0hDPCmjKjiRAJwSdoTTVvQMsNTHPUmzaAMRKa2NsyM,49
|
|
2
|
+
nice_dialogs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
nice_dialogs/dialogs/__init__.py,sha256=XKEkPGHnBWIwNcim8fPgvWcne1QL_c8egN672kUTnp4,397
|
|
4
|
+
nice_dialogs/dialogs/confirmation.py,sha256=yoRf7RNJdmXnEiFh25Lb1j8jgFp77TNhh49Hba0lXCM,4400
|
|
5
|
+
nice_dialogs/dialogs/cron_editor.py,sha256=atA4CGJokVlU10CPU01HTEf79AnXnGS9FeRtms93TMM,12542
|
|
6
|
+
nice_dialogs/dialogs/datetime_picker.py,sha256=EQPTa1CGxJF_141l90Y5JowwH3cYIxEuSLYoaHYLd8w,8267
|
|
7
|
+
nice_dialogs/dialogs/labelmaker.py,sha256=MDKmDGnhORe9FivfLAZf_lnpCA5k1pXR_A2y8i1v2zI,11791
|
|
8
|
+
nice_dialogs/dialogs/types.py,sha256=oC4hV5FSoi9Tm9H15jLsM3gf69D0vS2os8MQMj1QzLk,902
|
|
9
|
+
nice_dialogs/dialogs/upload_file.py,sha256=v_r44S_nZOeeKYr6Fhn0zUd4k1PcWUzPxV6uh4YoIv0,5474
|
|
10
|
+
nice_dialogs-1.3.0.dist-info/METADATA,sha256=2dkuYtB0jgudFTdeQnLJt5i1jlghoZDs2ygCjfH6a7U,3401
|
|
11
|
+
nice_dialogs-1.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
nice_dialogs-1.3.0.dist-info/licenses/LICENSE,sha256=cKcebn3s97ceAXWCbTh2aMmhBVVz-vwV92xVBezRRnk,1070
|
|
13
|
+
nice_dialogs-1.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Toby Jennings
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|