2025, Oct 17 18:18

Как включить allowlist дат в tkcalendar и DateEntry

Как разрешить строго заданные даты в tkcalendar: allowed_dates в Calendar, отключение лишних дней, настройка DateEntry в Tkinter, согласование mindate и maxdate.

Ограничить выбор даты строгим списком разрешённых значений кажется простым, но во многих GUI‑стеках стандартный виджет умеет лишь задавать начальную и конечную границу. Если вы используете tk и tkcalendar, параметры mindate и maxdate надёжно ограничивают диапазон, однако они не мешают выбирать запрещённые дни внутри этих рамок. Ниже — краткое руководство по реализации настоящего allowlist, при котором в календаре можно щёлкнуть только по заранее разрешённым датам.

Проблема

Вам нужно, чтобы пользователь выбрал ровно одну дату из заранее заданного набора. Обычный DateEntry умеет ограничивать диапазон, но всё равно позволяет кликать по любому промежуточному дню, который попадает между mindate и maxdate.

import datetime as dt
import tkinter as tk
from tkinter import ttk
from tkcalendar import DateEntry
# fmt: off
allowed_str_days = [
    "2024-04-08", "2024-04-10", "2024-04-11", "2024-04-12",
    "2024-04-15", "2024-04-16", "2024-04-17", "2024-04-18", "2024-04-19",
    "2024-04-22",
    "2024-05-21", "2024-05-22", "2024-05-23", "2024-05-24",
    "2024-05-27", "2024-05-28", "2024-05-29", "2024-05-30", "2024-05-31",
    "2024-06-03", "2024-06-04", "2024-06-05", "2024-06-07",
    "2024-06-10", "2024-06-11", "2024-06-12", "2024-06-13", "2024-06-14",
]
# fmt: on
app = tk.Tk()
picked_var = tk.StringVar()
selector = DateEntry(
    app,
    textvariable=picked_var,
    date_pattern="yyyy-mm-dd",
    mindate=dt.date.fromisoformat(allowed_str_days[0]),
    maxdate=dt.date.fromisoformat(allowed_str_days[-1]),
)
selector.pack()
app.mainloop()

Этот код ограничивает самую раннюю и самую позднюю доступные даты, но не исключает запрещённые дни внутри этих границ.

Что происходит на самом деле

Calendar и DateEntry в tkcalendar поддерживают ограничения диапазона через mindate и maxdate. Встроенного механизма, который бы разрешал только разреженное подмножество дат внутри этого диапазона, нет. Чтобы получить настоящий allowlist, нужно активно отключать каждую дату, которой нет в списке. Это требует изменений в том, как рендерятся ячейки календаря и как валидируется выбор даты.

Решение

Подход — расширить базовый Calendar новой опцией allowed_dates. Эта опция принимает список объектов datetime.date и во время отрисовки отключает все даты, отсутствующие в списке, в пределах от allowed_dates[0] до allowed_dates[-1]. Суть реализуется там, где отображается календарь: пройтись по релевантным дням и отключить ячейки, не входящие в allowlist.

# внутри пользовательского класса Calendar
# --- отрисовка сетки
def _render_grid(self):
    # ... существующий код отрисовки вида месяца ...
    allowlist = self['allowed_dates']
    if allowlist is not None:
        import datetime as dt
        day_step = dt.timedelta(days=1)
        cur_day = allowlist[0]
        last_day = allowlist[-1]
        while cur_day <= last_day:
            if cur_day not in allowlist:
                row_idx, col_idx = self._get_day_coords(cur_day)
                if row_idx is not None:
                    print(cur_day, row_idx, col_idx, '!disabled')
                    self._calendar[row_idx][col_idx].state(['disabled'])
            cur_day += day_step

С этим в арсенале можно передать allowed_dates и сделать кликабельными только эти клетки. Строковый список нужно сперва преобразовать в datetime.date; далее можно использовать либо Calendar, либо DateEntry из вашего модуля. Ниже — исполняемый пример, который также показывает, как динамически добавлять и удалять даты из списка, одновременно поддерживая mindate и maxdate в соответствии с новыми границами.

