dateentry.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. # -*- coding: utf-8 -*-
  2. """
  3. tkcalendar - Calendar and DateEntry widgets for Tkinter
  4. Copyright 2017-2019 Juliette Monsel <j_4321@protonmail.com>
  5. with contributions from:
  6. - Neal Probert (https://github.com/nprobert)
  7. - arahorn28 (https://github.com/arahorn28)
  8. tkcalendar is free software: you can redistribute it and/or modify
  9. it under the terms of the GNU General Public License as published by
  10. the Free Software Foundation, either version 3 of the License, or
  11. (at your option) any later version.
  12. tkcalendar is distributed in the hope that it will be useful,
  13. but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. GNU General Public License for more details.
  16. You should have received a copy of the GNU General Public License
  17. along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. DateEntry widget
  19. """
  20. from sys import platform
  21. try:
  22. import tkinter as tk
  23. from tkinter import ttk
  24. except ImportError:
  25. import Tkinter as tk
  26. import ttk
  27. from tkcalendar.calendar_ import Calendar
  28. # temporary fix for issue #61 and https://bugs.python.org/issue38661
  29. MAPS = {'winnative': {'focusfill': [('readonly', 'focus', 'SystemHighlight')],
  30. 'foreground': [('disabled', 'SystemGrayText'),
  31. ('readonly', 'focus', 'SystemHighlightText')],
  32. 'selectforeground': [('!focus', 'SystemWindowText')],
  33. 'fieldbackground': [('readonly', 'SystemButtonFace'),
  34. ('disabled', 'SystemButtonFace')],
  35. 'selectbackground': [('!focus', 'SystemWindow')]},
  36. 'clam': {'foreground': [('readonly', 'focus', '#ffffff')],
  37. 'fieldbackground': [('readonly', 'focus', '#4a6984'), ('readonly', '#dcdad5')],
  38. 'background': [('active', '#eeebe7'), ('pressed', '#eeebe7')],
  39. 'arrowcolor': [('disabled', '#999999')]},
  40. 'alt': {'fieldbackground': [('readonly', '#d9d9d9'),
  41. ('disabled', '#d9d9d9')],
  42. 'arrowcolor': [('disabled', '#a3a3a3')]},
  43. 'default': {'fieldbackground': [('readonly', '#d9d9d9'), ('disabled', '#d9d9d9')],
  44. 'arrowcolor': [('disabled', '#a3a3a3')]},
  45. 'classic': {'fieldbackground': [('readonly', '#d9d9d9'), ('disabled', '#d9d9d9')]},
  46. 'vista': {'focusfill': [('readonly', 'focus', 'SystemHighlight')],
  47. 'foreground': [('disabled', 'SystemGrayText'),
  48. ('readonly', 'focus', 'SystemHighlightText')],
  49. 'selectforeground': [('!focus', 'SystemWindowText')],
  50. 'selectbackground': [('!focus', 'SystemWindow')]},
  51. 'xpnative': {'focusfill': [('readonly', 'focus', 'SystemHighlight')],
  52. 'foreground': [('disabled', 'SystemGrayText'),
  53. ('readonly', 'focus', 'SystemHighlightText')],
  54. 'selectforeground': [('!focus', 'SystemWindowText')],
  55. 'selectbackground': [('!focus', 'SystemWindow')]}}
  56. class DateEntry(ttk.Entry):
  57. """Date selection entry with drop-down calendar."""
  58. entry_kw = {'exportselection': 1,
  59. 'invalidcommand': '',
  60. 'justify': 'left',
  61. 'show': '',
  62. 'cursor': 'xterm',
  63. 'style': '',
  64. 'state': 'normal',
  65. 'takefocus': 'ttk::takefocus',
  66. 'textvariable': '',
  67. 'validate': 'none',
  68. 'validatecommand': '',
  69. 'width': 12,
  70. 'xscrollcommand': ''}
  71. def __init__(self, master=None, **kw):
  72. """
  73. Create an entry with a drop-down calendar to select a date.
  74. When the entry looses focus, if the user input is not a valid date,
  75. the entry content is reset to the last valid date.
  76. Keyword Options
  77. ---------------
  78. usual ttk.Entry options and Calendar options.
  79. The Calendar option 'cursor' has been renamed
  80. 'calendar_cursor' to avoid name clashes with the
  81. corresponding ttk.Entry option.
  82. Virtual event
  83. -------------
  84. A ``<<DateEntrySelected>>`` event is generated each time
  85. the user selects a date.
  86. """
  87. # sort keywords between entry options and calendar options
  88. kw['selectmode'] = 'day'
  89. entry_kw = {}
  90. style = kw.pop('style', 'DateEntry')
  91. for key in self.entry_kw:
  92. entry_kw[key] = kw.pop(key, self.entry_kw[key])
  93. entry_kw['font'] = kw.get('font', None)
  94. self._cursor = entry_kw['cursor'] # entry cursor
  95. kw['cursor'] = kw.pop('calendar_cursor', None)
  96. ttk.Entry.__init__(self, master, **entry_kw)
  97. self._determine_downarrow_name_after_id = ''
  98. # drop-down calendar
  99. self._top_cal = tk.Toplevel(self)
  100. self._top_cal.withdraw()
  101. if platform == "linux":
  102. self._top_cal.attributes('-type', 'DROPDOWN_MENU')
  103. self._top_cal.overrideredirect(True)
  104. self._calendar = Calendar(self._top_cal, **kw)
  105. self._calendar.pack()
  106. # locale date parsing / formatting
  107. self.format_date = self._calendar.format_date
  108. self.parse_date = self._calendar.parse_date
  109. # style
  110. self._theme_name = '' # to detect theme changes
  111. self.style = ttk.Style(self)
  112. self._setup_style()
  113. self.configure(style=style)
  114. # add validation to Entry so that only dates in the locale's format
  115. # are accepted
  116. validatecmd = self.register(self._validate_date)
  117. self.configure(validate='focusout',
  118. validatecommand=validatecmd)
  119. # initially selected date
  120. self._date = self._calendar.selection_get()
  121. if self._date is None:
  122. today = self._calendar.date.today()
  123. year = kw.get('year', today.year)
  124. month = kw.get('month', today.month)
  125. day = kw.get('day', today.day)
  126. try:
  127. self._date = self._calendar.date(year, month, day)
  128. except ValueError:
  129. self._date = today
  130. self._set_text(self.format_date(self._date))
  131. # --- bindings
  132. # reconfigure style if theme changed
  133. self.bind('<<ThemeChanged>>',
  134. lambda e: self.after(10, self._on_theme_change))
  135. # determine new downarrow button bbox
  136. self.bind('<Configure>', self._determine_downarrow_name)
  137. self.bind('<Map>', self._determine_downarrow_name)
  138. # handle appearence to make the entry behave like a Combobox but with
  139. # a drop-down calendar instead of a drop-down list
  140. self.bind('<Leave>', lambda e: self.state(['!active']))
  141. self.bind('<Motion>', self._on_motion)
  142. self.bind('<ButtonPress-1>', self._on_b1_press)
  143. # update entry content when date is selected in the Calendar
  144. self._calendar.bind('<<CalendarSelected>>', self._select)
  145. # hide calendar if it looses focus
  146. self._calendar.bind('<FocusOut>', self._on_focus_out_cal)
  147. def __getitem__(self, key):
  148. """Return the resource value for a KEY given as string."""
  149. return self.cget(key)
  150. def __setitem__(self, key, value):
  151. self.configure(**{key: value})
  152. def _setup_style(self, event=None):
  153. """Style configuration to make the DateEntry look like a Combobbox."""
  154. self.style.layout('DateEntry', self.style.layout('TCombobox'))
  155. self.update_idletasks()
  156. conf = self.style.configure('TCombobox')
  157. if conf:
  158. self.style.configure('DateEntry', **conf)
  159. maps = self.style.map('TCombobox')
  160. if maps:
  161. try:
  162. self.style.map('DateEntry', **maps)
  163. except tk.TclError:
  164. # temporary fix for issue #61 and https://bugs.python.org/issue38661
  165. maps = MAPS.get(self.style.theme_use(), MAPS['default'])
  166. self.style.map('DateEntry', **maps)
  167. try:
  168. self.after_cancel(self._determine_downarrow_name_after_id)
  169. except ValueError:
  170. # nothing to cancel
  171. pass
  172. self._determine_downarrow_name_after_id = self.after(10, self._determine_downarrow_name)
  173. def _determine_downarrow_name(self, event=None):
  174. """Determine downarrow button name."""
  175. try:
  176. self.after_cancel(self._determine_downarrow_name_after_id)
  177. except ValueError:
  178. # nothing to cancel
  179. pass
  180. if self.winfo_ismapped():
  181. self.update_idletasks()
  182. y = self.winfo_height() // 2
  183. x = self.winfo_width() - 10
  184. name = self.identify(x, y)
  185. if name:
  186. self._downarrow_name = name
  187. else:
  188. self._determine_downarrow_name_after_id = self.after(10, self._determine_downarrow_name)
  189. def _on_motion(self, event):
  190. """Set widget state depending on mouse position to mimic Combobox behavior."""
  191. x, y = event.x, event.y
  192. if 'disabled' not in self.state():
  193. if self.identify(x, y) == self._downarrow_name:
  194. self.state(['active'])
  195. ttk.Entry.configure(self, cursor='arrow')
  196. else:
  197. self.state(['!active'])
  198. ttk.Entry.configure(self, cursor=self._cursor)
  199. def _on_theme_change(self):
  200. theme = self.style.theme_use()
  201. if self._theme_name != theme:
  202. # the theme has changed, update the DateEntry style to look like a combobox
  203. self._theme_name = theme
  204. self._setup_style()
  205. def _on_b1_press(self, event):
  206. """Trigger self.drop_down on downarrow button press and set widget state to ['pressed', 'active']."""
  207. x, y = event.x, event.y
  208. if (('disabled' not in self.state()) and self.identify(x, y) == self._downarrow_name):
  209. self.state(['pressed'])
  210. self.drop_down()
  211. def _on_focus_out_cal(self, event):
  212. """Withdraw drop-down calendar when it looses focus."""
  213. if self.focus_get() is not None:
  214. if self.focus_get() == self:
  215. x, y = event.x, event.y
  216. if (type(x) != int or type(y) != int or self.identify(x, y) != self._downarrow_name):
  217. self._top_cal.withdraw()
  218. self.state(['!pressed'])
  219. else:
  220. self._top_cal.withdraw()
  221. self.state(['!pressed'])
  222. elif self.grab_current():
  223. # 'active' won't be in state because of the grab
  224. x, y = self._top_cal.winfo_pointerxy()
  225. xc = self._top_cal.winfo_rootx()
  226. yc = self._top_cal.winfo_rooty()
  227. w = self._top_cal.winfo_width()
  228. h = self._top_cal.winfo_height()
  229. if xc <= x <= xc + w and yc <= y <= yc + h:
  230. # re-focus calendar so that <FocusOut> will be triggered next time
  231. self._calendar.focus_force()
  232. else:
  233. self._top_cal.withdraw()
  234. self.state(['!pressed'])
  235. else:
  236. if 'active' in self.state():
  237. # re-focus calendar so that <FocusOut> will be triggered next time
  238. self._calendar.focus_force()
  239. else:
  240. self._top_cal.withdraw()
  241. self.state(['!pressed'])
  242. def _validate_date(self):
  243. """Date entry validation: only dates in locale '%x' format are accepted."""
  244. try:
  245. date = self.parse_date(self.get())
  246. self._date = self._calendar.check_date_range(date)
  247. if self._date != date:
  248. self._set_text(self.format_date(self._date))
  249. return False
  250. else:
  251. return True
  252. except (ValueError, IndexError):
  253. self._set_text(self.format_date(self._date))
  254. return False
  255. def _select(self, event=None):
  256. """Display the selected date in the entry and hide the calendar."""
  257. date = self._calendar.selection_get()
  258. if date is not None:
  259. self._set_text(self.format_date(date))
  260. self._date = date
  261. self.event_generate('<<DateEntrySelected>>')
  262. self._top_cal.withdraw()
  263. if 'readonly' not in self.state():
  264. self.focus_set()
  265. def _set_text(self, txt):
  266. """Insert text in the entry."""
  267. if 'readonly' in self.state():
  268. readonly = True
  269. self.state(('!readonly',))
  270. else:
  271. readonly = False
  272. self.delete(0, 'end')
  273. self.insert(0, txt)
  274. if readonly:
  275. self.state(('readonly',))
  276. def destroy(self):
  277. try:
  278. self.after_cancel(self._determine_downarrow_name_after_id)
  279. except ValueError:
  280. # nothing to cancel
  281. pass
  282. ttk.Entry.destroy(self)
  283. def drop_down(self):
  284. """Display or withdraw the drop-down calendar depending on its current state."""
  285. if self._calendar.winfo_ismapped():
  286. self._top_cal.withdraw()
  287. else:
  288. self._validate_date()
  289. date = self.parse_date(self.get())
  290. x = self.winfo_rootx()
  291. y = self.winfo_rooty() + self.winfo_height()
  292. if self.winfo_toplevel().attributes('-topmost'):
  293. self._top_cal.attributes('-topmost', True)
  294. else:
  295. self._top_cal.attributes('-topmost', False)
  296. self._top_cal.geometry('+%i+%i' % (x, y))
  297. self._top_cal.deiconify()
  298. self._calendar.focus_set()
  299. self._calendar.selection_set(date)
  300. def state(self, *args):
  301. """
  302. Modify or inquire widget state.
  303. Widget state is returned if statespec is None, otherwise it is
  304. set according to the statespec flags and then a new state spec
  305. is returned indicating which flags were changed. statespec is
  306. expected to be a sequence.
  307. """
  308. if args:
  309. # change cursor depending on state to mimic Combobox behavior
  310. states = args[0]
  311. if 'disabled' in states or 'readonly' in states:
  312. self.configure(cursor='arrow')
  313. elif '!disabled' in states or '!readonly' in states:
  314. self.configure(cursor='xterm')
  315. return ttk.Entry.state(self, *args)
  316. def keys(self):
  317. """Return a list of all resource names of this widget."""
  318. keys = list(self.entry_kw)
  319. keys.extend(self._calendar.keys())
  320. keys.append('calendar_cursor')
  321. return list(set(keys))
  322. def cget(self, key):
  323. """Return the resource value for a KEY given as string."""
  324. if key in self.entry_kw:
  325. return ttk.Entry.cget(self, key)
  326. elif key == 'calendar_cursor':
  327. return self._calendar.cget('cursor')
  328. else:
  329. return self._calendar.cget(key)
  330. def configure(self, cnf={}, **kw):
  331. """
  332. Configure resources of a widget.
  333. The values for resources are specified as keyword
  334. arguments. To get an overview about
  335. the allowed keyword arguments call the method :meth:`~DateEntry.keys`.
  336. """
  337. if not isinstance(cnf, dict):
  338. raise TypeError("Expected a dictionary or keyword arguments.")
  339. kwargs = cnf.copy()
  340. kwargs.update(kw)
  341. entry_kw = {}
  342. keys = list(kwargs.keys())
  343. for key in keys:
  344. if key in self.entry_kw:
  345. entry_kw[key] = kwargs.pop(key)
  346. font = kwargs.get('font', None)
  347. if font is not None:
  348. entry_kw['font'] = font
  349. self._cursor = str(entry_kw.get('cursor', self._cursor))
  350. if entry_kw.get('state') == 'readonly' and self._cursor == 'xterm' and 'cursor' not in entry_kw:
  351. entry_kw['cursor'] = 'arrow'
  352. self._cursor = 'arrow'
  353. ttk.Entry.configure(self, entry_kw)
  354. kwargs['cursor'] = kwargs.pop('calendar_cursor', None)
  355. self._calendar.configure(kwargs)
  356. if 'date_pattern' in kwargs or 'locale' in kwargs:
  357. self._set_text(self.format_date(self._date))
  358. config = configure
  359. def set_date(self, date):
  360. """
  361. Set the value of the DateEntry to date.
  362. date can be a datetime.date, a datetime.datetime or a string
  363. in locale '%x' format.
  364. """
  365. try:
  366. txt = self.format_date(date)
  367. except AssertionError:
  368. txt = str(date)
  369. try:
  370. self.parse_date(txt)
  371. except Exception:
  372. raise ValueError("%r is not a valid date." % date)
  373. self._set_text(txt)
  374. self._validate_date()
  375. def get_date(self):
  376. """Return the content of the DateEntry as a datetime.date instance."""
  377. self._validate_date()
  378. return self.parse_date(self.get())