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.
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "1.3.0"
@@ -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
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
26
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
27
+ [![prek](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.