import datetime as dt
import tkinter as tk
# from tkinter import ttk
# from tkcalendar import DateEntry
from mycalendar import Calendar
from mycalendar import DateEntry
# fmt: off
raw_days = [
    "2024-04-08", "2024-04-10", "2024-04-11", "2024-04-12",
    "2024-04-15", "2024-04-16", "2024-04-17", "2024-04-18", "2024-04-19",
    "2024-04-22",
    "2024-05-21", "2024-05-22", "2024-05-23", "2024-05-24",
    "2024-05-27", "2024-05-28", "2024-05-29", "2024-05-30", "2024-05-31",
    "2024-06-03", "2024-06-04", "2024-06-05", "2024-06-07",
    "2024-06-10", "2024-06-11", "2024-06-12", "2024-06-13", "2024-06-14",
]
# fmt: on
ui = tk.Tk()
whitelist_dt = [dt.date.fromisoformat(d) for d in raw_days]
# Пример Calendar
tk.Label(ui, text="Calendar").pack()
cal_widget = Calendar(
    ui,
    date_pattern="yyyy-mm-dd",
    mindate=whitelist_dt[0],
    maxdate=whitelist_dt[-1],
    allowed_dates=whitelist_dt,
    locale="en_GB.utf-8",
)
cal_widget.pack()
entry_var = tk.StringVar()
# Пример DateEntry
tk.Label(ui, text="DateEntry").pack()
entry_widget = DateEntry(
    ui,
    textvariable=entry_var,
    date_pattern="yyyy-mm-dd",
    mindate=whitelist_dt[0],
    maxdate=whitelist_dt[-1],
    allowed_dates=whitelist_dt,
    locale="en_GB.utf-8",
)
entry_widget.pack()
# В качестве цели может выступать и Calendar, и DateEntry
# target = cal_widget
target = entry_widget
def show_whitelist():
    for d in target['allowed_dates']:
        print('allowed:', d)
btn_show = tk.Button(ui, text="Show Allowed Dates", command=show_whitelist)
btn_show.pack(fill='x')
def add_whitelisted(day_str):
    day_obj = dt.date.fromisoformat(day_str)
    if day_obj not in target['allowed_dates']:
        print('add allowed:', day_str)
        target['allowed_dates'].append(day_obj)
        target['allowed_dates'] = sorted(target['allowed_dates'])
        if target['allowed_dates'][0] < target['mindate']:
            target['mindate'] = target['allowed_dates'][0]
        if target['allowed_dates'][-1] > target['maxdate']:
            target['maxdate'] = target['allowed_dates'][-1]
        if target == cal_widget:
            target._render_grid()
for s in ('2024-06-06', '2024-06-26', '2024-07-10'):
    tk.Button(ui, text=f"Add Allowed Date: {s}", command=lambda x=s: add_whitelisted(x)).pack(fill='x')
def remove_whitelisted(day_str):
    day_obj = dt.date.fromisoformat(day_str)
    if day_obj in target['allowed_dates']:
        print('remove allowed:', day_str)
        target['allowed_dates'].remove(day_obj)
        if target['mindate'] < target['allowed_dates'][0]:
            target['mindate'] = target['allowed_dates'][0]
        if target['maxdate'] > target['allowed_dates'][-1]:
            target['maxdate'] = target['allowed_dates'][-1]
        if target == cal_widget:
            target._render_grid()
for s in ('2024-06-06', '2024-06-26', '2024-07-10'):
    tk.Button(ui, text=f"Remove Allowed Date: {s}", command=lambda x=s: remove_whitelisted(x)).pack(fill='x')
ui.mainloop()

При таком подходе есть несколько наблюдений. Добавление 2024-06-06 в Calendar также отразилось в DateEntry, но удаление не распространилось аналогичным образом. Ещё одно поведение: DateEntry не добавлял дату, если она выходила за текущие mindate или maxdate. Поэтому важно держать правки allowlist согласованными с границами диапазона — так взаимодействие остаётся предсказуемым.

Зачем это нужно

В реальных сценариях планирования часто требуется разреженный набор доступных дат. Отключение произвольных дней внутри диапазона не входит в стандартные возможности виджетов, поэтому расширение Calendar с учётом allowlist — практичный способ обеспечить нужный пользовательский опыт без отказа от привычного DateEntry. Также важно понимать, что за валидацию и отображение дат отвечают несколько функций. Помимо процедуры, которая рисует сетку, проверки выбора вроде _check_sel_date и интеграция с DateEntry тоже могут потребовать доработок, чтобы allowed_dates соблюдался на всем протяжении взаимодействия.

Выводы

Если вам нужен настоящий allowlist, передавайте allowed_dates как объекты datetime.date и во время отрисовки календаря отключайте каждую неразрешённую ячейку. Преобразуйте входные строки через date.fromisoformat, держите список отсортированным и при добавлении или удалении элементов корректируйте mindate и maxdate, чтобы диапазон и allowlist оставались согласованными. При интеграции с DateEntry будьте готовы согласовать дополнительные функции, чтобы правила allowlist отражались повсюду, и загляните в исходники tkcalendar в модулях tkcalendar.calendar_ и tkcalendar.dateentry, чтобы найти соответствующие точки расширения. Если модификация календаря не вписывается в ваши сроки, рабочей альтернативой будет выпадающий список, привязанный к тому же allowlist.

Статья основана на вопросе с StackOverflow от Joooeey и ответе furas.