# modded and debugged version from source
# http://code.activestate.com/recipes/580725-tkinter-datepicker-like-the-jquery-ui-datepicker/
# Original author: Miguel Martinez Lopez

"""
These are the default bindings:
    Get focus : Show calendar
    Click button 1 on entry: Show calendar
    Click button 1 outsite calendar and entry: Hide calendar
    Escape: Hide calendar
    CTRL + PAGE UP: Move to the previous month.
    CTRL + PAGE DOWN: Move to the next month.
    CTRL + SHIFT + PAGE UP: Move to the previous year.
    CTRL + SHIFT + PAGE DOWN: Move to the next year.
    CTRL + LEFT: Move to the previous day.
    CTRL + RIGHT: Move to the next day.
    CTRL + UP: Move to the previous week.
    CTRL + DOWN: Move to the next week.
    CTRL + END: Close the datepicker and erase the date.
    CTRL + HOME: Move to the current month.
    CTRL + SPACE: Show date on calendar
    CTRL + Return: Set current selection to entry
"""

import calendar
import datetime

try:
    import Tkinter
    import tkFont
    import ttk

    from Tkconstants import CENTER, LEFT, RIGHT, N, E, W, S
    from Tkinter import StringVar
except ImportError: # py3k
    import tkinter as Tkinter
    import tkinter.font as tkFont
    import tkinter.ttk as ttk

    from tkinter.constants import CENTER, LEFT, RIGHT, N, E, W, S
    from tkinter import StringVar

def get_calendar(locale, fwday):
    # instantiate proper calendar class
    if locale is None:
        return calendar.TextCalendar(fwday)
    else:
        return calendar.LocaleTextCalendar(fwday, locale)


