# -*- coding: utf-8 -*-
"""
tkcalendar - Calendar and DateEntry widgets for Tkinter
Copyright 2017-2019 Juliette Monsel <j_4321@protonmail.com>
with contributions from:
- Neal Probert (https://github.com/nprobert)
- arahorn28 (https://github.com/arahorn28)
tkcalendar is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
tkcalendar is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
DateEntry widget
"""
from sys import platform
try:
import tkinter as tk
from tkinter import ttk
except ImportError:
import Tkinter as tk
import ttk
from tkcalendar.calendar_ import Calendar
# temporary fix for issue #61 and https://bugs.python.org/issue38661
MAPS = {'winnative': {'focusfill': [('readonly', 'focus', 'SystemHighlight')],
'foreground': [('disabled', 'SystemGrayText'),
('readonly', 'focus', 'SystemHighlightText')],
'selectforeground': [('!focus', 'SystemWindowText')],
'fieldbackground': [('readonly', 'SystemButtonFace'),
('disabled', 'SystemButtonFace')],
'selectbackground': [('!focus', 'SystemWindow')]},
'clam': {'foreground': [('readonly', 'focus', '#ffffff')],
'fieldbackground': [('readonly', 'focus', '#4a6984'), ('readonly', '#dcdad5')],
'background': [('active', '#eeebe7'), ('pressed', '#eeebe7')],
'arrowcolor': [('disabled', '#999999')]},
'alt': {'fieldbackground': [('readonly', '#d9d9d9'),
('disabled', '#d9d9d9')],
'arrowcolor': [('disabled', '#a3a3a3')]},
'default': {'fieldbackground': [('readonly', '#d9d9d9'), ('disabled', '#d9d9d9')],
'arrowcolor': [('disabled', '#a3a3a3')]},
'classic': {'fieldbackground': [('readonly', '#d9d9d9'), ('disabled', '#d9d9d9')]},
'vista': {'focusfill': [('readonly', 'focus', 'SystemHighlight')],
'foreground': [('disabled', 'SystemGrayText'),
('readonly', 'focus', 'SystemHighlightText')],
'selectforeground': [('!focus', 'SystemWindowText')],
'selectbackground': [('!focus', 'SystemWindow')]},
'xpnative': {'focusfill': [('readonly', 'focus', 'SystemHighlight')],
'foreground': [('disabled', 'SystemGrayText'),
('readonly', 'focus', 'SystemHighlightText')],
'selectforeground': [('!focus', 'SystemWindowText')],
'selectbackground': [('!focus', 'SystemWindow')]}}
[docs]class DateEntry(ttk.Entry):
"""Date selection entry with drop-down calendar."""
entry_kw = {'exportselection': 1,
'invalidcommand': '',
'justify': 'left',
'show': '',
'cursor': 'xterm',
'style': '',
'state': 'normal',
'takefocus': 'ttk::takefocus',
'textvariable': '',
'validate': 'none',
'validatecommand': '',
'width': 12,
'xscrollcommand': ''}
[docs] def __init__(self, master=None, **kw):
"""
Create an entry with a drop-down calendar to select a date.
When the entry looses focus, if the user input is not a valid date,
the entry content is reset to the last valid date.
Keyword Options
---------------
usual ttk.Entry options and Calendar options.
The Calendar option 'cursor' has been renamed
'calendar_cursor' to avoid name clashes with the
corresponding ttk.Entry option.
Virtual event
-------------
A ``<<DateEntrySelected>>`` event is generated each time
the user selects a date.
"""
# sort keywords between entry options and calendar options
kw['selectmode'] = 'day'
entry_kw = {}
style = kw.pop('style', 'DateEntry')
for key in self.entry_kw:
entry_kw[key] = kw.pop(key, self.entry_kw[key])
entry_kw['font'] = kw.get('font', None)
self._cursor = entry_kw['cursor'] # entry cursor
kw['cursor'] = kw.pop('calendar_cursor', None)
ttk.Entry.__init__(self, master, **entry_kw)
self._determine_downarrow_name_after_id = ''
# drop-down calendar
self._top_cal = tk.Toplevel(self)
self._top_cal.withdraw()
if platform == "linux":
self._top_cal.attributes('-type', 'DROPDOWN_MENU')
self._top_cal.overrideredirect(True)
self._calendar = Calendar(self._top_cal, **kw)
self._calendar.pack()
# locale date parsing / formatting
self.format_date = self._calendar.format_date
self.parse_date = self._calendar.parse_date
# style
self._theme_name = '' # to detect theme changes
self.style = ttk.Style(self)
self._setup_style()
self.configure(style=style)
# add validation to Entry so that only dates in the locale's format
# are accepted
validatecmd = self.register(self._validate_date)
self.configure(validate='focusout',
validatecommand=validatecmd)
# initially selected date
self._date = self._calendar.selection_get()
if self._date is None:
today = self._calendar.date.today()
year = kw.get('year', today.year)
month = kw.get('month', today.month)
day = kw.get('day', today.day)
try:
self._date = self._calendar.date(year, month, day)
except ValueError:
self._date = today
self._set_text(self.format_date(self._date))
# --- bindings
# reconfigure style if theme changed
self.bind('<<ThemeChanged>>',
lambda e: self.after(10, self._on_theme_change))
# determine new downarrow button bbox
self.bind('<Configure>', self._determine_downarrow_name)
self.bind('<Map>', self._determine_downarrow_name)
# handle appearence to make the entry behave like a Combobox but with
# a drop-down calendar instead of a drop-down list
self.bind('<Leave>', lambda e: self.state(['!active']))
self.bind('<Motion>', self._on_motion)
self.bind('<ButtonPress-1>', self._on_b1_press)
# update entry content when date is selected in the Calendar
self._calendar.bind('<<CalendarSelected>>', self._select)
# hide calendar if it looses focus
self._calendar.bind('<FocusOut>', self._on_focus_out_cal)
def __getitem__(self, key):
"""Return the resource value for a KEY given as string."""
return self.cget(key)
def __setitem__(self, key, value):
self.configure(**{key: value})
def _setup_style(self, event=None):
"""Style configuration to make the DateEntry look like a Combobbox."""
self.style.layout('DateEntry', self.style.layout('TCombobox'))
self.update_idletasks()
conf = self.style.configure('TCombobox')
if conf:
self.style.configure('DateEntry', **conf)
maps = self.style.map('TCombobox')
if maps:
try:
self.style.map('DateEntry', **maps)
except tk.TclError:
# temporary fix for issue #61 and https://bugs.python.org/issue38661
maps = MAPS.get(self.style.theme_use(), MAPS['default'])
self.style.map('DateEntry', **maps)
try:
self.after_cancel(self._determine_downarrow_name_after_id)
except ValueError:
# nothing to cancel
pass
self._determine_downarrow_name_after_id = self.after(10, self._determine_downarrow_name)
def _determine_downarrow_name(self, event=None):
"""Determine downarrow button name."""
try:
self.after_cancel(self._determine_downarrow_name_after_id)
except ValueError:
# nothing to cancel
pass
if self.winfo_ismapped():
self.update_idletasks()
y = self.winfo_height() // 2
x = self.winfo_width() - 10
name = self.identify(x, y)
if name:
self._downarrow_name = name
else:
self._determine_downarrow_name_after_id = self.after(10, self._determine_downarrow_name)
def _on_motion(self, event):
"""Set widget state depending on mouse position to mimic Combobox behavior."""
x, y = event.x, event.y
if 'disabled' not in self.state():
if self.identify(x, y) == self._downarrow_name:
self.state(['active'])
ttk.Entry.configure(self, cursor='arrow')
else:
self.state(['!active'])
ttk.Entry.configure(self, cursor=self._cursor)
def _on_theme_change(self):
theme = self.style.theme_use()
if self._theme_name != theme:
# the theme has changed, update the DateEntry style to look like a combobox
self._theme_name = theme
self._setup_style()
def _on_b1_press(self, event):
"""Trigger self.drop_down on downarrow button press and set widget state to ['pressed', 'active']."""
x, y = event.x, event.y
if (('disabled' not in self.state()) and self.identify(x, y) == self._downarrow_name):
self.state(['pressed'])
self.drop_down()
def _on_focus_out_cal(self, event):
"""Withdraw drop-down calendar when it looses focus."""
if self.focus_get() is not None:
if self.focus_get() == self:
x, y = event.x, event.y
if (type(x) != int or type(y) != int or self.identify(x, y) != self._downarrow_name):
self._top_cal.withdraw()
self.state(['!pressed'])
else:
self._top_cal.withdraw()
self.state(['!pressed'])
elif self.grab_current():
# 'active' won't be in state because of the grab
x, y = self._top_cal.winfo_pointerxy()
xc = self._top_cal.winfo_rootx()
yc = self._top_cal.winfo_rooty()
w = self._top_cal.winfo_width()
h = self._top_cal.winfo_height()
if xc <= x <= xc + w and yc <= y <= yc + h:
# re-focus calendar so that <FocusOut> will be triggered next time
self._calendar.focus_force()
else:
self._top_cal.withdraw()
self.state(['!pressed'])
else:
if 'active' in self.state():
# re-focus calendar so that <FocusOut> will be triggered next time
self._calendar.focus_force()
else:
self._top_cal.withdraw()
self.state(['!pressed'])
def _validate_date(self):
"""Date entry validation: only dates in locale '%x' format are accepted."""
try:
date = self.parse_date(self.get())
self._date = self._calendar.check_date_range(date)
if self._date != date:
self._set_text(self.format_date(self._date))
return False
else:
return True
except (ValueError, IndexError):
self._set_text(self.format_date(self._date))
return False
def _select(self, event=None):
"""Display the selected date in the entry and hide the calendar."""
date = self._calendar.selection_get()
if date is not None:
self._set_text(self.format_date(date))
self._date = date
self.event_generate('<<DateEntrySelected>>')
self._top_cal.withdraw()
if 'readonly' not in self.state():
self.focus_set()
def _set_text(self, txt):
"""Insert text in the entry."""
if 'readonly' in self.state():
readonly = True
self.state(('!readonly',))
else:
readonly = False
self.delete(0, 'end')
self.insert(0, txt)
if readonly:
self.state(('readonly',))
[docs] def destroy(self):
try:
self.after_cancel(self._determine_downarrow_name_after_id)
except ValueError:
# nothing to cancel
pass
ttk.Entry.destroy(self)
[docs] def drop_down(self):
"""Display or withdraw the drop-down calendar depending on its current state."""
if self._calendar.winfo_ismapped():
self._top_cal.withdraw()
else:
self._validate_date()
date = self.parse_date(self.get())
x = self.winfo_rootx()
y = self.winfo_rooty() + self.winfo_height()
if self.winfo_toplevel().attributes('-topmost'):
self._top_cal.attributes('-topmost', True)
else:
self._top_cal.attributes('-topmost', False)
self._top_cal.geometry('+%i+%i' % (x, y))
self._top_cal.deiconify()
self._calendar.focus_set()
self._calendar.selection_set(date)
[docs] def state(self, *args):
"""
Modify or inquire widget state.
Widget state is returned if statespec is None, otherwise it is
set according to the statespec flags and then a new state spec
is returned indicating which flags were changed. statespec is
expected to be a sequence.
"""
if args:
# change cursor depending on state to mimic Combobox behavior
states = args[0]
if 'disabled' in states or 'readonly' in states:
self.configure(cursor='arrow')
elif '!disabled' in states or '!readonly' in states:
self.configure(cursor='xterm')
return ttk.Entry.state(self, *args)
[docs] def keys(self):
"""Return a list of all resource names of this widget."""
keys = list(self.entry_kw)
keys.extend(self._calendar.keys())
keys.append('calendar_cursor')
return list(set(keys))
[docs] def cget(self, key):
"""Return the resource value for a KEY given as string."""
if key in self.entry_kw:
return ttk.Entry.cget(self, key)
elif key == 'calendar_cursor':
return self._calendar.cget('cursor')
else:
return self._calendar.cget(key)
[docs] def configure(self, cnf={}, **kw):
"""
Configure resources of a widget.
The values for resources are specified as keyword
arguments. To get an overview about
the allowed keyword arguments call the method :meth:`~DateEntry.keys`.
"""
if not isinstance(cnf, dict):
raise TypeError("Expected a dictionary or keyword arguments.")
kwargs = cnf.copy()
kwargs.update(kw)
entry_kw = {}
keys = list(kwargs.keys())
for key in keys:
if key in self.entry_kw:
entry_kw[key] = kwargs.pop(key)
font = kwargs.get('font', None)
if font is not None:
entry_kw['font'] = font
self._cursor = str(entry_kw.get('cursor', self._cursor))
if entry_kw.get('state') == 'readonly' and self._cursor == 'xterm' and 'cursor' not in entry_kw:
entry_kw['cursor'] = 'arrow'
self._cursor = 'arrow'
ttk.Entry.configure(self, entry_kw)
kwargs['cursor'] = kwargs.pop('calendar_cursor', None)
self._calendar.configure(kwargs)
if 'date_pattern' in kwargs or 'locale' in kwargs:
self._set_text(self.format_date(self._date))
config = configure
[docs] def set_date(self, date):
"""
Set the value of the DateEntry to date.
date can be a datetime.date, a datetime.datetime or a string
in locale '%x' format.
"""
try:
txt = self.format_date(date)
except AssertionError:
txt = str(date)
try:
self.parse_date(txt)
except Exception:
raise ValueError("%r is not a valid date." % date)
self._set_text(txt)
self._validate_date()
[docs] def get_date(self):
"""Return the content of the DateEntry as a datetime.date instance."""
self._validate_date()
return self.parse_date(self.get())