bootstack 0.1.0a1__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.
- bootstack/__init__.py +249 -0
- bootstack/__main__.py +5 -0
- bootstack/api/__init__.py +127 -0
- bootstack/api/app.py +30 -0
- bootstack/api/constants.py +3 -0
- bootstack/api/data.py +23 -0
- bootstack/api/dialogs.py +44 -0
- bootstack/api/i18n.py +17 -0
- bootstack/api/localization.py +16 -0
- bootstack/api/menu.py +7 -0
- bootstack/api/style.py +23 -0
- bootstack/api/utils.py +24 -0
- bootstack/api/widgets.py +137 -0
- bootstack/assets/__init__.py +24 -0
- bootstack/assets/bootstack-transparent.png +0 -0
- bootstack/assets/bootstack.ico +0 -0
- bootstack/assets/bootstack.png +0 -0
- bootstack/assets/elements/__init__.py +0 -0
- bootstack/assets/elements/badge-pill.png +0 -0
- bootstack/assets/elements/badge-square.png +0 -0
- bootstack/assets/elements/border.png +0 -0
- bootstack/assets/elements/button-compact.png +0 -0
- bootstack/assets/elements/button-default.png +0 -0
- bootstack/assets/elements/button-group-horizontal-after-compact.png +0 -0
- bootstack/assets/elements/button-group-horizontal-after-default.png +0 -0
- bootstack/assets/elements/button-group-horizontal-before-compact.png +0 -0
- bootstack/assets/elements/button-group-horizontal-before-default.png +0 -0
- bootstack/assets/elements/button-group-horizontal-center-compact.png +0 -0
- bootstack/assets/elements/button-group-horizontal-center-default.png +0 -0
- bootstack/assets/elements/button-group-vertical-after-compact.png +0 -0
- bootstack/assets/elements/button-group-vertical-after-default.png +0 -0
- bootstack/assets/elements/button-group-vertical-before-compact.png +0 -0
- bootstack/assets/elements/button-group-vertical-before-default.png +0 -0
- bootstack/assets/elements/button-group-vertical-center-compact.png +0 -0
- bootstack/assets/elements/button-group-vertical-center-default.png +0 -0
- bootstack/assets/elements/checkbox-checked.png +0 -0
- bootstack/assets/elements/checkbox-indeterminate.png +0 -0
- bootstack/assets/elements/checkbox-unchecked.png +0 -0
- bootstack/assets/elements/field.png +0 -0
- bootstack/assets/elements/input-after-compact.png +0 -0
- bootstack/assets/elements/input-after-default.png +0 -0
- bootstack/assets/elements/input-before-compact.png +0 -0
- bootstack/assets/elements/input-before-default.png +0 -0
- bootstack/assets/elements/input-compact.png +0 -0
- bootstack/assets/elements/input-default.png +0 -0
- bootstack/assets/elements/list-item-separated.png +0 -0
- bootstack/assets/elements/list-item.png +0 -0
- bootstack/assets/elements/manifest.toml +480 -0
- bootstack/assets/elements/menu-item.png +0 -0
- bootstack/assets/elements/nav-button-compact.png +0 -0
- bootstack/assets/elements/nav-button-default.png +0 -0
- bootstack/assets/elements/nav-icon-button-compact.png +0 -0
- bootstack/assets/elements/nav-icon-button-default.png +0 -0
- bootstack/assets/elements/notebook-client-border.png +0 -0
- bootstack/assets/elements/notebook-tab-active.png +0 -0
- bootstack/assets/elements/notebook-tab-bar.png +0 -0
- bootstack/assets/elements/notebook-tab-normal.png +0 -0
- bootstack/assets/elements/notebook-tab-pill.png +0 -0
- bootstack/assets/elements/progress-bar-horizontal-striped.png +0 -0
- bootstack/assets/elements/progress-bar-solid.png +0 -0
- bootstack/assets/elements/progress-bar-thin.png +0 -0
- bootstack/assets/elements/progress-bar-vertical-striped.png +0 -0
- bootstack/assets/elements/radio-selected.png +0 -0
- bootstack/assets/elements/radio-unselected.png +0 -0
- bootstack/assets/elements/scrollbar-horizontal.png +0 -0
- bootstack/assets/elements/scrollbar-vertical.png +0 -0
- bootstack/assets/elements/slider-handle-focus.png +0 -0
- bootstack/assets/elements/slider-handle.png +0 -0
- bootstack/assets/elements/slider-track-horizontal.png +0 -0
- bootstack/assets/elements/slider-track-vertical.png +0 -0
- bootstack/assets/elements/switch-off.png +0 -0
- bootstack/assets/elements/switch-on.png +0 -0
- bootstack/assets/elements/tabs-bar-horizontal.png +0 -0
- bootstack/assets/elements/tabs-bar-vertical.png +0 -0
- bootstack/assets/elements/tabs-pill.png +0 -0
- bootstack/assets/locales/ar/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ar/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/bg/LC_MESSAGES/bootstack.po +875 -0
- bootstack/assets/locales/cs/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/cs/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/da/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/da/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/de/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/de/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/en/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/en/LC_MESSAGES/bootstack.po +875 -0
- bootstack/assets/locales/es/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/es/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/fr/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/fr/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/he/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/he/LC_MESSAGES/bootstack.po +851 -0
- bootstack/assets/locales/hi/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/hi/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/it/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/it/LC_MESSAGES/bootstack.po +841 -0
- bootstack/assets/locales/ja/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ja/LC_MESSAGES/bootstack.po +914 -0
- bootstack/assets/locales/ko/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ko/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/nb/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/nb/LC_MESSAGES/bootstack.po +841 -0
- bootstack/assets/locales/nl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/nl/LC_MESSAGES/bootstack.po +841 -0
- bootstack/assets/locales/pl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pl/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/pt/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pt/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/sl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/sl/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/sv/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/sv/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/tr/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/tr/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/themes/__init__.py +0 -0
- bootstack/assets/themes/amber-dark.json +32 -0
- bootstack/assets/themes/amber-light.json +32 -0
- bootstack/assets/themes/aurora-dark.json +32 -0
- bootstack/assets/themes/aurora-light.json +32 -0
- bootstack/assets/themes/bootstrap-dark.json +32 -0
- bootstack/assets/themes/bootstrap-light.json +32 -0
- bootstack/assets/themes/classic-dark.json +32 -0
- bootstack/assets/themes/classic-light.json +32 -0
- bootstack/assets/themes/docs-dark.json +32 -0
- bootstack/assets/themes/docs-light.json +32 -0
- bootstack/assets/themes/forest-dark.json +32 -0
- bootstack/assets/themes/forest-light.json +32 -0
- bootstack/assets/themes/ocean-dark.json +32 -0
- bootstack/assets/themes/ocean-light.json +32 -0
- bootstack/assets/themes/rose-dark.json +32 -0
- bootstack/assets/themes/rose-light.json +32 -0
- bootstack/assets/widgets/__init__.py +0 -0
- bootstack/assets/widgets/badge-default.png +0 -0
- bootstack/assets/widgets/badge-pill.png +0 -0
- bootstack/assets/widgets/border.png +0 -0
- bootstack/assets/widgets/button-group-horizontal-after.png +0 -0
- bootstack/assets/widgets/button-group-horizontal-before.png +0 -0
- bootstack/assets/widgets/button-group-horizontal-center.png +0 -0
- bootstack/assets/widgets/button-group-vertical-after.png +0 -0
- bootstack/assets/widgets/button-group-vertical-before.png +0 -0
- bootstack/assets/widgets/button-group-vertical-center.png +0 -0
- bootstack/assets/widgets/button.png +0 -0
- bootstack/assets/widgets/checkbox-checked.png +0 -0
- bootstack/assets/widgets/checkbox-indeterminate.png +0 -0
- bootstack/assets/widgets/checkbox-unchecked.png +0 -0
- bootstack/assets/widgets/field.png +0 -0
- bootstack/assets/widgets/icon-button.png +0 -0
- bootstack/assets/widgets/input-inner.png +0 -0
- bootstack/assets/widgets/input-prefix.png +0 -0
- bootstack/assets/widgets/input-suffix.png +0 -0
- bootstack/assets/widgets/input.png +0 -0
- bootstack/assets/widgets/list-item-focus.png +0 -0
- bootstack/assets/widgets/list-item-separated.png +0 -0
- bootstack/assets/widgets/menu-item-separated.png +0 -0
- bootstack/assets/widgets/notebook-client-border.png +0 -0
- bootstack/assets/widgets/notebook-pill-active.png +0 -0
- bootstack/assets/widgets/notebook-pill-inactive.png +0 -0
- bootstack/assets/widgets/notebook-tab-active.png +0 -0
- bootstack/assets/widgets/notebook-tab-border.png +0 -0
- bootstack/assets/widgets/notebook-tab-normal.png +0 -0
- bootstack/assets/widgets/notebook-underline.png +0 -0
- bootstack/assets/widgets/progress-bar-horizontal-default.png +0 -0
- bootstack/assets/widgets/progress-bar-horizontal-striped.png +0 -0
- bootstack/assets/widgets/progress-bar-vertical-default.png +0 -0
- bootstack/assets/widgets/progress-bar-vertical-striped.png +0 -0
- bootstack/assets/widgets/progress-trough-horizontal.png +0 -0
- bootstack/assets/widgets/progress-trough-vertical.png +0 -0
- bootstack/assets/widgets/radio-selected.png +0 -0
- bootstack/assets/widgets/radio-unselected.png +0 -0
- bootstack/assets/widgets/scrollbar-horizontal-rounded.png +0 -0
- bootstack/assets/widgets/scrollbar-vertical-rounded.png +0 -0
- bootstack/assets/widgets/separator-horizontal.png +0 -0
- bootstack/assets/widgets/separator-vertical.png +0 -0
- bootstack/assets/widgets/slider-handle-focus.png +0 -0
- bootstack/assets/widgets/slider-handle.png +0 -0
- bootstack/assets/widgets/slider-track-horizontal.png +0 -0
- bootstack/assets/widgets/slider-track-vertical.png +0 -0
- bootstack/assets/widgets/switch-off.png +0 -0
- bootstack/assets/widgets/switch-on.png +0 -0
- bootstack/assets/widgets/tabs-bar-horizontal.png +0 -0
- bootstack/assets/widgets/tabs-bar-vertical.png +0 -0
- bootstack/assets/widgets/tabs-pill.png +0 -0
- bootstack/cli/__init__.py +124 -0
- bootstack/cli/__main__.py +6 -0
- bootstack/cli/add.py +439 -0
- bootstack/cli/build.py +115 -0
- bootstack/cli/config.py +287 -0
- bootstack/cli/demo.py +1267 -0
- bootstack/cli/doctor.py +195 -0
- bootstack/cli/list_cmd.py +71 -0
- bootstack/cli/promote.py +120 -0
- bootstack/cli/pyinstaller.py +246 -0
- bootstack/cli/run.py +99 -0
- bootstack/cli/start.py +105 -0
- bootstack/cli/templates/__init__.py +861 -0
- bootstack/constants.py +325 -0
- bootstack/core/__init__.py +34 -0
- bootstack/core/capabilities/__init__.py +45 -0
- bootstack/core/capabilities/after.py +103 -0
- bootstack/core/capabilities/bind.py +154 -0
- bootstack/core/capabilities/bindtags.py +112 -0
- bootstack/core/capabilities/busy.py +61 -0
- bootstack/core/capabilities/clipboard.py +88 -0
- bootstack/core/capabilities/focus.py +118 -0
- bootstack/core/capabilities/grab.py +65 -0
- bootstack/core/capabilities/grid.py +188 -0
- bootstack/core/capabilities/localization.py +231 -0
- bootstack/core/capabilities/pack.py +119 -0
- bootstack/core/capabilities/place.py +92 -0
- bootstack/core/capabilities/selection.py +136 -0
- bootstack/core/capabilities/signals.py +242 -0
- bootstack/core/capabilities/winfo.py +315 -0
- bootstack/core/colorutils.py +234 -0
- bootstack/core/exceptions.py +95 -0
- bootstack/core/images.py +283 -0
- bootstack/core/localization/README.md +90 -0
- bootstack/core/localization/__init__.py +13 -0
- bootstack/core/localization/intl_format.py +580 -0
- bootstack/core/localization/msgcat.py +425 -0
- bootstack/core/localization/specs.py +143 -0
- bootstack/core/mixins/__init__.py +1 -0
- bootstack/core/mixins/ttk_state.py +35 -0
- bootstack/core/mixins/widget.py +132 -0
- bootstack/core/publisher.py +147 -0
- bootstack/core/signals/README.md +112 -0
- bootstack/core/signals/__init__.py +8 -0
- bootstack/core/signals/integration.py +100 -0
- bootstack/core/signals/signal.py +317 -0
- bootstack/core/signals/types.py +4 -0
- bootstack/core/validation/__init__.py +5 -0
- bootstack/core/validation/types.py +13 -0
- bootstack/core/validation/validation_result.py +17 -0
- bootstack/core/validation/validation_rules.py +112 -0
- bootstack/core/variables.py +62 -0
- bootstack/datasource/README.md +607 -0
- bootstack/datasource/__init__.py +51 -0
- bootstack/datasource/base.py +474 -0
- bootstack/datasource/file_source.py +541 -0
- bootstack/datasource/memory_source.py +482 -0
- bootstack/datasource/sqlite_source.py +453 -0
- bootstack/datasource/types.py +259 -0
- bootstack/dialogs/__init__.py +56 -0
- bootstack/dialogs/colorchooser.py +674 -0
- bootstack/dialogs/colordropper.py +257 -0
- bootstack/dialogs/datedialog.py +404 -0
- bootstack/dialogs/dialog.py +514 -0
- bootstack/dialogs/filterdialog.py +358 -0
- bootstack/dialogs/fontdialog.py +339 -0
- bootstack/dialogs/formdialog.py +541 -0
- bootstack/dialogs/message.py +489 -0
- bootstack/dialogs/query.py +561 -0
- bootstack/py.typed +1 -0
- bootstack/runtime/__init__.py +3 -0
- bootstack/runtime/app.py +879 -0
- bootstack/runtime/base_window.py +786 -0
- bootstack/runtime/events.py +399 -0
- bootstack/runtime/menu.py +510 -0
- bootstack/runtime/shortcuts.py +423 -0
- bootstack/runtime/tk_patch.py +31 -0
- bootstack/runtime/toplevel.py +131 -0
- bootstack/runtime/utility.py +371 -0
- bootstack/runtime/visual_focus.py +228 -0
- bootstack/runtime/window_utilities.py +1043 -0
- bootstack/style/__init__.py +5498 -0
- bootstack/style/bootstyle.py +507 -0
- bootstack/style/bootstyle_builder_base.py +752 -0
- bootstack/style/bootstyle_builder_mixed.py +93 -0
- bootstack/style/bootstyle_builder_tk.py +109 -0
- bootstack/style/bootstyle_builder_ttk.py +354 -0
- bootstack/style/builders/__init__.py +51 -0
- bootstack/style/builders/badge.py +44 -0
- bootstack/style/builders/button.py +453 -0
- bootstack/style/builders/buttongroup.py +344 -0
- bootstack/style/builders/calendar.py +271 -0
- bootstack/style/builders/checkbutton.py +95 -0
- bootstack/style/builders/combobox.py +112 -0
- bootstack/style/builders/contextmenu.py +268 -0
- bootstack/style/builders/entry.py +83 -0
- bootstack/style/builders/expander.py +171 -0
- bootstack/style/builders/field.py +312 -0
- bootstack/style/builders/frame.py +27 -0
- bootstack/style/builders/label.py +28 -0
- bootstack/style/builders/labelframe.py +41 -0
- bootstack/style/builders/listview.py +267 -0
- bootstack/style/builders/menubar.py +74 -0
- bootstack/style/builders/menubutton.py +408 -0
- bootstack/style/builders/notebook.py +316 -0
- bootstack/style/builders/panedwindow.py +25 -0
- bootstack/style/builders/progressbar.py +71 -0
- bootstack/style/builders/radiobutton.py +68 -0
- bootstack/style/builders/scale.py +66 -0
- bootstack/style/builders/scrollbar.py +360 -0
- bootstack/style/builders/separator.py +45 -0
- bootstack/style/builders/sidenav.py +313 -0
- bootstack/style/builders/sizegrip.py +15 -0
- bootstack/style/builders/spinbox.py +119 -0
- bootstack/style/builders/switch.py +67 -0
- bootstack/style/builders/tabitem.py +205 -0
- bootstack/style/builders/toolbutton.py +260 -0
- bootstack/style/builders/tooltip.py +26 -0
- bootstack/style/builders/treeview.py +269 -0
- bootstack/style/builders/utils.py +404 -0
- bootstack/style/builders_tk/__init__.py +16 -0
- bootstack/style/builders_tk/defaults.py +229 -0
- bootstack/style/element.py +173 -0
- bootstack/style/style.py +499 -0
- bootstack/style/theme_provider.py +449 -0
- bootstack/style/tk_patch.py +5 -0
- bootstack/style/token_maps.py +42 -0
- bootstack/style/types.py +32 -0
- bootstack/style/typography.py +527 -0
- bootstack/style/utility.py +696 -0
- bootstack/themes/__init__.py +12 -0
- bootstack/themes/standard.py +415 -0
- bootstack/themes/user.py +45 -0
- bootstack/widgets/__init__.py +53 -0
- bootstack/widgets/composites/__init__.py +38 -0
- bootstack/widgets/composites/accordion.py +385 -0
- bootstack/widgets/composites/appshell.py +445 -0
- bootstack/widgets/composites/buttongroup.py +391 -0
- bootstack/widgets/composites/calendar.py +914 -0
- bootstack/widgets/composites/compositeframe.py +282 -0
- bootstack/widgets/composites/contextmenu.py +1754 -0
- bootstack/widgets/composites/dateentry.py +261 -0
- bootstack/widgets/composites/dropdownbutton.py +190 -0
- bootstack/widgets/composites/expander.py +508 -0
- bootstack/widgets/composites/field.py +448 -0
- bootstack/widgets/composites/floodgauge.py +434 -0
- bootstack/widgets/composites/form.py +983 -0
- bootstack/widgets/composites/labeledscale.py +209 -0
- bootstack/widgets/composites/list/__init__.py +15 -0
- bootstack/widgets/composites/list/listitem.py +733 -0
- bootstack/widgets/composites/list/listview.py +1507 -0
- bootstack/widgets/composites/menubar.py +303 -0
- bootstack/widgets/composites/meter.py +882 -0
- bootstack/widgets/composites/numericentry.py +183 -0
- bootstack/widgets/composites/pagestack.py +330 -0
- bootstack/widgets/composites/passwordentry.py +149 -0
- bootstack/widgets/composites/pathentry.py +223 -0
- bootstack/widgets/composites/radiogroup.py +466 -0
- bootstack/widgets/composites/scrolledtext.py +388 -0
- bootstack/widgets/composites/scrolledtext.pyi +186 -0
- bootstack/widgets/composites/scrollview.py +675 -0
- bootstack/widgets/composites/selectbox.py +544 -0
- bootstack/widgets/composites/sidenav/__init__.py +24 -0
- bootstack/widgets/composites/sidenav/group.py +485 -0
- bootstack/widgets/composites/sidenav/header.py +83 -0
- bootstack/widgets/composites/sidenav/item.py +413 -0
- bootstack/widgets/composites/sidenav/separator.py +51 -0
- bootstack/widgets/composites/sidenav/view.py +919 -0
- bootstack/widgets/composites/spinnerentry.py +232 -0
- bootstack/widgets/composites/tableview/__init__.py +5 -0
- bootstack/widgets/composites/tableview/tableview.py +2254 -0
- bootstack/widgets/composites/tableview/types.py +169 -0
- bootstack/widgets/composites/tabs/__init__.py +6 -0
- bootstack/widgets/composites/tabs/tabitem.py +372 -0
- bootstack/widgets/composites/tabs/tabs.py +478 -0
- bootstack/widgets/composites/tabs/tabview.py +352 -0
- bootstack/widgets/composites/textentry.py +90 -0
- bootstack/widgets/composites/timeentry.py +189 -0
- bootstack/widgets/composites/toast.py +364 -0
- bootstack/widgets/composites/togglegroup.py +382 -0
- bootstack/widgets/composites/toolbar.py +393 -0
- bootstack/widgets/composites/tooltip.py +404 -0
- bootstack/widgets/internal/__init__.py +0 -0
- bootstack/widgets/internal/wrapper_base.py +304 -0
- bootstack/widgets/mixins/__init__.py +25 -0
- bootstack/widgets/mixins/configure_mixin.py +186 -0
- bootstack/widgets/mixins/entry_mixin.py +70 -0
- bootstack/widgets/mixins/font_mixin.py +346 -0
- bootstack/widgets/mixins/icon_mixin.py +38 -0
- bootstack/widgets/mixins/localization_mixin.py +255 -0
- bootstack/widgets/mixins/signal_mixin.py +272 -0
- bootstack/widgets/mixins/validation_mixin.py +204 -0
- bootstack/widgets/parts/__init__.py +11 -0
- bootstack/widgets/parts/numberentry_part.py +345 -0
- bootstack/widgets/parts/spinnerentry_part.py +394 -0
- bootstack/widgets/parts/textentry_part.py +344 -0
- bootstack/widgets/primitives/__init__.py +55 -0
- bootstack/widgets/primitives/badge.py +44 -0
- bootstack/widgets/primitives/button.py +89 -0
- bootstack/widgets/primitives/card.py +66 -0
- bootstack/widgets/primitives/checkbutton.py +124 -0
- bootstack/widgets/primitives/checktoggle.py +53 -0
- bootstack/widgets/primitives/combobox.py +165 -0
- bootstack/widgets/primitives/entry.py +98 -0
- bootstack/widgets/primitives/frame.py +206 -0
- bootstack/widgets/primitives/gridframe.py +479 -0
- bootstack/widgets/primitives/label.py +95 -0
- bootstack/widgets/primitives/labelframe.py +63 -0
- bootstack/widgets/primitives/menubutton.py +118 -0
- bootstack/widgets/primitives/notebook.py +551 -0
- bootstack/widgets/primitives/optionmenu.py +248 -0
- bootstack/widgets/primitives/packframe.py +228 -0
- bootstack/widgets/primitives/panedwindow.py +58 -0
- bootstack/widgets/primitives/progressbar.py +95 -0
- bootstack/widgets/primitives/radiobutton.py +115 -0
- bootstack/widgets/primitives/radiotoggle.py +50 -0
- bootstack/widgets/primitives/scale.py +85 -0
- bootstack/widgets/primitives/scrollbar.py +56 -0
- bootstack/widgets/primitives/separator.py +56 -0
- bootstack/widgets/primitives/sizegrip.py +47 -0
- bootstack/widgets/primitives/spinbox.py +91 -0
- bootstack/widgets/primitives/switch.py +41 -0
- bootstack/widgets/primitives/treeview.py +77 -0
- bootstack/widgets/types.py +20 -0
- bootstack-0.1.0a1.dist-info/METADATA +196 -0
- bootstack-0.1.0a1.dist-info/RECORD +419 -0
- bootstack-0.1.0a1.dist-info/WHEEL +5 -0
- bootstack-0.1.0a1.dist-info/entry_points.txt +2 -0
- bootstack-0.1.0a1.dist-info/licenses/LICENSE +22 -0
- bootstack-0.1.0a1.dist-info/licenses/NOTICE +10 -0
- bootstack-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
"""Inline calendar widget supporting single and range date selection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import calendar
|
|
5
|
+
import tkinter
|
|
6
|
+
from datetime import date, datetime, timedelta
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
from tkinter import StringVar
|
|
9
|
+
from typing import Any, Callable, Iterable, Literal, Optional
|
|
10
|
+
|
|
11
|
+
from babel import dates
|
|
12
|
+
from babel.core import Locale
|
|
13
|
+
from bootstack.widgets.primitives import Button, CheckToggle, Frame, Label, Separator
|
|
14
|
+
from bootstack.widgets.types import Master
|
|
15
|
+
from bootstack.constants import BOTH, CENTER, LEFT, NSEW, PRIMARY, X, Y, YES
|
|
16
|
+
from bootstack.core.localization import MessageCatalog
|
|
17
|
+
from bootstack.runtime.utility import bind_right_click
|
|
18
|
+
from bootstack.widgets.mixins import configure_delegate
|
|
19
|
+
|
|
20
|
+
ttk = SimpleNamespace(
|
|
21
|
+
Button=Button,
|
|
22
|
+
CheckToggle=CheckToggle,
|
|
23
|
+
Frame=Frame,
|
|
24
|
+
Label=Label,
|
|
25
|
+
Separator=Separator,
|
|
26
|
+
StringVar=StringVar,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_WEEKDAY_TOKENS = (
|
|
30
|
+
"day.mo",
|
|
31
|
+
"day.tu",
|
|
32
|
+
"day.we",
|
|
33
|
+
"day.th",
|
|
34
|
+
"day.fr",
|
|
35
|
+
"day.sa",
|
|
36
|
+
"day.su",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_MONTH_TOKENS = (
|
|
40
|
+
None,
|
|
41
|
+
"month.january",
|
|
42
|
+
"month.february",
|
|
43
|
+
"month.march",
|
|
44
|
+
"month.april",
|
|
45
|
+
"month.may",
|
|
46
|
+
"month.june",
|
|
47
|
+
"month.july",
|
|
48
|
+
"month.august",
|
|
49
|
+
"month.september",
|
|
50
|
+
"month.october",
|
|
51
|
+
"month.november",
|
|
52
|
+
"month.december",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _localized_month_name(month_index: int) -> str:
|
|
57
|
+
if 1 <= month_index < len(_MONTH_TOKENS):
|
|
58
|
+
token = _MONTH_TOKENS[month_index]
|
|
59
|
+
if token:
|
|
60
|
+
return MessageCatalog.translate(token)
|
|
61
|
+
return calendar.month_name[month_index] if 1 <= month_index <= 12 else ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _format_month_year(month_date: date) -> str:
|
|
65
|
+
"""Format month/year in the current locale via Babel, falling back to English."""
|
|
66
|
+
locale_code = MessageCatalog.locale().replace("_", "-")
|
|
67
|
+
try:
|
|
68
|
+
return dates.format_skeleton("yMMMM", month_date, locale=locale_code)
|
|
69
|
+
except Exception:
|
|
70
|
+
month_name = _localized_month_name(month_date.month)
|
|
71
|
+
return f"{month_name} {month_date.year}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _longest_month_title_length() -> int:
|
|
75
|
+
"""Calculate the character length of the longest month/year title for the current locale."""
|
|
76
|
+
max_length = 0
|
|
77
|
+
# Use a 4-digit year for measurement (e.g., 2024)
|
|
78
|
+
sample_year = 2024
|
|
79
|
+
for month in range(1, 13):
|
|
80
|
+
title = _format_month_year(date(sample_year, month, 1))
|
|
81
|
+
if len(title) > max_length:
|
|
82
|
+
max_length = len(title)
|
|
83
|
+
return max_length
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Calendar(ttk.Frame):
|
|
87
|
+
"""Inline calendar widget for selecting dates.
|
|
88
|
+
|
|
89
|
+
Supports single or range selection modes with optional disabled dates
|
|
90
|
+
and min/max bounds. Displays one month in single mode or two months
|
|
91
|
+
in range mode.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
master: Master = None,
|
|
97
|
+
*,
|
|
98
|
+
value: date | datetime | str | None = None,
|
|
99
|
+
start_date: date | datetime | str | None = None,
|
|
100
|
+
end_date: date | datetime | str | None = None,
|
|
101
|
+
disabled_dates: Iterable[date | datetime | str] | None = None,
|
|
102
|
+
selection_mode: Literal['single', 'range'] = "single",
|
|
103
|
+
max_date: date | datetime | str | None = None,
|
|
104
|
+
min_date: date | datetime | str | None = None,
|
|
105
|
+
show_outside_days: bool | None = None,
|
|
106
|
+
show_week_numbers: bool = False,
|
|
107
|
+
first_weekday: int | None = None,
|
|
108
|
+
accent: str = None,
|
|
109
|
+
bootstyle: str = None,
|
|
110
|
+
padding: int | tuple[int, int] | tuple[int, int, int, int] | str | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Initialize a Calendar widget.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
master: Parent widget. If None, uses the default root window.
|
|
116
|
+
value (date | datetime | str): Initial selected date for single selection mode.
|
|
117
|
+
start_date (date | datetime | str): Range start date. Use `value` instead
|
|
118
|
+
for single selection mode.
|
|
119
|
+
end_date (date | datetime | str): Range end date. Only used when
|
|
120
|
+
`selection_mode='range'`.
|
|
121
|
+
disabled_dates (Iterable): Collection of dates that cannot be selected.
|
|
122
|
+
selection_mode (str): Selection mode - `'single'` for single date or
|
|
123
|
+
`'range'` for date range selection.
|
|
124
|
+
max_date (date | datetime | str): Maximum selectable date. Dates after
|
|
125
|
+
this are disabled.
|
|
126
|
+
min_date (date | datetime | str): Minimum selectable date. Dates before
|
|
127
|
+
this are disabled.
|
|
128
|
+
show_outside_days (bool): Whether to show days from adjacent months.
|
|
129
|
+
Defaults to True for single mode, False for range mode.
|
|
130
|
+
show_week_numbers (bool): Whether to display ISO week numbers in the
|
|
131
|
+
leftmost column.
|
|
132
|
+
first_weekday (int | None): First day of the week. 0=Monday, 6=Sunday.
|
|
133
|
+
If None, uses the locale default.
|
|
134
|
+
accent (str): Accent token for selected dates and highlights (e.g., 'primary', 'success').
|
|
135
|
+
bootstyle (str): DEPRECATED - Use `accent` instead.
|
|
136
|
+
padding (int | tuple | str): Padding around the widget.
|
|
137
|
+
"""
|
|
138
|
+
super().__init__(master, padding=padding)
|
|
139
|
+
|
|
140
|
+
self._selection_mode = selection_mode
|
|
141
|
+
# Derive visible months from selection mode: single->1, range->2
|
|
142
|
+
self._display_months = 2 if selection_mode == "range" else 1
|
|
143
|
+
# Default outside-day visibility: True for single, False for range if not provided
|
|
144
|
+
if show_outside_days is None:
|
|
145
|
+
self._show_outside_days = selection_mode != "range"
|
|
146
|
+
else:
|
|
147
|
+
self._show_outside_days = bool(show_outside_days)
|
|
148
|
+
self._show_week_numbers = show_week_numbers
|
|
149
|
+
|
|
150
|
+
# Resolve first_weekday: None -> locale default via Babel
|
|
151
|
+
if first_weekday is None:
|
|
152
|
+
try:
|
|
153
|
+
locale_code = MessageCatalog.locale().replace("-", "_")
|
|
154
|
+
loc = Locale.parse(locale_code)
|
|
155
|
+
first_weekday = loc.first_week_day
|
|
156
|
+
except Exception:
|
|
157
|
+
first_weekday = 0 # fallback to Monday (ISO standard)
|
|
158
|
+
self._first_weekday = first_weekday
|
|
159
|
+
self._accent = accent or bootstyle or PRIMARY
|
|
160
|
+
self._calendar = calendar.Calendar(firstweekday=first_weekday)
|
|
161
|
+
|
|
162
|
+
# Allow 'value' as alias for 'start_date' (reads better in single mode)
|
|
163
|
+
if start_date is None and value is not None:
|
|
164
|
+
start_date = value
|
|
165
|
+
|
|
166
|
+
initial = self._coerce_date(start_date) or date.today()
|
|
167
|
+
self._initial_date = initial
|
|
168
|
+
self._display_date = date(initial.year, initial.month, 1)
|
|
169
|
+
|
|
170
|
+
self._range_start: date | None = self._coerce_date(start_date)
|
|
171
|
+
self._range_end: date | None = self._coerce_date(end_date)
|
|
172
|
+
if self._range_start and self._range_end and self._range_end < self._range_start:
|
|
173
|
+
self._range_start, self._range_end = self._range_end, self._range_start
|
|
174
|
+
|
|
175
|
+
self._selected_date: date = self._range_end or self._range_start or initial
|
|
176
|
+
|
|
177
|
+
self._disabled_dates = {
|
|
178
|
+
d for d in (self._coerce_date(x) for x in (disabled_dates or [])) if d is not None
|
|
179
|
+
}
|
|
180
|
+
self._max_date = self._coerce_date(max_date)
|
|
181
|
+
self._min_date = self._coerce_date(min_date)
|
|
182
|
+
|
|
183
|
+
self._title_var = ttk.StringVar()
|
|
184
|
+
self._locked_size: Optional[tuple[int, int]] = None
|
|
185
|
+
|
|
186
|
+
self._header_frame: ttk.Frame | None = None
|
|
187
|
+
self._months_frame: ttk.Frame | None = None
|
|
188
|
+
self._month_frames: list[ttk.Frame] = []
|
|
189
|
+
self._month_separators: list[ttk.Separator] = []
|
|
190
|
+
self._month_views: list[dict[str, Any]] = []
|
|
191
|
+
|
|
192
|
+
self._build_ui()
|
|
193
|
+
self.bind("<<LocaleChanged>>", lambda *_: self._refresh_calendar(), add="+")
|
|
194
|
+
|
|
195
|
+
# --- public API --------------------------------------------------
|
|
196
|
+
|
|
197
|
+
# Value API (v2 standard) -----------------------------------------
|
|
198
|
+
def get(self) -> date | None:
|
|
199
|
+
"""Return the currently selected date.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
The selected date, or None if no date is selected.
|
|
203
|
+
"""
|
|
204
|
+
return self._selected_date
|
|
205
|
+
|
|
206
|
+
def set(self, value: date | datetime | str | None) -> None:
|
|
207
|
+
"""Set the selected date programmatically.
|
|
208
|
+
|
|
209
|
+
This method does NOT emit `<<DateSelect>>`. Use for programmatic
|
|
210
|
+
updates when you don't want to trigger event handlers.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
value: The date to select. Accepts date, datetime, ISO string,
|
|
214
|
+
or None to clear selection.
|
|
215
|
+
"""
|
|
216
|
+
new_date = self._coerce_date(value)
|
|
217
|
+
if new_date is None:
|
|
218
|
+
return
|
|
219
|
+
self._selected_date = new_date
|
|
220
|
+
if self._selection_mode == "single":
|
|
221
|
+
self._range_start = new_date
|
|
222
|
+
self._range_end = None
|
|
223
|
+
else:
|
|
224
|
+
# In range mode, set() sets the start of a new range
|
|
225
|
+
self._range_start = new_date
|
|
226
|
+
self._range_end = None
|
|
227
|
+
self._display_date = date(new_date.year, new_date.month, 1)
|
|
228
|
+
self._refresh_calendar()
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def value(self) -> date | None:
|
|
232
|
+
"""The currently selected date.
|
|
233
|
+
|
|
234
|
+
This property provides convenient access to `get()` and `set()`.
|
|
235
|
+
"""
|
|
236
|
+
return self.get()
|
|
237
|
+
|
|
238
|
+
@value.setter
|
|
239
|
+
def value(self, val: date | datetime | str | None) -> None:
|
|
240
|
+
self.set(val)
|
|
241
|
+
|
|
242
|
+
# Range API -------------------------------------------------------
|
|
243
|
+
def get_range(self) -> tuple[date | None, date | None]:
|
|
244
|
+
"""Return the selected date range.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
A tuple of (start, end) dates. If only a start is selected
|
|
248
|
+
(range in progress), end will be None. If no selection,
|
|
249
|
+
both may be None.
|
|
250
|
+
"""
|
|
251
|
+
return (self._range_start, self._range_end)
|
|
252
|
+
|
|
253
|
+
def set_range(
|
|
254
|
+
self,
|
|
255
|
+
start: date | datetime | str | None,
|
|
256
|
+
end: date | datetime | str | None = None,
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Set the selected date range programmatically.
|
|
259
|
+
|
|
260
|
+
This method does NOT emit `<<DateSelect>>`. Use for programmatic
|
|
261
|
+
updates when you don't want to trigger event handlers.
|
|
262
|
+
|
|
263
|
+
If both start and end are provided and end < start, they are
|
|
264
|
+
automatically normalized (swapped) to ensure start <= end.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
start: The range start date. Accepts date, datetime, ISO string.
|
|
268
|
+
end: The range end date. If None, sets a range-in-progress.
|
|
269
|
+
"""
|
|
270
|
+
s, e = self._normalize_range(start, end)
|
|
271
|
+
self._range_start = s
|
|
272
|
+
self._range_end = e
|
|
273
|
+
# Update selected_date to the end if complete, else start
|
|
274
|
+
self._selected_date = e if e else (s if s else self._selected_date)
|
|
275
|
+
# Navigate display to show the range
|
|
276
|
+
if s:
|
|
277
|
+
self._display_date = date(s.year, s.month, 1)
|
|
278
|
+
self._refresh_calendar()
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def range(self) -> tuple[date | None, date | None]:
|
|
282
|
+
"""The selected date range as (start, end).
|
|
283
|
+
|
|
284
|
+
This property provides convenient access to `get_range()` and
|
|
285
|
+
`set_range()`.
|
|
286
|
+
"""
|
|
287
|
+
return self.get_range()
|
|
288
|
+
|
|
289
|
+
@range.setter
|
|
290
|
+
def range(self, val: tuple[date | datetime | str | None, date | datetime | str | None]) -> None:
|
|
291
|
+
if val is None:
|
|
292
|
+
self.set_range(None, None)
|
|
293
|
+
elif isinstance(val, (list, tuple)) and len(val) >= 2:
|
|
294
|
+
self.set_range(val[0], val[1])
|
|
295
|
+
elif isinstance(val, (list, tuple)) and len(val) == 1:
|
|
296
|
+
self.set_range(val[0], None)
|
|
297
|
+
else:
|
|
298
|
+
self.set_range(val, None)
|
|
299
|
+
|
|
300
|
+
# Legacy delegate (for configure() compatibility) -----------------
|
|
301
|
+
@configure_delegate("date")
|
|
302
|
+
def _delegate_date(self, value: date | datetime | str | None = None) -> Optional[date]:
|
|
303
|
+
"""Get or set the current selected date via configure()."""
|
|
304
|
+
if value is None:
|
|
305
|
+
return self._selected_date
|
|
306
|
+
self.set(value)
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
# Event binding ---------------------------------------------------
|
|
310
|
+
def on_date_selected(self, callback: Callable) -> str:
|
|
311
|
+
"""Bind to `<<DateSelect>>`. Callback receives `event.data = {'date': date, 'range': tuple[date, date | None]}`."""
|
|
312
|
+
return self.bind("<<DateSelect>>", callback, add=True)
|
|
313
|
+
|
|
314
|
+
def off_date_selected(self, bind_id: str | None = None) -> None:
|
|
315
|
+
"""Unbind from `<<DateSelect>>`."""
|
|
316
|
+
return self.unbind("<<DateSelect>>", bind_id)
|
|
317
|
+
|
|
318
|
+
# --- UI construction --------------------------------------------
|
|
319
|
+
def _build_ui(self) -> None:
|
|
320
|
+
# Single-month header only for non-range mode
|
|
321
|
+
if self._selection_mode != "range":
|
|
322
|
+
self._create_header()
|
|
323
|
+
if not hasattr(self, "_header_separator"):
|
|
324
|
+
self._header_separator = ttk.Separator(self)
|
|
325
|
+
self._header_separator.pack(fill=X)
|
|
326
|
+
self._draw_calendar()
|
|
327
|
+
|
|
328
|
+
def _create_header(self) -> None:
|
|
329
|
+
self._header_frame = ttk.Frame(self)
|
|
330
|
+
self._header_frame.pack(fill=X)
|
|
331
|
+
|
|
332
|
+
for col in range(5):
|
|
333
|
+
self._header_frame.columnconfigure(col, weight=1 if col == 2 else 0)
|
|
334
|
+
|
|
335
|
+
self._prev_year_btn = ttk.Button(
|
|
336
|
+
master=self._header_frame,
|
|
337
|
+
icon='chevron-double-left',
|
|
338
|
+
icon_only=True,
|
|
339
|
+
variant="ghost",
|
|
340
|
+
density='compact',
|
|
341
|
+
command=self._on_prev_year,
|
|
342
|
+
)
|
|
343
|
+
self._prev_year_btn.grid(row=0, column=0)
|
|
344
|
+
bind_right_click(self._prev_year_btn, self._on_prev_year)
|
|
345
|
+
|
|
346
|
+
self._prev_month_btn = ttk.Button(
|
|
347
|
+
master=self._header_frame,
|
|
348
|
+
icon='chevron-left',
|
|
349
|
+
density='compact',
|
|
350
|
+
variant="ghost",
|
|
351
|
+
icon_only=True,
|
|
352
|
+
command=self._on_prev_month,
|
|
353
|
+
)
|
|
354
|
+
self._prev_month_btn.grid(row=0, column=1)
|
|
355
|
+
|
|
356
|
+
self._set_title()
|
|
357
|
+
title_width = _longest_month_title_length()
|
|
358
|
+
title_label = ttk.Label(
|
|
359
|
+
master=self._header_frame,
|
|
360
|
+
textvariable=self._title_var,
|
|
361
|
+
anchor=CENTER,
|
|
362
|
+
accent="secondary",
|
|
363
|
+
font='caption[bold]',
|
|
364
|
+
width=title_width,
|
|
365
|
+
)
|
|
366
|
+
title_label.grid(row=0, column=2, sticky="ew")
|
|
367
|
+
title_label.bind("<Button-1>", self._on_reset_date)
|
|
368
|
+
|
|
369
|
+
self._next_month_btn = ttk.Button(
|
|
370
|
+
master=self._header_frame,
|
|
371
|
+
icon='chevron-right',
|
|
372
|
+
variant="ghost",
|
|
373
|
+
density='compact',
|
|
374
|
+
icon_only=True,
|
|
375
|
+
command=self._on_next_month,
|
|
376
|
+
)
|
|
377
|
+
self._next_month_btn.grid(row=0, column=3)
|
|
378
|
+
|
|
379
|
+
self._next_year_btn = ttk.Button(
|
|
380
|
+
master=self._header_frame,
|
|
381
|
+
icon='chevron-double-right',
|
|
382
|
+
icon_only=True,
|
|
383
|
+
variant="ghost",
|
|
384
|
+
density='compact',
|
|
385
|
+
command=self._on_next_year,
|
|
386
|
+
)
|
|
387
|
+
self._next_year_btn.grid(row=0, column=4)
|
|
388
|
+
bind_right_click(self._next_year_btn, self._on_next_year)
|
|
389
|
+
|
|
390
|
+
# Preserve column widths so hiding buttons won't shift the title
|
|
391
|
+
self._header_frame.update_idletasks()
|
|
392
|
+
col_sizes = [
|
|
393
|
+
self._prev_year_btn.winfo_reqwidth(),
|
|
394
|
+
self._prev_month_btn.winfo_reqwidth(),
|
|
395
|
+
title_label.winfo_reqwidth(),
|
|
396
|
+
self._next_month_btn.winfo_reqwidth(),
|
|
397
|
+
self._next_year_btn.winfo_reqwidth(),
|
|
398
|
+
]
|
|
399
|
+
for idx, size in enumerate(col_sizes):
|
|
400
|
+
self._header_frame.columnconfigure(idx, minsize=size)
|
|
401
|
+
|
|
402
|
+
# --- drawing ------------------------------------------------------
|
|
403
|
+
def _draw_calendar(self) -> None:
|
|
404
|
+
if self._months_frame is None:
|
|
405
|
+
self._months_frame = ttk.Frame(self)
|
|
406
|
+
self._months_frame.pack(fill=BOTH, expand=YES)
|
|
407
|
+
|
|
408
|
+
if self._display_months == 1:
|
|
409
|
+
self._set_title()
|
|
410
|
+
|
|
411
|
+
current = self._display_date
|
|
412
|
+
for idx in range(self._display_months):
|
|
413
|
+
if idx >= len(self._month_frames):
|
|
414
|
+
frame = ttk.Frame(self._months_frame, padding=0)
|
|
415
|
+
self._month_frames.append(frame)
|
|
416
|
+
self._month_views.append({})
|
|
417
|
+
frame.pack(side=LEFT, fill=BOTH, expand=YES, padx=0, pady=0)
|
|
418
|
+
month_frame = self._month_frames[idx]
|
|
419
|
+
view = self._month_views[idx]
|
|
420
|
+
view["frame"] = month_frame
|
|
421
|
+
month_frame.pack_configure(side=LEFT, fill=BOTH, expand=YES, padx=0, pady=0)
|
|
422
|
+
self._draw_month(month_frame, current, view, idx)
|
|
423
|
+
current = self._add_months(current, 1)
|
|
424
|
+
|
|
425
|
+
# Insert vertical separators between months for multi-month display
|
|
426
|
+
if idx < self._display_months - 1:
|
|
427
|
+
if len(self._month_separators) <= idx:
|
|
428
|
+
sep = ttk.Separator(self._months_frame, orient="vertical")
|
|
429
|
+
self._month_separators.append(sep)
|
|
430
|
+
sep = self._month_separators[idx]
|
|
431
|
+
sep.pack(side=LEFT, fill=Y, padx=0)
|
|
432
|
+
|
|
433
|
+
# Hide unused frames
|
|
434
|
+
for extra in self._month_frames[self._display_months:]:
|
|
435
|
+
extra.pack_forget()
|
|
436
|
+
for sep in self._month_separators[self._display_months - 1:]:
|
|
437
|
+
sep.pack_forget()
|
|
438
|
+
|
|
439
|
+
self.after_idle(self._lock_size)
|
|
440
|
+
|
|
441
|
+
def _draw_month(self, parent: ttk.Frame, month_date: date, view: dict[str, Any], idx: int) -> None:
|
|
442
|
+
# Per-month header when in range mode
|
|
443
|
+
if self._selection_mode == "range":
|
|
444
|
+
header = view.get("header_frame")
|
|
445
|
+
title_var = view.get("title_var")
|
|
446
|
+
if header is None:
|
|
447
|
+
header = ttk.Frame(parent)
|
|
448
|
+
header.pack(fill=X)
|
|
449
|
+
for col in range(5):
|
|
450
|
+
header.columnconfigure(col, weight=1 if col == 2 else 0)
|
|
451
|
+
view["header_frame"] = header
|
|
452
|
+
title_var = ttk.StringVar()
|
|
453
|
+
view["title_var"] = title_var
|
|
454
|
+
# Separator between header and weekday row for this month
|
|
455
|
+
sep = ttk.Separator(parent)
|
|
456
|
+
sep.pack(fill=X)
|
|
457
|
+
view["header_separator"] = sep
|
|
458
|
+
|
|
459
|
+
prev_year = ttk.Button(
|
|
460
|
+
master=header,
|
|
461
|
+
icon='chevron-double-left',
|
|
462
|
+
icon_only=True,
|
|
463
|
+
accent="secondary",
|
|
464
|
+
variant="ghost",
|
|
465
|
+
density='compact',
|
|
466
|
+
command=self._on_prev_year,
|
|
467
|
+
)
|
|
468
|
+
prev_year.grid(row=0, column=0)
|
|
469
|
+
view["prev_year_btn"] = prev_year
|
|
470
|
+
|
|
471
|
+
prev_month = ttk.Button(
|
|
472
|
+
master=header,
|
|
473
|
+
icon='chevron-left',
|
|
474
|
+
accent="secondary",
|
|
475
|
+
variant="ghost",
|
|
476
|
+
density='compact',
|
|
477
|
+
icon_only=True,
|
|
478
|
+
command=self._on_prev_month,
|
|
479
|
+
)
|
|
480
|
+
prev_month.grid(row=0, column=1)
|
|
481
|
+
view["prev_month_btn"] = prev_month
|
|
482
|
+
|
|
483
|
+
title_width = _longest_month_title_length()
|
|
484
|
+
title_label = ttk.Label(
|
|
485
|
+
master=header,
|
|
486
|
+
textvariable=title_var,
|
|
487
|
+
anchor=CENTER,
|
|
488
|
+
accent="secondary",
|
|
489
|
+
font='caption[bold]',
|
|
490
|
+
width=title_width,
|
|
491
|
+
)
|
|
492
|
+
title_label.grid(row=0, column=2, sticky="ew")
|
|
493
|
+
|
|
494
|
+
next_month = ttk.Button(
|
|
495
|
+
master=header,
|
|
496
|
+
icon='chevron-right',
|
|
497
|
+
accent="secondary",
|
|
498
|
+
variant="ghost",
|
|
499
|
+
density='compact',
|
|
500
|
+
icon_only=True,
|
|
501
|
+
command=self._on_next_month,
|
|
502
|
+
)
|
|
503
|
+
next_month.grid(row=0, column=3)
|
|
504
|
+
view["next_month_btn"] = next_month
|
|
505
|
+
|
|
506
|
+
next_year = ttk.Button(
|
|
507
|
+
master=header,
|
|
508
|
+
icon='chevron-double-right',
|
|
509
|
+
icon_only=True,
|
|
510
|
+
accent="secondary",
|
|
511
|
+
variant="ghost",
|
|
512
|
+
density='compact',
|
|
513
|
+
command=self._on_next_year,
|
|
514
|
+
)
|
|
515
|
+
next_year.grid(row=0, column=4)
|
|
516
|
+
view["next_year_btn"] = next_year
|
|
517
|
+
|
|
518
|
+
# Capture sizes and create spacers to hold column widths when buttons are hidden
|
|
519
|
+
header.update_idletasks()
|
|
520
|
+
col_sizes = [
|
|
521
|
+
prev_year.winfo_reqwidth(),
|
|
522
|
+
prev_month.winfo_reqwidth(),
|
|
523
|
+
title_label.winfo_reqwidth(),
|
|
524
|
+
next_month.winfo_reqwidth(),
|
|
525
|
+
next_year.winfo_reqwidth(),
|
|
526
|
+
]
|
|
527
|
+
spacers = [
|
|
528
|
+
ttk.Frame(header, width=col_sizes[0], height=1),
|
|
529
|
+
ttk.Frame(header, width=col_sizes[1], height=1),
|
|
530
|
+
None,
|
|
531
|
+
ttk.Frame(header, width=col_sizes[3], height=1),
|
|
532
|
+
ttk.Frame(header, width=col_sizes[4], height=1),
|
|
533
|
+
]
|
|
534
|
+
view["col_spacers"] = spacers
|
|
535
|
+
for c_idx, size in enumerate(col_sizes):
|
|
536
|
+
header.columnconfigure(c_idx, minsize=size)
|
|
537
|
+
|
|
538
|
+
# Update title text
|
|
539
|
+
if title_var is None:
|
|
540
|
+
title_var = ttk.StringVar()
|
|
541
|
+
view["title_var"] = title_var
|
|
542
|
+
title_var.set(_format_month_year(month_date))
|
|
543
|
+
|
|
544
|
+
# Show/hide nav buttons depending on column
|
|
545
|
+
prev_year = view.get("prev_year_btn")
|
|
546
|
+
prev_month = view.get("prev_month_btn")
|
|
547
|
+
next_month = view.get("next_month_btn")
|
|
548
|
+
next_year = view.get("next_year_btn")
|
|
549
|
+
spacers = view.get("col_spacers", [None] * 5)
|
|
550
|
+
spacer_left_year, spacer_left_month, _, spacer_right_month, spacer_right_year = spacers
|
|
551
|
+
|
|
552
|
+
def _grid_left():
|
|
553
|
+
if prev_year:
|
|
554
|
+
prev_year.grid(row=0, column=0)
|
|
555
|
+
if prev_month:
|
|
556
|
+
prev_month.grid(row=0, column=1)
|
|
557
|
+
if spacer_right_month:
|
|
558
|
+
spacer_right_month.grid(row=0, column=3)
|
|
559
|
+
if spacer_right_year:
|
|
560
|
+
spacer_right_year.grid(row=0, column=4)
|
|
561
|
+
if next_month:
|
|
562
|
+
next_month.grid_remove()
|
|
563
|
+
if next_year:
|
|
564
|
+
next_year.grid_remove()
|
|
565
|
+
if spacer_left_year:
|
|
566
|
+
spacer_left_year.grid_remove()
|
|
567
|
+
if spacer_left_month:
|
|
568
|
+
spacer_left_month.grid_remove()
|
|
569
|
+
|
|
570
|
+
def _grid_right():
|
|
571
|
+
if spacer_left_year:
|
|
572
|
+
spacer_left_year.grid(row=0, column=0)
|
|
573
|
+
if spacer_left_month:
|
|
574
|
+
spacer_left_month.grid(row=0, column=1)
|
|
575
|
+
if next_month:
|
|
576
|
+
next_month.grid(row=0, column=3)
|
|
577
|
+
if next_year:
|
|
578
|
+
next_year.grid(row=0, column=4)
|
|
579
|
+
if prev_year:
|
|
580
|
+
prev_year.grid_remove()
|
|
581
|
+
if prev_month:
|
|
582
|
+
prev_month.grid_remove()
|
|
583
|
+
if spacer_right_month:
|
|
584
|
+
spacer_right_month.grid_remove()
|
|
585
|
+
if spacer_right_year:
|
|
586
|
+
spacer_right_year.grid_remove()
|
|
587
|
+
|
|
588
|
+
# Ensure layout is stable each draw
|
|
589
|
+
if idx == 0:
|
|
590
|
+
_grid_left()
|
|
591
|
+
else:
|
|
592
|
+
_grid_right()
|
|
593
|
+
else:
|
|
594
|
+
# Ensure any per-month header is hidden when not in range nav mode
|
|
595
|
+
header = view.get("header_frame")
|
|
596
|
+
if header:
|
|
597
|
+
header.pack_forget()
|
|
598
|
+
|
|
599
|
+
# Weekday header per month
|
|
600
|
+
weekdays_frame: ttk.Frame | None = view.get("weekdays")
|
|
601
|
+
if weekdays_frame is None:
|
|
602
|
+
weekdays_frame = ttk.Frame(parent)
|
|
603
|
+
weekdays_frame.pack(fill=X)
|
|
604
|
+
view["weekdays"] = weekdays_frame
|
|
605
|
+
else:
|
|
606
|
+
for child in weekdays_frame.winfo_children():
|
|
607
|
+
child.destroy()
|
|
608
|
+
|
|
609
|
+
if self._show_week_numbers:
|
|
610
|
+
ttk.Label(weekdays_frame, text="#", anchor=CENTER, padding=5, surface="background[+1]").pack(
|
|
611
|
+
side=LEFT, fill=X, expand=YES)
|
|
612
|
+
for col in self._header_columns():
|
|
613
|
+
ttk.Label(
|
|
614
|
+
master=weekdays_frame,
|
|
615
|
+
text=col,
|
|
616
|
+
anchor=CENTER,
|
|
617
|
+
padding=5,
|
|
618
|
+
accent="secondary",
|
|
619
|
+
font='caption[bold]',
|
|
620
|
+
).pack(side=LEFT, fill=X, expand=YES)
|
|
621
|
+
|
|
622
|
+
# Grid reused
|
|
623
|
+
grid: ttk.Frame | None = view.get("grid")
|
|
624
|
+
if grid is None:
|
|
625
|
+
grid = ttk.Frame(parent)
|
|
626
|
+
grid.pack(fill=BOTH, expand=YES)
|
|
627
|
+
view["grid"] = grid
|
|
628
|
+
cells: list[list[ttk.Checkbutton]] = []
|
|
629
|
+
cell_vars: list[list[tkinter.BooleanVar]] = []
|
|
630
|
+
week_labels: list[ttk.Label] = []
|
|
631
|
+
for r in range(6):
|
|
632
|
+
if self._show_week_numbers:
|
|
633
|
+
wl = ttk.Label(grid, anchor=CENTER, padding=5, surface="background[+1]")
|
|
634
|
+
wl.grid(row=r, column=0, sticky=NSEW)
|
|
635
|
+
week_labels.append(wl)
|
|
636
|
+
row_cells: list[ttk.Checkbutton] = []
|
|
637
|
+
row_vars: list[tkinter.BooleanVar] = []
|
|
638
|
+
for c in range(7):
|
|
639
|
+
col_offset = 1 if self._show_week_numbers else 0
|
|
640
|
+
grid.columnconfigure(c + col_offset, weight=1)
|
|
641
|
+
var = tkinter.BooleanVar(value=False)
|
|
642
|
+
btn = ttk.CheckToggle(
|
|
643
|
+
grid,
|
|
644
|
+
width=2,
|
|
645
|
+
padding=self._square_button_padding(),
|
|
646
|
+
accent=self._accent,
|
|
647
|
+
variant="calendar-day",
|
|
648
|
+
variable=var,
|
|
649
|
+
onvalue=True,
|
|
650
|
+
offvalue=False,
|
|
651
|
+
takefocus=True,
|
|
652
|
+
)
|
|
653
|
+
btn.grid(row=r, column=c + col_offset, sticky=NSEW)
|
|
654
|
+
row_cells.append(btn)
|
|
655
|
+
row_vars.append(var)
|
|
656
|
+
cells.append(row_cells)
|
|
657
|
+
cell_vars.append(row_vars)
|
|
658
|
+
view["cells"] = cells
|
|
659
|
+
view["cell_vars"] = cell_vars
|
|
660
|
+
view["week_labels"] = week_labels
|
|
661
|
+
else:
|
|
662
|
+
cells = view["cells"]
|
|
663
|
+
cell_vars = view.get("cell_vars", [])
|
|
664
|
+
week_labels = view.get("week_labels", [])
|
|
665
|
+
|
|
666
|
+
# Compute 42 sequential days starting at first cell of month view
|
|
667
|
+
month_dates = self._calendar.monthdatescalendar(year=month_date.year, month=month_date.month)
|
|
668
|
+
start = month_dates[0][0]
|
|
669
|
+
days = [start + timedelta(days=i) for i in range(42)]
|
|
670
|
+
|
|
671
|
+
# Update week numbers; hide rows with no in-month days
|
|
672
|
+
if self._show_week_numbers:
|
|
673
|
+
for r, wl in enumerate(week_labels):
|
|
674
|
+
row_days = days[r * 7:(r + 1) * 7]
|
|
675
|
+
in_month = any(d.month == month_date.month for d in row_days)
|
|
676
|
+
if in_month:
|
|
677
|
+
wl.configure(text=str(row_days[0].isocalendar()[1]))
|
|
678
|
+
wl.grid(row=r, column=0, sticky=NSEW)
|
|
679
|
+
else:
|
|
680
|
+
if self._show_outside_days:
|
|
681
|
+
off_only = all(d.month != month_date.month for d in row_days)
|
|
682
|
+
else:
|
|
683
|
+
off_only = True
|
|
684
|
+
if off_only:
|
|
685
|
+
wl.grid_remove()
|
|
686
|
+
else:
|
|
687
|
+
wl.configure(text=str(row_days[0].isocalendar()[1]))
|
|
688
|
+
wl.grid(row=r, column=0, sticky=NSEW)
|
|
689
|
+
|
|
690
|
+
# Track which rows should be visible
|
|
691
|
+
row_visible = [False] * 6
|
|
692
|
+
|
|
693
|
+
# Update cells without recreating
|
|
694
|
+
for idx, d in enumerate(days):
|
|
695
|
+
r, c = divmod(idx, 7)
|
|
696
|
+
btn = cells[r][c]
|
|
697
|
+
var = cell_vars[r][c]
|
|
698
|
+
in_month = d.month == month_date.month
|
|
699
|
+
|
|
700
|
+
# Mark row visible if it has an in-month day or we are showing outside days
|
|
701
|
+
if in_month or self._show_outside_days:
|
|
702
|
+
row_visible[r] = True
|
|
703
|
+
|
|
704
|
+
# Outside days always use calendar-outside variant
|
|
705
|
+
if not in_month:
|
|
706
|
+
btn.configure(
|
|
707
|
+
text=d.day if self._show_outside_days else "",
|
|
708
|
+
command=lambda d=d: None,
|
|
709
|
+
variant="calendar-outside",
|
|
710
|
+
takefocus=False,
|
|
711
|
+
)
|
|
712
|
+
btn.state(["disabled"])
|
|
713
|
+
var.set(False)
|
|
714
|
+
continue
|
|
715
|
+
|
|
716
|
+
disabled = self._is_disabled(d)
|
|
717
|
+
accent, variant = self._style_for_date(d, in_month, disabled)
|
|
718
|
+
is_selected = self._is_selected(d)
|
|
719
|
+
btn.configure(
|
|
720
|
+
text=d.day,
|
|
721
|
+
accent=accent,
|
|
722
|
+
variant=variant,
|
|
723
|
+
command=(lambda d=d: self._on_date_selected_by_date(d)),
|
|
724
|
+
takefocus=not disabled,
|
|
725
|
+
)
|
|
726
|
+
var.set(is_selected)
|
|
727
|
+
if disabled:
|
|
728
|
+
btn.state(["disabled"])
|
|
729
|
+
else:
|
|
730
|
+
btn.state(["!disabled"])
|
|
731
|
+
|
|
732
|
+
# Hide or show rows (including week numbers) based on visibility
|
|
733
|
+
col_offset = 1 if self._show_week_numbers else 0
|
|
734
|
+
for r in range(6):
|
|
735
|
+
if row_visible[r]:
|
|
736
|
+
for c in range(7):
|
|
737
|
+
cells[r][c].grid(row=r, column=c + col_offset, sticky=NSEW)
|
|
738
|
+
if self._show_week_numbers and r < len(week_labels):
|
|
739
|
+
week_labels[r].grid(row=r, column=0, sticky=NSEW)
|
|
740
|
+
else:
|
|
741
|
+
for c in range(7):
|
|
742
|
+
cells[r][c].grid_remove()
|
|
743
|
+
if self._show_week_numbers and r < len(week_labels):
|
|
744
|
+
week_labels[r].grid_remove()
|
|
745
|
+
|
|
746
|
+
# --- selection/navigation ----------------------------------------
|
|
747
|
+
def _refresh_calendar(self) -> None:
|
|
748
|
+
self._draw_calendar()
|
|
749
|
+
|
|
750
|
+
def _on_next_month(self, *_args) -> None:
|
|
751
|
+
candidate = self._add_months(self._display_date, 1)
|
|
752
|
+
if self._is_month_allowed(candidate):
|
|
753
|
+
self._display_date = candidate
|
|
754
|
+
self._refresh_calendar()
|
|
755
|
+
|
|
756
|
+
def _on_prev_month(self, *_args) -> None:
|
|
757
|
+
candidate = self._add_months(self._display_date, -1)
|
|
758
|
+
if self._is_month_allowed(candidate):
|
|
759
|
+
self._display_date = candidate
|
|
760
|
+
self._refresh_calendar()
|
|
761
|
+
|
|
762
|
+
def _on_next_year(self, *_args) -> None:
|
|
763
|
+
candidate = date(self._display_date.year + 1, self._display_date.month, 1)
|
|
764
|
+
if self._is_month_allowed(candidate):
|
|
765
|
+
self._display_date = candidate
|
|
766
|
+
self._refresh_calendar()
|
|
767
|
+
|
|
768
|
+
def _on_prev_year(self, *_args) -> None:
|
|
769
|
+
candidate = date(self._display_date.year - 1, self._display_date.month, 1)
|
|
770
|
+
if self._is_month_allowed(candidate):
|
|
771
|
+
self._display_date = candidate
|
|
772
|
+
self._refresh_calendar()
|
|
773
|
+
|
|
774
|
+
def _on_reset_date(self, *_args) -> None:
|
|
775
|
+
self._display_date = date(self._initial_date.year, self._initial_date.month, 1)
|
|
776
|
+
self._selected_date = self._initial_date
|
|
777
|
+
self._range_start = self._initial_date
|
|
778
|
+
self._range_end = None
|
|
779
|
+
self._refresh_calendar()
|
|
780
|
+
self.event_generate(
|
|
781
|
+
"<<DateSelect>>", data={"date": self._selected_date, "range": (self._range_start, self._range_end)})
|
|
782
|
+
|
|
783
|
+
def _on_date_selected_by_date(self, target: date) -> None:
|
|
784
|
+
if self._is_disabled(target):
|
|
785
|
+
return
|
|
786
|
+
if self._selection_mode == "range":
|
|
787
|
+
if self._range_start is None or self._range_end is not None:
|
|
788
|
+
self._range_start = target
|
|
789
|
+
self._range_end = None
|
|
790
|
+
else:
|
|
791
|
+
if target < self._range_start:
|
|
792
|
+
self._range_start, target = target, self._range_start
|
|
793
|
+
self._range_end = target
|
|
794
|
+
self._selected_date = target
|
|
795
|
+
else:
|
|
796
|
+
self._selected_date = target
|
|
797
|
+
self._range_start = target
|
|
798
|
+
self._range_end = None
|
|
799
|
+
|
|
800
|
+
self._draw_calendar()
|
|
801
|
+
self.event_generate(
|
|
802
|
+
"<<DateSelect>>", data={"date": self._selected_date, "range": (self._range_start, self._range_end)})
|
|
803
|
+
|
|
804
|
+
# --- helpers ------------------------------------------------------
|
|
805
|
+
def _lock_size(self) -> None:
|
|
806
|
+
if self._locked_size is None:
|
|
807
|
+
self.update_idletasks()
|
|
808
|
+
self._locked_size = (self.winfo_width(), self.winfo_height())
|
|
809
|
+
try:
|
|
810
|
+
self.minsize(*self._locked_size)
|
|
811
|
+
except Exception:
|
|
812
|
+
pass
|
|
813
|
+
|
|
814
|
+
def _header_columns(self) -> list[str]:
|
|
815
|
+
localized_weekdays = [MessageCatalog.translate(token) for token in _WEEKDAY_TOKENS]
|
|
816
|
+
return localized_weekdays[self._first_weekday:] + localized_weekdays[: self._first_weekday]
|
|
817
|
+
|
|
818
|
+
def _is_disabled(self, d: date) -> bool:
|
|
819
|
+
if d in self._disabled_dates:
|
|
820
|
+
return True
|
|
821
|
+
if self._min_date and d < self._min_date:
|
|
822
|
+
return True
|
|
823
|
+
if self._max_date and d > self._max_date:
|
|
824
|
+
return True
|
|
825
|
+
return False
|
|
826
|
+
|
|
827
|
+
def _style_for_date(self, d: date, in_month: bool, disabled: bool) -> tuple[str | None, str]:
|
|
828
|
+
"""Return (accent, variant) tuple for the given date."""
|
|
829
|
+
if disabled or not in_month:
|
|
830
|
+
return (None, "ghost")
|
|
831
|
+
if self._selection_mode == "range" and self._range_start:
|
|
832
|
+
end = self._range_end
|
|
833
|
+
start = self._range_start
|
|
834
|
+
if end and start:
|
|
835
|
+
if start <= d <= end:
|
|
836
|
+
if start < d < end:
|
|
837
|
+
return (self._accent, "calendar-range")
|
|
838
|
+
return (self._accent, "calendar-date")
|
|
839
|
+
return (self._accent, "calendar-day")
|
|
840
|
+
|
|
841
|
+
def _is_selected(self, d: date) -> bool:
|
|
842
|
+
if self._selection_mode == "range":
|
|
843
|
+
if not self._range_start:
|
|
844
|
+
return False
|
|
845
|
+
if self._range_end:
|
|
846
|
+
return self._range_start <= d <= self._range_end
|
|
847
|
+
return d == self._range_start
|
|
848
|
+
return d == self._selected_date
|
|
849
|
+
|
|
850
|
+
def _is_month_allowed(self, candidate: date) -> bool:
|
|
851
|
+
if self._min_date and candidate < self._min_date.replace(day=1):
|
|
852
|
+
return False
|
|
853
|
+
if self._max_date and candidate > self._max_date.replace(day=1):
|
|
854
|
+
return False
|
|
855
|
+
return True
|
|
856
|
+
|
|
857
|
+
def _set_title(self) -> None:
|
|
858
|
+
self._title_var.set(_format_month_year(self._display_date))
|
|
859
|
+
|
|
860
|
+
def _square_button_padding(self) -> tuple[int, int, int, int]:
|
|
861
|
+
"""Calculate padding for square calendar day buttons based on caption font metrics."""
|
|
862
|
+
from tkinter import font
|
|
863
|
+
f = font.nametofont('caption')
|
|
864
|
+
linespace = f.metrics()['linespace']
|
|
865
|
+
text_width = f.measure('00')
|
|
866
|
+
# For square buttons with centered text: width = height
|
|
867
|
+
# width = text_width + 2*h_pad, height = linespace + v_pad
|
|
868
|
+
# Use symmetric h_pad and add v_pad to balance
|
|
869
|
+
diff = linespace - text_width # 15 - 12 = 3
|
|
870
|
+
h_pad = (diff + 1) // 2 # round up: 2
|
|
871
|
+
v_pad = 2 * h_pad - diff # balance: 2*2 - 3 = 1
|
|
872
|
+
# Return (left, top, right, bottom) - add top padding to nudge text down
|
|
873
|
+
return (h_pad, 2, h_pad, v_pad)
|
|
874
|
+
|
|
875
|
+
@staticmethod
|
|
876
|
+
def _add_months(d: date, n: int) -> date:
|
|
877
|
+
year = d.year + (d.month - 1 + n) // 12
|
|
878
|
+
month = (d.month - 1 + n) % 12 + 1
|
|
879
|
+
return date(year, month, 1)
|
|
880
|
+
|
|
881
|
+
@staticmethod
|
|
882
|
+
def _coerce_date(value: date | datetime | str | None) -> date | None:
|
|
883
|
+
if value is None:
|
|
884
|
+
return None
|
|
885
|
+
if isinstance(value, date) and not isinstance(value, datetime):
|
|
886
|
+
return value
|
|
887
|
+
if isinstance(value, datetime):
|
|
888
|
+
return value.date()
|
|
889
|
+
if isinstance(value, str):
|
|
890
|
+
for fmt in ("%Y-%m-%d", "%m/%d/%Y"):
|
|
891
|
+
try:
|
|
892
|
+
return datetime.strptime(value, fmt).date()
|
|
893
|
+
except Exception:
|
|
894
|
+
continue
|
|
895
|
+
try:
|
|
896
|
+
return datetime.fromisoformat(value).date()
|
|
897
|
+
except Exception:
|
|
898
|
+
return None
|
|
899
|
+
return None
|
|
900
|
+
|
|
901
|
+
def _normalize_range(
|
|
902
|
+
self,
|
|
903
|
+
start: date | datetime | str | None,
|
|
904
|
+
end: date | datetime | str | None = None,
|
|
905
|
+
) -> tuple[date | None, date | None]:
|
|
906
|
+
"""Normalize a date range, ensuring start <= end if both are present."""
|
|
907
|
+
s = self._coerce_date(start)
|
|
908
|
+
e = self._coerce_date(end)
|
|
909
|
+
if s is not None and e is not None and e < s:
|
|
910
|
+
s, e = e, s
|
|
911
|
+
return (s, e)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
__all__ = ["Calendar"]
|