class Calendar(ttk.Frame):
    datetime = calendar.datetime.datetime
    timedelta = calendar.datetime.timedelta

    def __init__(self, master=None, year=None, month=None, firstweekday=calendar.MONDAY, locale=None, activebackground='#b1dcfb', activeforeground='black', selectbackground='#003eff', selectforeground='white', command=None, select=None, borderwidth=1, relief="solid", on_click_month_button=None):
        """
        WIDGET OPTIONS

            locale, firstweekday, year, month, selectbackground,
            selectforeground, activebackground, activeforeground, 
            command, borderwidth, relief, on_click_month_button
        """

        if year is None:
            year = self.datetime.now().year
        
        if month is None:
            month = self.datetime.now().month

        self._selected_date = None

        self._sel_bg = selectbackground 
        self._sel_fg = selectforeground

        self._act_bg = activebackground 
        self._act_fg = activeforeground
        
        self._norm_bg = "white"
        self._norm_fg = "grey30"
        
        self.on_click_month_button = on_click_month_button
        
        self._selection_is_visible = False
        self._command = command
        self._select  = select

        ttk.Frame.__init__(self, master, borderwidth=borderwidth, relief=relief)
        
        self.bind("<FocusIn>", lambda event:self.event_generate('<<DatePickerFocusIn>>'))
        self.bind("<FocusOut>", lambda event:self.event_generate('<<DatePickerFocusOut>>'))
    
        self._cal = get_calendar(locale, firstweekday)

        # custom ttk styles
        style = ttk.Style()
        style.layout('L.TButton', (
            [('Button.focus', {'children': [('Button.leftarrow', None)]})]
        ))
        style.layout('R.TButton', (
            [('Button.focus', {'children': [('Button.rightarrow', None)]})]
        ))

        self._font = tkFont.Font()
        
        self._header_var = StringVar()
        self._year_var   = StringVar()

        # header frame and its widgets
        hframe = ttk.Frame(self)
        lbtn = ttk.Button(hframe, style='L.TButton', command=self._on_press_year_left_button)
        lbtn.grid(row=0,column=0,sticky=E)
        
        datefont = tkFont.Font(weight='bold')
        self._header = ttk.Label(hframe, font=datefont,  anchor=CENTER,
         textvariable=self._year_var)
        self._header.grid(row=0,column=1)
        rbtn = ttk.Button(hframe, style='R.TButton', command=self._on_press_year_right_button)
        rbtn.grid(row=0,column=2,sticky=W)
        
        hframe2 = ttk.Frame(self)
        lbtn2 = ttk.Button(hframe, style='L.TButton',
                        command=self._on_press_left_button)
        lbtn2.grid(row=1,column=0)
        
        self._header2 = ttk.Label(hframe, width=12, anchor=CENTER,
                            textvariable=self._header_var)
        self._header2.grid(row=1,column=1)
        
        rbtn2 = ttk.Button(hframe, style='R.TButton',
                        command=self._on_press_right_button)
        rbtn2.grid(row=1,column=2)

        hframe.grid(row=0,column=0,columnspan=7,pady=2)
        
        self._day_labels = {}

        days_of_the_week = self._cal.formatweekheader(3).split()
 
        for i, day_of_the_week in enumerate(days_of_the_week):
            Tkinter.Label(self, 
                    text=day_of_the_week, 
                    background='grey90').grid(row=2, column=i, sticky=N+E+W+S)

        for i in range(6):
            for j in range(7):
                self._day_labels[i,j] = label = Tkinter.Label(self, 
                            background = self._norm_bg,
                            foreground = self._norm_fg)
                
                label.grid(row=i+3, column=j, sticky=N+E+W+S)
                label.bind("<Enter>", 
                    lambda event: event.widget.configure(background=self._act_bg, 
                        foreground=self._act_fg))
                label.bind("<Leave>", 
                    lambda event: event.widget.configure(background=self._norm_bg,
                        foreground=self._norm_fg))

                label.bind("<1>", self._pressed)
        
        # adjust its columns width
        font = tkFont.Font()
        maxwidth = max(font.measure(text) for text in days_of_the_week)
        for i in range(7):
            self.grid_columnconfigure(i, minsize=maxwidth, weight=1)

        self._year = None
        self._month = None

        # insert dates in the currently empty calendar
        self._build_calendar(year, month)

    def _build_calendar(self, year, month):
        if not( self._year == year and self._month == month):
            self._year = year
            self._month = month

            # update header text (Month, YEAR)
            header = self._cal.formatmonthname(year, month, 1, False)
            theyear   = self._cal.formatyear(year, 0)
            self._header_var.set(header.title())
            self._year_var.set(year)

            # update calendar shown dates
            cal = self._cal.monthdayscalendar(year, month)

            for i in range(len(cal)):
                
                week = cal[i] 
                fmt_week = [('%02d' % day) if day else '' for day in week]
                
                for j, day_number in enumerate(fmt_week):
                    self._day_labels[i,j]["text"] = day_number

            if len(cal) < 6:
                for j in range(7):
                    self._day_labels[5,j]["text"] = ""

        if self._selected_date is not None and self._selected_date.year == self._year and self._selected_date.month == self._month:
            self._show_selection()

    def _find_label_coordinates(self, date):
        # original code was quite buggy, for exemple for month april of 2019
        # first_weekday_of_the_month = (date.weekday() - date.day) % 7
        # return divmod((first_weekday_of_the_month-1 - self._cal.firstweekday)%7 + date.day, 7)
        
        # new code seems to work much better, though I don't know exactly why ;-)
        first_weekday_of_the_month = datetime.date(date.year,date.month,1).weekday()
        if date.weekday()!=6:
            a=divmod((first_weekday_of_the_month - self._cal.firstweekday)%7 + date.day, 7)
            b=divmod((first_weekday_of_the_month -1 - self._cal.firstweekday)%7 + date.day, 7)
            return (min(a[0],b[0]),b[1])
        else:
            a=divmod((first_weekday_of_the_month - self._cal.firstweekday)%7 + date.day-1, 7)
            b=divmod((first_weekday_of_the_month -1 - self._cal.firstweekday)%7 + date.day-1, 7)
            return (min(a[0],b[0]),b[1]+1)
        
    def _show_selection(self):
        """Show a new selection."""

        i,j = self._find_label_coordinates(self._selected_date)

        label = self._day_labels[i,j]

        label.configure(background=self._sel_bg, foreground=self._sel_fg)

        label.unbind("<Enter>")
        label.unbind("<Leave>")
        
        self._selection_is_visible = True
        
    def _clear_selection(self):
        """Show a new selection."""
        i,j = self._find_label_coordinates(self._selected_date)

        label = self._day_labels[i,j]
        label.configure(background=self._norm_bg, foreground=self._norm_fg)

        label.bind("<Enter>", 
            lambda event: event.widget.configure(background=self._act_bg, 
                foreground=self._act_fg))
        label.bind("<Leave>", 
            lambda event: event.widget.configure(background=self._norm_bg, 
                foreground=self._norm_fg))

        self._selection_is_visible = False

    # Callback

    def _pressed(self, evt):
        """Clicked somewhere in the calendar."""
        
        text = evt.widget["text"]
        
        if text == "":
            return

        day_number = int(text)

        new_selected_date = datetime.datetime(self._year, self._month, day_number)
        if self._selected_date != new_selected_date:
            if self._selected_date is not None:
                self._clear_selection()
            
            self._selected_date = new_selected_date
            if self._select:
                self._select(self._selected_date)
            
            self._show_selection()
        
        if self._command:
            self._command(self._selected_date)

    def _on_press_year_left_button(self):
        self.prev_year()
        
        if self.on_click_month_button is not None:
            self.on_click_month_button()

    def _on_press_left_button(self):
        self.prev_month()
        
        if self.on_click_month_button is not None:
            self.on_click_month_button()
    
    def _on_press_year_right_button(self):
        self.next_year()

        if self.on_click_month_button is not None:
            self.on_click_month_button()
    
    def _on_press_right_button(self):
        self.next_month()

        if self.on_click_month_button is not None:
            self.on_click_month_button()
        
    def select_prev_day(self):
        """Updated calendar to show the previous day."""
        if self._selected_date is None:
            self._selected_date = datetime.datetime(self._year, self._month, 1)
        else:
            self._clear_selection()
            self._selected_date = self._selected_date - self.timedelta(days=1)
            
        if self._select:
            self._select(self._selected_date)

        self._build_calendar(self._selected_date.year, self._selected_date.month) # reconstruct calendar

    def select_next_day(self):
        """Update calendar to show the next day."""

        if self._selected_date is None:
            self._selected_date = datetime.datetime(self._year, self._month, 1)
        else:
            self._clear_selection()
            self._selected_date = self._selected_date + self.timedelta(days=1)
            
        if self._select:
            self._select(self._selected_date)

        self._build_calendar(self._selected_date.year, self._selected_date.month) # reconstruct calendar


    def select_prev_week_day(self):
        """Updated calendar to show the previous week."""
        if self._selected_date is None:
            self._selected_date = datetime.datetime(self._year, self._month, 1)
        else:
            self._clear_selection()
            self._selected_date = self._selected_date - self.timedelta(days=7)
            
        if self._select:
            self._select(self._selected_date)

        self._build_calendar(self._selected_date.year, self._selected_date.month) # reconstruct calendar

    def select_next_week_day(self):
        """Update calendar to show the next week."""
        if self._selected_date is None:
            self._selected_date = datetime.datetime(self._year, self._month, 1)
        else:
            self._clear_selection()
            self._selected_date = self._selected_date + self.timedelta(days=7)
            
        if self._select:
            self._select(self._selected_date)

        self._build_calendar(self._selected_date.year, self._selected_date.month) # reconstruct calendar

    def select_current_date(self):
        """Update calendar to current date."""
        if self._selection_is_visible: self._clear_selection()

        self._selected_date = datetime.datetime.now()
            
        if self._select:
            self._select(self._selected_date)
            
        self._build_calendar(self._selected_date.year, self._selected_date.month)

    def prev_month(self):
        """Updated calendar to show the previous week."""
        if self._selection_is_visible: self._clear_selection()
        
        date = self.datetime(self._year, self._month, 1) - self.timedelta(days=1)
        self._selected_date = date
            
        if self._select:
            self._select(self._selected_date)
            
        self._build_calendar(date.year, date.month) # reconstuct calendar

    def next_month(self):
        """Update calendar to show the next month."""
        if self._selection_is_visible: self._clear_selection()

        date = self.datetime(self._year, self._month, 1) + \
            self.timedelta(days=calendar.monthrange(self._year, self._month)[1] + 1)
        self._selected_date = date
            
        if self._select:
            self._select(self._selected_date)

        self._build_calendar(date.year, date.month) # reconstuct calendar

    def prev_year(self):
        """Updated calendar to show the previous year."""
        
        if self._selection_is_visible: self._clear_selection()

        date = self.datetime(self._year-1, self._month,1)
        self._selected_date = date
            
        if self._select:
            self._select(self._selected_date)

        self._build_calendar(date.year, date.month) # reconstruct calendar

    def next_year(self):
        """Update calendar to show the next year."""
        
        if self._selection_is_visible: self._clear_selection()

        date = self.datetime(self._year+1, self._month,1)
        self._selected_date = date
            
        if self._select:
            self._select(self._selected_date)

        self._build_calendar(date.year, date.month) # reconstruct calendar

    def get_selection(self):
        """Return a datetime representing the current selected date."""
        return self._selected_date
        
    selection = get_selection

    def set_selection(self, date):
        """Set the selected date."""
        if self._selected_date is not None and self._selected_date != date:
            self._clear_selection()

        self._selected_date = date
            
        if self._select:
            self._select(self._selected_date)

        self._build_calendar(date.year, date.month) # reconstruct calendar

# see this URL for date format information:
#     https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior

class Datepicker(ttk.Entry):
    def __init__(self, master, entrywidth=None, entrystyle=None, datevar=None, dateformat="%Y-%m-%d", onselect=None, firstweekday=calendar.MONDAY, locale=None, activebackground='#b1dcfb', activeforeground='black', selectbackground='#003eff', selectforeground='white', borderwidth=1, relief="solid"):
        
        if datevar is not None:
            self.date_var = datevar
        else:
            self.date_var = Tkinter.StringVar()
        
        entry_config = {}
        if entrywidth is not None:
            entry_config["width"] = entrywidth
            
        if entrystyle is not None:
            entry_config["style"] = entrystyle
    
        ttk.Entry.__init__(self, master, textvariable=self.date_var, **entry_config)

        self.date_format = dateformat
        
        self._is_calendar_visible = False
        self._on_select_date_command = onselect

        self.calendar_frame = Calendar(self.winfo_toplevel(), firstweekday=firstweekday, locale=locale, activebackground=activebackground, activeforeground=activeforeground, selectbackground=selectbackground, selectforeground=selectforeground, command=self._on_selected_date, select=self._on_select_date, on_click_month_button=lambda: self.focus())

        self.bind_all("<1>", self._on_click, "+")

        self.bind("<FocusOut>", lambda event: self._on_entry_focus_out())
        self.bind("<Escape>", lambda event: self.esc_hide_calendar())
        self.calendar_frame.bind("<<DatePickerFocusOut>>", lambda event: self._on_calendar_focus_out())


        # CTRL + PAGE UP: Move to the previous month.
        self.bind("<Control-Prior>", lambda event: self.calendar_frame.prev_month())
        
        # CTRL + PAGE DOWN: Move to the next month.
        self.bind("<Control-Next>", lambda event: self.calendar_frame.next_month())

        # CTRL + SHIFT + PAGE UP: Move to the previous year.
        self.bind("<Control-Shift-Prior>", lambda event: self.calendar_frame.prev_year())

        # CTRL + SHIFT + PAGE DOWN: Move to the next year.
        self.bind("<Control-Shift-Next>", lambda event: self.calendar_frame.next_year())
        

        # CTRL + LEFT: Move to the previous day.
        # self.bind("<Control-Left>", lambda event: self.calendar_frame.select_prev_day())
        def ctrl_left(event):
            self.calendar_frame.select_prev_day()
            return 'break'
        self.bind("<Control-Left>", ctrl_left)
        
        # CTRL + RIGHT: Move to the next day.
        # self.bind("<Control-Right>", lambda event: self.calendar_frame.select_next_day())
        def ctrl_right(event):
            self.calendar_frame.select_next_day()
            return 'break'
        self.bind("<Control-Right>", ctrl_right)
        
        # CTRL + UP: Move to the previous week.
        self.bind("<Control-Up>", lambda event: self.calendar_frame.select_prev_week_day())
        
        # CTRL + DOWN: Move to the next week.
        self.bind("<Control-Down>", lambda event: self.calendar_frame.select_next_week_day())

        # CTRL + END: Close the datepicker and erase the date.
        self.bind("<Control-End>", lambda event: self.erase())

        # CTRL + HOME: Move to the current month.
        self.bind("<Control-Home>", lambda event: self.calendar_frame.select_current_date())
        
        # CTRL + SPACE: Show date on calendar
        self.bind("<Control-space>", lambda event: self.show_date_on_calendar())
        
        # CTRL + Return: Set to entry current selection
        self.bind("<Control-Return>", lambda event: self.set_date_from_calendar())
        
        # MOD on focus
        self.bind("<FocusIn>", self._on_click, "+")
        
        if self.current_text=='':
            self.calendar_frame.select_current_date()

    def set_date_from_calendar(self):
        if self.is_calendar_visible:
            selected_date = self.calendar_frame.selection()

            if selected_date is not None:
                self.date_var.set(selected_date.strftime(self.date_format))
                
                if self._on_select_date_command is not None:
                    self._on_select_date_command(selected_date)

            self.hide_calendar()
      
    @property
    def current_text(self):
        return self.date_var.get()
        
    @current_text.setter
    def current_text(self, text):
        return self.date_var.set(text)
        
    @property
    def current_date(self):
        try:
            date = datetime.datetime.strptime(self.date_var.get(), self.date_format)
            return date
        except ValueError:
            return None
    
    @current_date.setter
    def current_date(self, date):
        self.date_var.set(date.strftime(self.date_format))
        
    @property
    def is_valid_date(self):
        if self.current_date is None:
            return False
        else:
            return True

    def show_date_on_calendar(self):
        date = self.current_date
        if date is not None:
            self.calendar_frame.set_selection(date)

        self.show_calendar()

    def show_calendar(self):
        self._old_date = self.current_text
        if not self._is_calendar_visible:
            self.calendar_frame.place(in_=self, relx=0, rely=1)
            self.calendar_frame.lift()

        self._is_calendar_visible = True

    def hide_calendar(self):
        if self._is_calendar_visible:
            self.calendar_frame.place_forget()
        
        self._is_calendar_visible = False

    def esc_hide_calendar(self):
        self.date_var.set(self._old_date)
        if self._is_calendar_visible:
            self.calendar_frame.place_forget()
        
        self._is_calendar_visible = False

    def erase(self):
        self.hide_calendar()
        self.date_var.set("")
    
    @property
    def is_calendar_visible(self):
        return self._is_calendar_visible

    def _on_entry_focus_out(self):
        if not str(self.focus_get()).startswith(str(self.calendar_frame)):
            self.hide_calendar()
        
    def _on_calendar_focus_out(self):
        if self.focus_get() != self:
            self.hide_calendar()

    def _on_selected_date(self, date):
        self.date_var.set(date.strftime(self.date_format))
        self.hide_calendar()
        
        if self._on_select_date_command is not None:
            self._on_select_date_command(date)

    def _on_select_date(self, date):
        self.date_var.set(date.strftime(self.date_format))
        
        if self._on_select_date_command is not None:
            self._on_select_date_command(date)

    def _on_click(self, event):
        str_widget = str(event.widget)

        if str_widget == str(self):
            if not self._is_calendar_visible:
                self.show_date_on_calendar()
        else:
            if not str_widget.startswith(str(self.calendar_frame)) and self._is_calendar_visible:
                self.hide_calendar()

# test code
if __name__ == "__main__":
    import sys
    if sys.platform == 'linux':
        locale = 'fr_FR.UTF-8'
    else:
        locale = 'French_France.1252'

    try:
        from Tkinter import Tk, Frame, Label
    except ImportError:
        from tkinter import Tk, Frame, Label
    
    root = Tk()
    root.geometry("500x600")
    
    main =Frame(root, pady =15, padx=15)
    main.pack(expand=True, fill="both")
    
    Label(main, justify="left", text=__doc__).pack(anchor="w", pady=(0,15))
    e = Datepicker(main,locale=locale)
    e.pack(anchor="w")
    e.focus()
    
    var = StringVar()
    var.set('2018-01-21')
    e = Datepicker(main,locale=locale, datevar=var)
    e.pack(anchor="w")
    
    if 'win' not in sys.platform:
        style = ttk.Style()
        style.theme_use('clam')

    root.mainloop()
